diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52a3646..815d2a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - main - master + - "feat/**" pull_request: branches: - main @@ -12,29 +13,37 @@ on: jobs: test: - name: Test and Build + name: Typecheck, build and test runs-on: ubuntu-latest steps: - name: Checkout repo uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies - run: bun install + run: npm ci - name: Typecheck - run: bun run typecheck --if-present + run: npm run typecheck - name: Lint - run: bun run lint --if-present + run: npm run lint + + - name: Build + run: npm run build - name: Test - run: bun test + run: npm test - - name: Build - run: bun run build --if-present + - name: CLI smoke test + run: node dist/cli/index.js version diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3ed1a10..4ccf176 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,9 +1,15 @@ name: Publish to npm +# Publication is deliberately manual. Creating or publishing a GitHub release +# must never publish to npm automatically, so this workflow only runs when a +# maintainer dispatches it by hand for an authorised release. on: - push: - tags: - - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: "Existing release tag being published (informational only)" + required: false + type: string permissions: contents: read @@ -11,40 +17,34 @@ permissions: jobs: publish: - name: Publish package runs-on: ubuntu-latest steps: - - name: Checkout repo + - name: Checkout uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: latest - - name: Setup Node for npm publishing - uses: actions/setup-node@v4 - with: - node-version: 22 - registry-url: https://registry.npmjs.org/ - - name: Install dependencies - run: bun install --frozen-lockfile + run: npm ci - name: Typecheck - run: bun run typecheck --if-present + run: npm run typecheck - - name: Lint - run: bun run lint --if-present + - name: Build + run: npm run build - name: Test - run: bun test - - - name: Build - run: bun run build --if-present + run: npm test - - name: Publish to npm - run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Publish + run: npm publish --access public diff --git a/.gitignore b/.gitignore index 79b733c..3037494 100644 --- a/.gitignore +++ b/.gitignore @@ -1,38 +1,14 @@ -# Dependencies node_modules/ - -# Build output dist/ - -# Test output +dist-test/ coverage/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# OS -.DS_Store -Thumbs.db - -# Logs -*.log -npm-debug.log* - -# Temp -*.tmp -*.temp -.tmp/ - -# Env files with secrets (not tracked by gts anyway) +*.tsbuildinfo .env -.env.local -.env.*.local - -# Global cheapcode folder (shouldn't be in this version but just in case) -.cheapcode/ - -# Test files -tests/ \ No newline at end of file +.env.* +!.env.example +!.env.sample +!.env.template +*.log +.DS_Store +.opencode/state/ +.git-that-shit/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c33401..f3e0cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,53 +5,121 @@ All notable changes to Git That Shit will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-10-15 + +### Added + +- **OpenCode V2 plugin API** — migrated from `@opencode-ai/plugin` (V1) to `@opencode/plugin` 2.0.21 using + `Plugin.define({ id: "gitdamnit.git-that-shit", setup(ctx) })`. +- **V2 tool registration** — all eight tools (`gts_status`, `gts_list`, `gts_diff`, `gts_restore`, `gts_snapshot`, + `gts_prune`, `gts_config`, `gts_doctor`) registered via `ctx.tool.transform` with JSON Schema inputs and structured + results. +- **V2 lifecycle** — `ctx.tool.hook("execute.before")` for pre-operation/pre-edit snapshots and `ctx.event.subscribe()` + for `session.created`, `session.compaction.ended`, and `filesystem.changed`; the subscription is aborted on unload. +- **Real pruning** — `gts prune --yes` / `gts_prune` now rewrites the shadow history, keeping the newest `keep` + snapshots, with a mandatory safety `git bundle` and an atomic branch move. Manifest entries are rebuilt to reference + real commits. Preview remains the default. +- **Canonical path + legacy migration** — the shadow layout is documented and a legacy `.git-that-shit/snapshots` + repository is migrated instead of orphaned. +- **Global config redaction** — the global OpenCode config (`config.json`, `opencode.json`, `opencode.jsonc`) is + redacted before being stored under `global-opencode/`. +- **Text redaction** — YAML/TOML/`.env`-style/`.jsonc`/shell files, `Authorization` headers, and multiline PEM blocks + are redacted. +- **Atomic locking** — exclusive-create lock acquisition with stale-lock ownership checks. +- **Extended test suite** — 322 tests across 24 files, including snapshot-failure modes, restore security (symlink + traversal and substitution, traversal/absolute paths, interrupted writes, locking), filesystem permissions, + concurrency, restore behavior, prune, security, CLI, packaging, independence/interoperability, strict-mode blocking, + and the V2 plugin surface. +- **Documentation** — `docs/ARCHITECTURE.md`, `docs/SECURITY.md`, `docs/INTEROP.md`. +- **Independence hardening** — hooks are failure-isolated (a snapshot error can never break a tool or another plugin) + and read-only with respect to the tool event; verified that OpenCode awaits the async `execute.before` hook before + running the tool, so pre-operation snapshots are genuinely blocking. Added the independence/interoperability + standard, companion matrix, and Docker Plugin Test Lab compatibility notes. +- **Configurable snapshot-failure modes** — `snapshot.failureMode` adds `standard` (default: continue with a clear + warning, never claim a recovery point) and `strict` (opt-in: deny classified *destructive* operations through the + OpenCode V2 `permission.evaluate` gate when their protective snapshot fails). Blocking is restricted to explicitly + classified operations and is documented as best-effort where the SDK cannot guarantee it. +- **Verified snapshots** — `SnapshotResult.verified` is true only when the commit was confirmed to resolve in the shadow + repository; the plugin never treats an unverified snapshot as a recovery point. +- **Restore symlink hardening** — refuse symlinked destinations, reject destinations whose nearest existing ancestor + resolves outside the project, and write atomically via `O_EXCL|O_NOFOLLOW` temp file + `rename`. +- **Private storage permissions** — Git That Shit's own storage uses `0700` directories and `0600` files (config, + manifest, lock, snapshot copies, prune bundles), applied without recursing into user project directories. +- **Configurable git timeout** — `snapshot.gitTimeoutMs` bounds shadow-repository git subprocesses. +- **Shipped licence and attribution** — the package now carries the Apache-2.0 `LICENSE` and a + `NOTICE` attribution file. + +### Fixed + +- **Duplicate restore** — `restoreSnapshot` invoked the restore once under the lock and again outside it. It now + executes exactly once, fully inside the lock. +- **Snapshot locking** — the snapshot operation previously held the lock only around a disk-space probe, leaving + collection, redaction, copying, staging, commit, and manifest update unprotected. The entire operation is now under a + single exclusive project lock. +- **Lock TOCTOU** — lock acquisition used a read-then-write strategy that allowed two processes to both hold the lock. + It now uses an atomic exclusive create. +- **Secret exposure** — the global OpenCode config was copied without redaction. It is now redacted, and files that + cannot be safely processed fail closed and are skipped. +- **Mislabeled post-edit snapshots** — the previous `file.edited` handler took a snapshot after the edit but labelled it + `pre-edit`. Pre-edit snapshots now come only from `execute.before`; post-edit snapshots come from + `filesystem.changed`. +- **Version output** — the CLI printed a hardcoded `0.1.0`; it now reads the packaged version. +- **Path traversal** — `Tracker.shouldTrack` now rejects absolute paths and `..` segments. +- **Restore conflict handling** — conflicting files are reported and skipped unless `--force`/`--overwrite` is used; + restore no longer silently overwrites user changes. +- **Pre-restore snapshot blocking** — a `no-changes` pre-restore snapshot no longer aborts a restore. +- **Redacted files are never restored** — restore previously would have written `[REDACTED]` placeholders over live + config files, destroying real secrets. Redacted files are now skipped and reported. +- **Post-edit hook** — verified against OpenCode 2.0.21 that `filesystem.changed` does not fire for agent tool writes; + post-edit snapshots are now driven by `ctx.tool.hook("execute.after")`, with `filesystem.changed` as a fallback for + out-of-band changes. +- **CLI snapshot exit code** — `gts snapshot` exits `0` on "no-changes" instead of `1`. +- **CI** — workflows now install with `npm ci`, run typecheck/lint/build before tests, and require Bun for `bun test`. +- **Command-preview secret leak** — destructive-command previews (and manual snapshot messages) are text-redacted + before they can reach the commit message, the manifest, a result object, or a log line. +- **Unverified snapshots** — a commit that could not be resolved in the shadow repository is now reported as a failure + rather than a successful snapshot. +- **Pre-restore snapshot block** — restore no longer aborts on any pre-restore snapshot failure in standard mode; it + continues with a warning and claims no recovery point, while strict mode refuses the restore. +- **Restore write failures** — a failed or interrupted restore write is reported per file, leaves no temporary files + behind, and never partially applies through a symlink. + +### Changed + +- Package converted to ESM (`"type": "module"`) with type declarations; `dist/` is no longer committed (built on + publish). +- Dependency set reduced to `@opencode/plugin`; unused `minimist`, `vitest`, and `@opencode-ai/plugin` removed. +- Snapshot commit messages now include trigger, type, timestamp, tool, and session. +- `gts prune` accepts `--keep N` (space or `=`), `-k`, and `--yes`. + +### Security + +- Sensitive `*.sqlite`, `*.sqlite3`, `*.db`, `*.db-wal`, `*.db-shm` files are hard-excluded so live databases are never + captured mid-write. + ## [0.2.0] - 2026-05-13 ### Fixed -- **Shadow repo initialization**: Replaced `git.isGitRepo()` (which walks up to parent `.git`) with strict `hasOwnGitDir()` check requiring own `.git` directory at shadow path. Prevents false "already initialized" when project has a parent git repo. -- **CLI init idempotency**: Second `gts init` now correctly prints "Git That Shit already initialized" instead of re-initializing. -- **CLI list output format**: Changed from "Recent N snapshot(s):" to "Showing N snapshot:" for consistency. -- **Tracker glob regex**: Fixed leading-dot glob pattern generation (`pattern.slice(1)` → `pattern.slice(2)`) to prevent double-dot regex corruption. -- **Detector regex anchors**: Removed trailing `$` from all 17 `RISKY_PACKAGE_MANAGER` patterns so commands like `bun add lodash`, `yarn remove express` match correctly. -- **Git config flags**: Changed `["config", "local", ...]` → `["config", "--local", ...]` in `setLocalConfig` and `getLocalConfig`. -- **Lock file directory**: Added `ensureDir` before `writeFile` in `acquireLock` to prevent ENOENT on missing directories. -- **Snapshot file collection**: Removed redundant `.git-that-shit/` directory scan that was finding shadow repo snapshot copies and causing false "changes detected" on second snapshot. -- **Restore functions**: Exported `dryRunRestore`; added shadow-internal file filtering (`.git-that-shit/`, `.gitignore`) from restore results; added manifest hash validation for bad hash error handling. -- **Manifest git tracking**: Added `.git-that-shit/manifest.jsonl` to shadow repo `.gitignore` to prevent manifest appends from triggering spurious commits. -- **findFiles directory exclusion**: Added `.git-that-shit` to excluded directories to prevent walking into shadow repo during file discovery. - -### Tests -- All 238 tests passing (20 CLI, 29 tracker, 6 restore, 14 snapshot, plus security, detector, config, disk, git, lock, manifest, paths, redactor). -- Updated `listSnapshots` test to modify file between snapshots for realistic scenario. -- Updated CLI `snapshot` tests to modify files between init and snapshot. -- Updated tracker `.env` test to verify hard-exclude wins over `allowSensitiveFiles`. + +- Shadow repo initialization uses `hasOwnGitDir()` so a parent `.git` is not mistaken for the shadow repo. +- CLI `init` idempotency. +- Tracker leading-dot glob regex. +- Detector regex anchors for package-manager operations. +- Git config flags (`--local`). +- Lock file directory creation. +- Restore shadow-internal file filtering and manifest hash validation. +- Manifest git-tracking exclusion. ## [0.1.0] - 2026-05-12 ### Added -- **Initial release** - Git That Shit v0.1.0 -- Shadow git repository for CheapCode/OpenCode config and state snapshots -- CLI binary `gts` with 9 commands: status, list, diff, restore, snapshot, prune, config, init, doctor -- OpenCode-compatible plugin export `GitThatShitPlugin` -- Destructive operation detection with true/false positive distinction -- Restore dry-run safety model with explicit `--yes` confirmation required -- Secret exclusion and redaction policy — `.env` excluded by default -- Snapshot manifest design (`.git-that-shit/manifest.jsonl`) -- Doctor command for comprehensive health diagnostics -- Hook timing verification for `file.edited` -- Cross-platform support: Windows, Linux, macOS -- 8 plugin tools: `gts_status`, `gts_list`, `gts_diff`, `gts_restore`, `gts_snapshot`, `gts_prune`, `gts_config`, `gts_doctor` -- Pre-operation, pre-risky-op, session-start, post-compact, manual, and pre-restore snapshot types -- Lock file mechanism for concurrency safety -- Configurable include/exclude patterns -- Disk space and file size safety checks -### Fixed -- Glob pattern handling for `**` (double star) in file matching -- CLI command dynamic import issues +- Initial release: shadow git repository, `gts` CLI (9 commands), OpenCode plugin, destructive-op detection, dry-run + restore, secret exclusion/redaction, manifest, doctor, 8 plugin tools, lock file, configurable patterns, disk/size + checks. ### Known Limitations -- Hook timing: `file.edited` hook timing must be verified. If it fires after writes, Git That Shit uses it as a post-edit checkpoint only. -- No unsafe history rewriting: Rebase/reset of shadow repo not implemented in v0.1 -- No cloud sync: Local git only -- Prune command: Preview functionality only, actual pruning not implemented in v0.1 \ No newline at end of file + +- Prune was preview-only. +- No history rewriting of the shadow repo. +- Local git only. diff --git a/DECISION_LOG.md b/DECISION_LOG.md new file mode 100644 index 0000000..b014ccb --- /dev/null +++ b/DECISION_LOG.md @@ -0,0 +1,124 @@ +# Decision Log + +## How to Use This Log + +1. Copy the block below to the end of the `## Decisions` section. +2. Use the next free four-digit ID. IDs are never reused and never renumbered. +3. Date the record the day the decision was actually made (`YYYY-MM-DD`). +4. Record the alternatives you rejected and why. A decision without context is a + rumour, not a record. +5. Never rewrite history. If a decision changes, append a superseding record. + +```markdown +### D-0001 — Short imperative title + +- **Date:** YYYY-MM-DD +- **Status:** Accepted +- **Context:** the situation and the constraint that forced a choice +- **Alternatives Considered:** option A (rejected because ...), option B (rejected because ...) +- **Decision:** what was decided, in one or two sentences +- **Rationale:** why this option wins, including what it costs +- **Consequences:** what follows from it — new constraints, follow-up work, risks +- **Supersedes:** None +``` + +Allowed statuses: `Proposed`, `Accepted`, `Superseded`, `Deprecated`, `Rejected`. + +## Decisions + +### D-0001 — Merge the v0.3.0 PR with a merge commit + +- **Date:** 2026-10-16 +- **Status:** Accepted +- **Context:** PR #1 moves `feat/git-that-shit-v2-hardening` into `master`. The + release candidate is bound to a specific verified commit + `6069f916a0fac14439c3335e682f994466961851`, and the release evidence (Docker + plugin lab) names that SHA. All three merge methods are enabled on the + repository and no prior PR merge establishes a convention. +- **Alternatives Considered:** Squash and merge (rejected: the verified commit + SHA would not appear in `master`, breaking SHA-level traceability to the + evidence). Rebase and merge (rejected: replays commits with new SHAs, so the + verified commit would no longer exist in `master`). +- **Decision:** Merge PR #1 with a merge commit. +- **Rationale:** A merge commit preserves `6069f91` as an ancestor of the + released history, so the release evidence remains verifiable against a real + commit. The cost is a non-linear history, which is acceptable for a release + branch. +- **Consequences:** The tag `v0.3.0` points at the merge commit, and the release + notes must name both the merge SHA and the verified source SHA `6069f91`. +- **Supersedes:** None + +### D-0002 — Ship the Apache-2.0 licence text in the repository and package + +- **Date:** 2026-10-16 +- **Status:** Accepted +- **Context:** `package.json` and the README declare Apache-2.0, but no `LICENSE` + file existed in the repository, its history, or the npm tarball (0.2.2 shipped + none either). Apache-2.0 section 4 requires redistributors to include a copy of + the licence, so a public release without it is a compliance gap. +- **Alternatives Considered:** Release without a LICENSE file to match the 0.2.2 + precedent (rejected: perpetuates the compliance gap and cannot satisfy the + release requirement that licence and attribution be correct). Defer to a later + release (rejected: the release is the moment of distribution). +- **Decision:** Add the standard, unmodified Apache-2.0 `LICENSE` file at the + repository root; npm includes it in the tarball automatically. +- **Rationale:** Removes the compliance gap at the point of distribution at + negligible cost and with no functional change. +- **Consequences:** One additional release-metadata commit is added on top of the + verified commit; the verified commit `6069f91` remains an ancestor and is not + rewritten. +- **Supersedes:** None + +### D-0003 — Gate the npm publish workflow to manual dispatch + +- **Date:** 2026-10-16 +- **Status:** Accepted +- **Context:** `.github/workflows/publish.yml` triggered on `release: [published]` + and ran `npm publish`. Creating the `v0.3.0` GitHub release would therefore + have triggered an automatic npm publication, which this release explicitly + must not do. The workflow also carries `id-token: write`, so Trusted Publishing + could have made that push succeed. +- **Alternatives Considered:** Publish the GitHub release and accept the workflow + run (rejected: risks an unauthorised npm publication). Create a draft release + only (rejected: leaves the release page unpublished, failing the release goal). +- **Decision:** Change the trigger to `workflow_dispatch` only, so npm publication + is always an explicit, separately authorised action. +- **Rationale:** Keeps distribution to npm deliberate while still allowing a + normal GitHub release. Cost: releasing to npm now needs a manual workflow run. +- **Consequences:** `v0.3.0` will be GitHub-released but not npm-published; npm + publication remains an outstanding blocker until separately authorised. +- **Supersedes:** None + +### D-0004 — Record release state as GITHUB-RELEASED / NPM-PENDING + +- **Date:** 2026-10-16 +- **Status:** Accepted +- **Context:** npm `latest` for `@gitdamnit/opencode-git-that-shit` is `0.2.2`; + `0.3.0` is unpublished. The README's only install command is `npm install`, + which resolves to `0.2.2` until npm publication. The release machine is not + authenticated to npm (`ENEEDAUTH`). +- **Alternatives Considered:** Publish to npm as part of this release (rejected: + not authorised and not authenticated). Re-describe the release as complete + (rejected: a GitHub release does not make a package available through npm). +- **Decision:** Treat the release as `GITHUB-RELEASED / NPM-PENDING` and document + the npm gap explicitly in the README, CHANGELOG and release notes. +- **Rationale:** Honest state beats an unsupported "fully released" claim. +- **Consequences:** Installation instructions must not imply that `0.3.0` is + installable from npm; a follow-up npm publication is required. +- **Supersedes:** None + +### D-0005 — Adopt the three ProDoc Guard canonical documents + +- **Date:** 2026-10-16 +- **Status:** Accepted +- **Context:** The repository had no durable, in-repo record of requirements, + decisions or verified status; release context lived only in conversation. +- **Alternatives Considered:** Rely on README/CHANGELOG alone (rejected: they + describe the product, not the project's verified state or decision history). +- **Decision:** Adopt `PRODUCT_REQUIREMENTS.md`, `DECISION_LOG.md` and + `PROJECT_STATUS.md` as the canonical project documents. +- **Rationale:** Requirements, decisions and evidence stay in Git and survive + session loss. Cost: three more documents to keep current. +- **Consequences:** Substantial work must reconcile these documents with the + repository before progress is reported. +- **Supersedes:** None diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + 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. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..ce5ab19 --- /dev/null +++ b/NOTICE @@ -0,0 +1,11 @@ +Git That Shit +Copyright 2026 Evan Zellner (@gitdamnit) + +This product includes software developed at +https://github.com/gitdamnit/opencode-git-that-shit + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/PRODUCT_REQUIREMENTS.md b/PRODUCT_REQUIREMENTS.md new file mode 100644 index 0000000..0443d81 --- /dev/null +++ b/PRODUCT_REQUIREMENTS.md @@ -0,0 +1,88 @@ +# Product Requirements + +## Project Identity + +**Name:** Git That Shit (`@gitdamnit/opencode-git-that-shit`) +**Tagline:** Automatic pre-disaster snapshots for OpenCode AI coding agent config and state files. +**Repository:** https://github.com/gitdamnit/opencode-git-that-shit +**Licence:** Apache-2.0 +**Maintainer:** @gitdamnit (Evan Zellner) + +## Purpose + +AI coding agents act with elevated privileges over project files and execute +destructive operations confidently. Project git does not protect the files that +break an environment: config and agent-state files are frequently gitignored or +untracked, and git records change *after* it happens. Git That Shit takes a +verified snapshot in an isolated shadow git repository *before* risky operations, +so a bad edit or command is recoverable. It exists as both an OpenCode V2 plugin +and a standalone `gts` CLI over one shared snapshot engine. + +## Intended Users + +- OpenCode users who run agents against a project and want a recovery point + before destructive operations. +- Developers who want a CLI (`gts`) to snapshot, inspect and restore config/state + without an active OpenCode session. +- Operators installing Git That Shit alone or alongside other OpenCode plugins + with no ordering assumptions. + +## Functional Requirements + +| ID | Requirement | Acceptance signal | +| --- | --- | --- | +| FR-1 | Create an isolated shadow git repository separate from the project's `.git`. | `.git-that-shit/git-that-shit/snapshots/` is a valid git repo; shadow commits never appear in `git log` | +| FR-2 | Snapshot before destructive operations via the OpenCode V2 `execute.before` hook. | `pre-op` / `pre-risky-op` / `pre-edit` snapshots recorded before the tool runs | +| FR-3 | Record a `post-edit` snapshot on completed edit tools via `execute.after`. | `post-edit` snapshot after an edit; never mislabelled `pre-edit` | +| FR-4 | Register eight tools (`gts_status`, `gts_list`, `gts_diff`, `gts_restore`, `gts_snapshot`, `gts_prune`, `gts_config`, `gts_doctor`). | All eight registered with JSON Schema inputs | +| FR-5 | Expose the same engine through the `gts` CLI. | `gts init/snapshot/status/list/diff/restore/prune/config/doctor/version` | +| FR-6 | Restore is dry-run by default; overwriting requires explicit confirmation. | `gts restore ` writes nothing; `--force` required to overwrite conflicts | +| FR-7 | Prune keeps the newest N snapshots with a safety bundle written first. | `git bundle` created; branch moved atomically; manifest rebuilt | +| FR-8 | Redact secrets before persistence and never log secret values. | `[REDACTED]` in stored snapshots for secret-like keys; `.env` excluded | +| FR-9 | Report a snapshot as existing only when its commit is verified to resolve. | `SnapshotResult.verified` true only after `git rev-parse --verify ^{commit}` | +| FR-10 | Serialize snapshot/restore/prune under one project-scoped exclusive lock. | Concurrent processes never both hold the lock | + +## Nonfunctional Requirements + +| ID | Requirement | Target | +| --- | --- | --- | +| NFR-1 | No runtime dependency on other plugins. | Only runtime dep is `@opencode/plugin`; no companion imports | +| NFR-2 | Plugin hooks never fail the host tool. | Snapshot errors are caught, logged and isolated | +| NFR-3 | Private storage is owner-only where the platform supports it. | `0700` directories, `0600` files (POSIX) | +| NFR-4 | Restore resists symlink traversal and substitution. | Symlinked destination/ancestor refused; atomic temp-file + `rename` | +| NFR-5 | Package is ESM with type declarations and no committed `dist/`. | `"type": "module"`, `dist/` gitignored, built on publish | + +## Scope and Exclusions + +In scope: config/state snapshotting, redaction, restore, prune, doctor, +concurrency locking, V2 plugin surface, `gts` CLI. + +Out of scope: project code backup, general backup/cloud sync, secret management +or vault, copying live databases (`.sqlite`/`.db` hard-excluded), rewriting +historical shadow commits to remove secrets, cross-machine restore. + +## Technical Constraints + +- OpenCode V2 (`@opencode/plugin` 2.0.21); V1 `@opencode-ai/plugin` removed. +- Node.js `>=18` (`engines`); verified on Node 22. +- TypeScript, ESM; tests run under `bun test`. +- POSIX permission hardening applies to Linux/macOS only; Windows relies on ACLs. +- Shadow-repo git subprocesses are bounded by `snapshot.gitTimeoutMs`. + +## Acceptance Criteria + +- Typecheck, lint, build, and the full `bun test` suite pass on the release commit. +- `npm pack` produces a tarball containing only `dist/`, `package.json`, + `README.md` (and `LICENSE`), with no tests, fixtures, secrets or local config. +- The packed artifact installs in an isolated environment, identifies as the + release version, imports its entry point, and its `gts` CLI runs. +- The Docker Plugin Test Lab exercises the plugin to 0 failures. +- The PR into `master` is merged with passing CI and the release tag matches the + merged version. + +## Release Objectives + +Ship **v0.3.0** as the first hardened, V2-native, independently-operable release: +publish a GitHub release for the verified commit `6069f91`, with npm publication +tracked separately as a blocker (automatic npm publishing is out of scope for +this release). diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md new file mode 100644 index 0000000..4f38c06 --- /dev/null +++ b/PROJECT_STATUS.md @@ -0,0 +1,80 @@ +# Project Status + +## Current Phase + +In Progress + +## Repository State + + + + +- **Observed branch:** feat/git-that-shit-v2-hardening +- **Observed commit:** 6069f916a0fac14439c3335e682f994466961851 +- **Observed working tree:** dirty +- **Observed at:** 2026-10-17T00:32:38Z + + +## Verification Evidence + +- **Verification:** commit `6069f91` — repository suite green, 322 tests / 24 files, 0 failures (`bun test`); typecheck, lint and build pass — evidence `docs/RELEASE-0.3.0-EVIDENCE.md` +- **Verification:** commit `6069f91` — `npm pack` yields 70 files (`dist/`, `LICENSE`, `NOTICE`, `package.json`, `README.md`); packed artifact installs isolated, reports `0.3.0`, imports entry point `gitdamnit.git-that-shit` and runs `gts version` — evidence `docs/RELEASE-0.3.0-EVIDENCE.md` +- **Verification:** commit `6069f91` — Docker Plugin Test Lab image `sha256:76ebd805cd548a638b9b1508e5f16e680cd5b082cd3f3561d28f2f2301336ff1` (OpenCode 2.0.21, Node 22.23.3): 145 passed / 0 failed / 0 skipped — evidence `docs/RELEASE-0.3.0-EVIDENCE.md` +- **Verification:** commit `6069f91` — source/staged/packed/installed versions all `0.3.0` and consistent across the three plugins — evidence `docs/RELEASE-0.3.0-EVIDENCE.md` + +## Completed Work + +- [Tested] OpenCode V2 migration: `Plugin.define({ id, setup })`, eight `gts_*` tools, `execute.before`/`execute.after`, event subscriptions — `src/plugin/index.ts` +- [Tested] Snapshot-failure safety: verified commits, `standard`/`strict` modes, `no-changes` handling, preview redaction — `src/engine/safety.ts` +- [Tested] Restore security: symlink traversal refusal, atomic `O_EXCL|O_NOFOLLOW` + `rename`, dry-run default, redacted files never restored — `src/engine/restore.ts` +- [Tested] Owner-only private storage (`0700`/`0600`) — `src/shared/permissions.ts` +- [Tested] Project-scoped atomic locking — `src/engine/lock.ts` +- [Tested] Plugin independence: no companion imports, failure-isolated, read-only hooks — `tests/independence.test.ts` +- [Implemented] Add Apache-2.0 `LICENSE` at the repository root — `LICENSE` +- [Implemented] Gate npm publishing to explicit `workflow_dispatch` — `.github/workflows/publish.yml` +- [Implemented] Canonical project documentation — `PRODUCT_REQUIREMENTS.md`, `DECISION_LOG.md`, `PROJECT_STATUS.md` + +## Tests + +| Suite | Command | Result | Evidence | +| --- | --- | --- | --- | +| unit + integration (repo) | `bun test` | Pass (322, 24 files) | `docs/RELEASE-0.3.0-EVIDENCE.md` | +| typecheck | `npm run typecheck` | Pass | `docs/RELEASE-0.3.0-EVIDENCE.md` | +| lint | `npm run lint` | Pass | `docs/RELEASE-0.3.0-EVIDENCE.md` | +| build | `npm run build` | Pass | `docs/RELEASE-0.3.0-EVIDENCE.md` | +| packaging | `npm pack` | Pass (70 files) | `docs/RELEASE-0.3.0-EVIDENCE.md` | +| Docker live-host plugin lab | lab harness (external) | Pass (145) | `docs/RELEASE-0.3.0-EVIDENCE.md` | +| fresh isolated install | `npm install ` + `gts` smoke | Pass | `docs/RELEASE-0.3.0-EVIDENCE.md` | + +## Known Defects + +- Strict-mode pre-execution blocking is **best-effort**: the `execute.before` + hook cannot veto a tool, hook ordering is unspecified, and `permission.evaluate` + may be unavailable, in which case strict mode fails open with a warning. The + restore path's own strict check is enforceable. Documented in `docs/INTEROP.md`. +- Private-storage permissions are POSIX-only; Windows relies on platform ACLs. +- Symlink hardening does not defend against a same-user attacker racing the + filesystem. +- Historical shadow commits may retain previously committed secrets; history is + not rewritten to remove them. + +## Blockers + +- **npm publication of `0.3.0` is not authorised and not authenticated.** + npm `latest` is `0.2.2`; the release machine reports `ENEEDAUTH`. The README's + `npm install` command therefore resolves to `0.2.2` until this is resolved. + Owner: @gitdamnit. Unblocks with an authorised `npm publish` (now manual). + +## Next Actions + +1. Merge PR #1 into `master` with a merge commit and pass CI. +2. Create and push annotated tag `v0.3.0`; publish the GitHub release. +3. Separately authorise and run the manual npm publish for `0.3.0`, then confirm + `npm install @gitdamnit/opencode-git-that-shit` resolves to `0.3.0`. + +## Release Status + +**v0.3.0 — GITHUB-RELEASED / NPM-PENDING.** Verified source commit +`6069f916a0fac14439c3335e682f994466961851`; package version `0.3.0`. GitHub +release is the distribution of record; npm publication is an outstanding, +separately-authorised blocker. diff --git a/README.md b/README.md index b320fdb..3ab5114 100644 --- a/README.md +++ b/README.md @@ -2,49 +2,54 @@ # @gitdamnit/opencode-git-that-shit - > "Oh Shit Git" is what you say after the disaster. Git That Shit is what the tool does *before* the disaster. - Automatic pre-disaster snapshots for OpenCode AI coding agent config and state files. ## What is Git That Shit? -Git That Shit is a safety plugin for OpenCode AI coding agent sessions. It automatically snapshots fragile configuration and agent state files into a separate shadow git repository before risky operations. +Git That Shit is a safety plugin for [OpenCode](https://opencode.ai) V2 AI coding sessions. It automatically +snapshots fragile configuration and agent state files into a separate shadow git repository before risky operations. -The product exists because one malformed JSON edit, one extra whitespace-sensitive config mistake, one bad agent command, or one accidental overwrite can break an entire coding environment. Git That Shit makes those failures recoverable. +The product exists because one malformed JSON edit, one whitespace-sensitive config mistake, one bad agent command, or +one accidental overwrite can break an entire coding environment. Git That Shit makes those failures recoverable. It is +both an OpenCode **plugin** and a standalone **`gts` CLI** that shares the same snapshot engine. ## Why it exists -AI coding agents (OpenCode, Claude Code, Aider, Codex) operate with elevated privileges over project files. Unlike human developers who hesitate before destructive acts, agents execute commands confidently — and sometimes catastrophically. +AI coding agents operate with elevated privileges over project files. Unlike human developers who hesitate before +destructive acts, agents execute commands confidently — and sometimes catastrophically. **Why project git is not enough:** + - Git is not always present - Config files are often in `.gitignore` or untracked - Git records changes *after* they happen, not *before* - Most users don't commit before every risky operation -Git That Shit is inspired by **etckeeper** — the Linux tool that automatically commits `/etc` to git before package manager operations. +Git That Shit is inspired by **etckeeper** — the Linux tool that automatically commits `/etc` to git before package +manager operations. ## What it protects By default, Git That Shit tracks: -- `opencode.json` - OpenCode main config -- `.opencode/state/checkpoint.json` - Session state -- `.opencode/state/handoff.md` - Handoff document -- `.opencode/state/checkpoint-history.json` - Checkpoint history -- `package.json`, `tsconfig.json` - Project configs -- Build configs (vite.config.ts, webpack.config.js, rollup.config.js, etc.) -- Docker configs (Dockerfile, docker-compose.yml) -- `.env.example`, `.env.sample`, `.env.template` - Env templates (NOT actual .env files) + +- `opencode.json`, `opencode.jsonc` — OpenCode config +- `.opencode/state/checkpoint.json`, `.opencode/state/handoff.md`, + `.opencode/state/checkpoint-history.json`, `.opencode/state/strangerdanger-audit.jsonl` — agent state +- `package.json`, `tsconfig.json`, `jsconfig.json` +- Build configs (`vite.config.ts`, `webpack.config.js`, `rollup.config.js`, `eslint.config.js`, `prettier.config.js`, + `biome.json`, `turbo.json`, `pnpm-workspace.yaml`) +- Container configs (`Dockerfile`, `docker-compose.yml`) +- `.env.example`, `.env.sample`, `.env.template` — env templates (never real `.env` files) ## What it does NOT protect - **Not a replacement for project git** — use project git for code - **Not a general backup system** — focused on config/state files only -- **Not secret management** — excludes `.env` by default (actual secrets) +- **Not secret management** — real secrets are excluded and redacted, but this is not a vault - **Not cloud sync** — local git only -- **Not for other AI agents** — only tested with OpenCode +- Does not copy live databases (see *Interoperability*) --- @@ -54,339 +59,422 @@ By default, Git That Shit tracks: npm install @gitdamnit/opencode-git-that-shit ``` -## OpenCode Plugin Setup +> **v0.3.0 distribution note.** `v0.3.0` is published as a GitHub release +> ([`v0.3.0`](https://github.com/gitdamnit/opencode-git-that-shit/releases/tag/v0.3.0)). +> npm publication of `0.3.0` is **pending**, so until it lands `npm install` +> resolves to the latest version published on npm (`0.2.2`). To use `0.3.0` +> immediately, install from the GitHub release source archive and run +> `npm run build`. -Create `opencode.json` in your project: +## OpenCode V2 plugin setup -```json +Git That Shit targets **OpenCode 2.0.x** and depends on `@opencode/plugin` 2.0.21. Add it to `opencode.json(c)`: + +```jsonc { - "plugins": [ - "@gitdamnit/opencode-git-that-shit" - ] + "$schema": "https://opencode.ai/config.json", + "plugins": ["@gitdamnit/opencode-git-that-shit"] } ``` -Or use the local path for development: +With options, or from a local checkout during development: -```json +```jsonc { + "$schema": "https://opencode.ai/config.json", "plugins": [ - "./node_modules/@gitdamnit/opencode-git-that-shit" + { + "package": "@gitdamnit/opencode-git-that-shit", + "options": {} + }, + "./plugins/git-that-shit" ] } ``` ---- - -## How It Works - -### Shadow Git Repository - -Git That Shit creates an **isolated shadow git repository** that is completely separate from your project's `.git`: - -- **Location:** `.git-that-shit/snapshots/` -- **Config:** `.git-that-shit/config.json` -- **Never touches your real git** — completely independent - -This means: -- Your project's git history stays clean -- Git That Shit's commits don't appear in your normal git log -- The shadow repo only stores config/state file snapshots - -### Auto-Triggers - -Git That Shit automatically creates snapshots **before** risky operations: +The plugin id is `gitdamnit.git-that-shit`. It is a V2 plugin using +`Plugin.define({ id, setup(ctx) })`; the V1 `@opencode-ai/plugin` interface is no longer used. -| Trigger Type | Commands/Events Detected | -|-------------|--------------------------| -| **Destructive Git** | `git reset --hard`, `git reset --mixed`, `git clean -fd`, `git stash drop`, `git checkout -f`, `git rebase --abort` (during conflict) | -| **Risky Package Ops** | `npm install`, `npm ci`, `yarn install`, `yarn add`, `pnpm install`, `pnpm add`, `bun install` | -| **Config File Edits** | Writing to `.json`, `.yaml`, `.yml`, `.toml`, `.ini` files that match tracked patterns | -| **Session Events** | On new session start, on context compaction | +> **V1 users:** V1 plugin functions do not run in V2. See *Migrating from V1* below. -### Hooks - -Git That Shit uses these OpenCode-compliant hooks: - -- `tool.execute.before` - Fires before any bash/shell command runs -- `file.edited` - Fires after file modifications -- `session.created` - Fires when a new session starts -- `session.compacted` - Fires when context window is compacted - -### Plugin Execution Order - -Git That Shit runs **after** other plugins in the hook chain. This is important: +--- -1. **Other plugins** (like Stranger Danger) process tool arguments first -2. **Git That Shit** snapshots the result *after* other plugins have modified things +## How it works -This means Git That Shit captures the "safe" version of files after other security plugins have redacted secrets. +### Shadow git repository ---- +Git That Shit creates an **isolated shadow git repository** that is completely separate from your project's `.git`. -## IMPORTANT: What NOT to Use With This +| Item | Canonical location | +| --- | --- | +| Shadow repo | `.git-that-shit/git-that-shit/snapshots/` | +| Config | `.git-that-shit/git-that-shit/config.json` | +| Manifest | `.git-that-shit/git-that-shit/snapshots/.git-that-shit/manifest.jsonl` | +| Lock | `.git-that-shit/git-that-shit/snapshots/.gts.lock` | -### Other AI Coding Agents (Not Compatible) +The nested `git-that-shit/git-that-shit` path is historical, but it is what every existing snapshot repository on disk +already uses, so it remains canonical. Older builds documented `.git-that-shit/snapshots/`; on first use Git That Shit +**migrates** such a repository to the canonical location instead of orphaning it. -Git That Shit is **specifically built for OpenCode** and may conflict with or not work properly with: +Your project's git history stays clean: shadow commits never appear in your normal `git log`, and the shadow repo only +stores config/state snapshots. -- **Claude Code** (has its own safety mechanisms) -- **Aider** (different hook system) -- **Codex** (different architecture) -- **Cursor** (has built-in git integration) -- **Windsurf** (has built-in safety) +### Automatic triggers (OpenCode V2) -If you're using OpenCode in a project that also uses other AI agents, Git That Shit's snapshots may not capture changes made by those other agents. +| Trigger type | V2 mechanism | Snapshot type | +| --- | --- | --- | +| Destructive git command | `ctx.tool.hook("execute.before")` | `pre-op` | +| Risky package-manager op | `ctx.tool.hook("execute.before")` | `pre-risky-op` | +| Config/file edit | `ctx.tool.hook("execute.before")` | `pre-edit` | +| Edit tool completed | `ctx.tool.hook("execute.after")` | `post-edit` | +| File changed out of band | `ctx.event.subscribe("filesystem.changed")` | `post-edit` (debounced) | +| New session | `ctx.event.subscribe("session.created")` | `session-start` | +| Context compaction | `ctx.event.subscribe("session.compaction.ended")` | `post-compact` | +| Manual / restore | CLI or `gts_snapshot` / restore | `manual`, `pre-restore` | -### Other OpenCode Plugins +Pre-edit snapshots are taken **before** the edit runs (`execute.before`); post-edit snapshots are taken **after** the +edit completes (`execute.after`). A snapshot is never labelled `pre-edit` when it was actually taken after the edit. -Git That Shit should work fine with most other OpenCode plugins, but be aware: +> **Verified against OpenCode 2.0.21:** post-edit snapshots are driven by `ctx.tool.hook("execute.after")`. +> The public `filesystem.changed` event is only emitted for out-of-band filesystem changes, **not** for agent tool +> writes, so it is registered as a debounced fallback rather than the primary post-edit signal. -1. **Plugin order matters** — Git That Shit's snapshots happen after other plugins process -2. **Disk space** — If you have many plugins creating files, the shadow repo will grow -3. **Lock conflicts** — Only one snapshot can run at a time; subsequent triggers wait +### Migrating from V1 -### Not for Production CI/CD +The V1 plugin API was replaced by a domain-based V2 API. The hook migration is not a mechanical rename: -This plugin is designed for **interactive AI coding sessions**. Do not: -- Use in automated CI/CD pipelines -- Rely on it for production disaster recovery -- Use as your only backup solution +| V1 hook | V2 API | +| --- | --- | +| `tool.execute.before` | `ctx.tool.hook("execute.before", …)` | +| `file.edited` | `ctx.tool.hook("execute.after", …)` (verified: `filesystem.changed` does not fire for agent tool writes) | +| `session.created` | `ctx.event.subscribe()` → `session.created` | +| `session.compacted` | `ctx.event.subscribe()` → `session.compaction.ended` | +| returned `event` hook | `ctx.event.subscribe()` | +| returned `dispose()` | cleanup function returned from `setup` | +| `tool` map / `tool()` helper | `ctx.tool.transform(editor => editor.add({ input: , … }))` | --- -## CLI Usage +## CLI usage -Run these commands in your terminal (not inside OpenCode): +The `gts` CLI works without an active OpenCode session and uses the same engine as the plugin: ```bash gts status # Show status gts list # List recent snapshots gts diff # Show what changed -gts restore # Restore (dry-run by default) +gts restore # Restore (dry-run by default) gts snapshot # Manual snapshot -gts prune # Prune old snapshots (dry-run) +gts prune # Prune old snapshots (dry-run by default) gts config # Show configuration -gts init # Initialize shadow repo +gts init # Initialize the shadow repo gts doctor # Run health checks -gts version # Show version +gts version # Show the packaged version ``` ### Restore examples ```bash -# Dry-run (default) - shows what would happen -gts restore abc1234 +gts restore abc1234 # Dry-run (default) +gts restore abc1234 --yes # Restore, but skip files changed since the snapshot +gts restore abc1234 --yes --force # Restore, overwriting conflicting files +gts restore abc1234 --to-temp # Restore into a temp directory, leave the project untouched +``` + +Restore semantics: -# Real restore - actually overwrites files -gts restore abc1234 --yes +- **Dry-run** (default) reports what would happen and writes nothing. +- `--yes` restores, but files that differ from the snapshot are treated as **conflicts** and left untouched. +- `--force` / `--overwrite` overwrites conflicting files. Restore never silently overwrites your current work. +- **Redacted files are never restored.** A snapshot stores `[REDACTED]` placeholders instead of real secret values, + so restoring them would destroy live credentials. Redacted files are skipped and reported; re-enter secrets manually. +- A `pre-restore` recovery snapshot is recorded automatically before a restore (unless `--force`). -# List first to find the hash -gts list -gts diff 719ab88 +### Prune + +```bash +gts prune # Preview what would be pruned +gts prune --keep 20 # Preview keeping the newest 20 +gts prune --keep 20 --yes # Actually prune ``` +Prune protects the newest `keep` snapshots and: + +1. writes a safety `git bundle` of the entire shadow repo before touching anything, then +2. rewrites the shadow history to keep only those snapshots, atomically moving the branch, then +3. rebuilds the manifest so every remaining entry references a real commit. + +Because history is rewritten, surviving snapshot hashes change. The safety bundle (`prune-backup-*.bundle` under +`.git-that-shit/git-that-shit/`) is your recovery path. + --- ## Configuration -Config is stored at `.git-that-shit/config.json`: +Config lives at `.git-that-shit/git-that-shit/config.json`: -```json +```jsonc { "version": "0.1", "tracking": { - "include": ["opencode.json", ".opencode/state/*.json", "*.config.js"], + "include": ["opencode.json", "opencode.jsonc", ".opencode/state/*.json", "*.config.js"], "exclude": [], "allowSensitiveFiles": false }, "destructiveOps": { - "riskyPackageManagerOps": true, // Snapshot before npm/yarn/pnpm install - "configWrites": true // Snapshot before config file edits + "riskyPackageManagerOps": true, // snapshot before npm/yarn/pnpm/bun install + "configWrites": true // snapshot before config file edits }, "fileEdits": { - "mode": "pre-if-available", // pre-if-available, post-edit, or disabled - "debounceMs": 2000 // Wait 2s before snapshot on file edits + "mode": "pre-if-available", // pre-if-available | post-only | disabled + "debounceMs": 2000 // debounce for post-edit snapshots }, "secrets": { "redactKeys": ["token", "apiKey", "password", "secret", "credential"], "warnOnSensitive": true }, "snapshot": { - "maxFileSizeMb": 10, // Skip files larger than 10MB - "minDiskSpaceMb": 100 // Fail if less than 100MB free + "maxFileSizeMb": 10, // skip files larger than 10 MB + "minDiskSpaceMb": 100, // fail if less than 100 MB free + "gitTimeoutMs": 30000, // subprocess timeout for shadow-repo git operations + "failureMode": "standard" // standard | strict (see below) }, "restore": { - "autoPreRestoreSnapshot": true // Create snapshot before restore + "autoPreRestoreSnapshot": true // snapshot before a restore } } ``` -### Configuration Options Explained - -| Option | What it does | -|--------|-------------| -| `tracking.include` | Glob patterns for files to snapshot | -| `tracking.exclude` | Additional patterns to skip | -| `destructiveOps.riskyPackageManagerOps` | Enable/disable npm/yarn snapshots | -| `destructiveOps.configWrites` | Enable/disable config file write snapshots | -| `fileEdits.mode` | When to snapshot: before, after, or disabled | -| `secrets.redactKeys` | JSON keys to redact in snapshots | -| `snapshot.maxFileSizeMb` | Skip files larger than this | -| `restore.autoPreRestoreSnapshot` | Auto-snapshot before each restore | - --- -## Secret Handling +## Snapshot failure behavior -Git That Shit takes secrets seriously: +A protective snapshot is created before risky operations, but snapshots can fail (no disk space, a locked project, +repository corruption, a timeout, an unreadable file). Git That Shit never claims a snapshot exists unless the commit +was **verified** to resolve in the shadow repository, and it never blocks a harmless command because the snapshot +engine is unhealthy. -1. **`.env` files are EXCLUDED** — actual env files with secrets are never tracked -2. **Sensitive file patterns blocked** — `*.pem`, `*.key`, `id_rsa`, `credentials.json`, etc. -3. **JSON value redaction** — Values with keys like `token`, `apiKey`, `password` are replaced with `[REDACTED]` -4. **Warning system** — Alerts if you try to track sensitive files +Two configurable modes (`snapshot.failureMode`): -**To explicitly allow sensitive files** (NOT RECOMMENDED): -```json -{ - "tracking": { - "allowSensitiveFiles": true - } -} -``` - ---- - -## Restore Workflow +| Mode | Behavior when a required snapshot cannot be created | +| --- | --- | +| **`standard`** (default) | The requested operation **continues**. A clear, credential-free warning is emitted, and no recovery point is claimed. | +| **`strict`** (opt-in) | Git That Shit **attempts to block** exclusively classified *destructive* operations (e.g. `git reset --hard`, `git clean -fd`, `rm -rf`). Risky package-manager operations, config writes and harmless commands are never blocked. | -When something breaks: +In strict mode, blocking uses the OpenCode V2 `permission.evaluate` gate: when the protective snapshot for a specific +tool call fails, that **exact** tool call is denied with an explanatory message. Two honest caveats are documented in +`docs/INTEROP.md`: -1. **Identify the problem** — "My opencode.json is corrupted!" -2. **List snapshots** — `gts list` -3. **Preview changes** — `gts diff ` -4. **Dry-run restore** — `gts restore ` (shows what would happen) -5. **Confirm restore** — `gts restore --yes` +- The `execute.before` *tool hook* returns `void` and **cannot** veto an operation; only `permission.evaluate` can. +- The SDK does not specify the ordering of `permission.evaluate` relative to `execute.before`, so blocking is + **best-effort**. If no permission gate is exposed, strict mode **fails open**, logs that the operation was *not* + blocked, and does not pretend otherwise. -**Restore always defaults to dry-run** — it's safe by default. +Strict mode also makes the restore path fail closed: a restore whose mandatory pre-restore snapshot cannot be created is +refused (unless `--force`). This path is guaranteed because Git That Shit owns it. --- -## Doctor Command +## Secret handling -Run health checks: +Git That Shit treats secrets as a first-class concern: -```bash -gts doctor -``` +1. **`.env` files are excluded** — real env files are never tracked +2. **Sensitive filenames blocked** — `*.pem`, `*.key`, `id_rsa`, `credentials.json`, `*.sqlite`, `*.db` and more +3. **Structured redaction** — JSON values under secret-like keys become `[REDACTED]`, including deeply nested keys +4. **Text redaction** — YAML/TOML/`.env`-style/`.jsonc`/shell files are line-redacted for `key: value`, `key=value`, + `Authorization: Bearer …` headers, and multiline PEM private-key blocks +5. **Global config is always redacted** — the global OpenCode config is redacted before being stored as + `global-opencode/*` +6. **Fail closed** — if a file cannot be safely processed (for example malformed JSON), it is **skipped**, never stored + raw +7. **No secret logging** — secret values never appear in logs, tool output, or errors -Checks: -- Git availability and version -- Config file validity -- Shadow repo exists and is valid git -- Manifest file readable -- No lock file conflicts (means no snapshot in progress) -- Sufficient disk space -- No sensitive files accidentally tracked -- Project git untouched (shadow repo is independent) +Git That Shit does **not** rewrite historical commits to remove secrets. If a secret was committed before redaction was +fixed, it remains in the shadow history. Report it and rotate the credential; see `docs/SECURITY.md`. --- -## Plugin Tools (Direct Use in OpenCode) +## Concurrency and multiple sessions -You can call these directly in your OpenCode conversations: +Multiple OpenCode sessions and CLI processes can operate on the same project safely: -``` -@tool gts_status -@tool gts_list -@tool gts_diff hash= -@tool gts_restore hash= -@tool gts_snapshot -@tool gts_prune -@tool gts_config -@tool gts_doctor -``` +- Every snapshot/restore/prune runs under a single **project-scoped exclusive lock**. +- Lock acquisition is **atomic** (exclusive file create), so two processes can never both hold the lock. +- Stale locks (dead process or older than 30 s) are detected and reclaimed; a process only removes a lock it can prove + is stale. +- The snapshot manifest is written atomically and each entry records the **originating session id**. +- There is one project-wide shadow history; sessions are tracked as metadata, not separate repositories. --- -## Limitations & Known Issues +## Independence and interoperability + +Git That Shit is designed to be installed and operated completely on its own. It is one of three independently +developed OpenCode plugins (Git That Shit, Stranger Danger, Checkpoint Orchestrator) and it shares no code, runtime, +storage, or initialization with the others. + +### Independence guarantees + +- **Works alone.** It functions with no companion plugin installed. +- **Works in any combination.** It works alongside each other plugin, or both, in any load order. +- **No ordering assumptions.** Correctness never depends on another plugin running before or after it. It never + requires a companion to initialize first, and a missing, disabled, failed, or outdated companion cannot prevent it + from working. +- **No shared framework.** It depends only on `@opencode/plugin` and Node built-ins. It does not import any other + plugin's modules and does not require a plugin manager or shared runtime. +- **Read-only hooks.** Its hooks never mutate the tool event (`event`/`event.input`). They are purely additive. +- **Failure isolation.** Snapshot failures, malformed project files, and absent companions are caught and logged; they + never surface as tool failures or affect other plugins. +- **Namespaced.** Configuration lives at `.git-that-shit/git-that-shit/config.json`, the shadow repository at + `.git-that-shit/git-that-shit/snapshots/`, and logs are prefixed `[git-that-shit]`. + +### Pre-operation protection + +`ctx.tool.hook("execute.before", …)` is **awaited** by OpenCode before the tool executes, so a pre-operation snapshot +completes before the risky operation runs. Verified against OpenCode 2.0.21: a deliberately delayed `execute.before` +hook retained the tool until the hook (including the snapshot commit) returned. (Note: the tool part's `time.start` is +recorded when the call is created, before hooks run; execution completes after the hook returns.) + +The `execute.before` hook is **awaited, but it cannot veto** the tool: its callback returns `void` and there is no +documented throw-to-block contract. Pre-execution *blocking* therefore uses the separate `permission.evaluate` gate and +is only available in **strict mode**; see [Snapshot failure behavior](#snapshot-failure-behavior) and `docs/INTEROP.md`. + +### Companion behavior + +| Companion state | Git That Shit behavior | +| --- | --- | +| Stranger Danger absent | Fully functional; redacts secrets independently | +| Checkpoint Orchestrator absent | Fully functional; snapshot history independent | +| Both absent | Fully functional | +| Companion disabled/failed | Fully functional; failures are isolated | +| Companion data malformed | Non-fatal; malformed files fail closed or are skipped | + +### Companion matrix (expected results) + +| Configuration | Expected result | +| --- | --- | +| Git That Shit alone | Fully functional | +| Stranger Danger + Git That Shit | Both functional | +| Git That Shit + Checkpoint Orchestrator | Both functional | +| All three plugins | All functional | + +### Specific companions + +- **Checkpoint Orchestrator** (migrating to SQLite): Git That Shit does **not** copy live SQLite databases. `.sqlite`, + `.sqlite3`, `.db`, `*.db-wal`, and `*.db-shm` are hard-excluded to avoid capturing a database mid-write. It may + back up stable, human-readable handoff files (`.opencode/state/checkpoint.json`, `handoff.md`, + `checkpoint-history.json`) as ordinary project files. A future integration should use Checkpoint Orchestrator's + supported backup/export interface; that interface is not yet finalised, so no speculative integration code exists. + See `docs/INTEROP.md`. +- **Stranger Danger**: Git That Shit does **not** assume Stranger Danger's sanitization runs before it. It redacts + secrets independently in its own pipeline, so snapshots are safe regardless of ordering. It never mutates tool input + and never depends on another plugin's processing. +- **Other plugins/agents**: Git That Shit only reads project config/state files and writes its own shadow repository. + It does not modify other projects, other plugins' storage, or global configuration. + +### Optional interoperability (implemented, no dependency) + +The only cross-plugin interaction is **read-only observation of well-known human-readable project state files** for +backup purposes. These files are project files, not another plugin's private storage; if they are absent or malformed, +Git That Shit still works. This is a path-based, versioned-by-convention interface with no imports and no shared state. -### v0.1.0 Limitations +--- -1. **Prune not implemented** — `gts prune` shows preview but doesn't actually delete -2. **No cross-machine restore** — Snapshots are local only -3. **No selective restore** — Restores all files, not single files -4. **Hook timing** — The `file.edited` hook timing from OpenCode should be verified in production -5. **Large files skipped** — Files over 10MB are not snapshot (configurable) -6. **Disk space check** — Requires 100MB free or snapshot fails +## Doctor -### What Could Go Wrong +```bash +gts doctor +``` -| Scenario | What Happens | -|---------|--------------| -| Disk full | Snapshot fails with error, but command still runs | -| Git not installed | Plugin warns but doesn't break OpenCode | -| Lock file present | Waits for previous snapshot to finish | -| Network issues | N/A — all local, no network required | -| Corrupt shadow repo | Run `gts doctor` to diagnose | +Health checks include git availability, config validity, shadow repo validity, local git config, manifest readability +and validity, stale-lock detection, disk space, global config presence, sensitive files, and project git independence. --- -## Uninstalling +## Plugin tools -To remove Git That Shit: +The plugin registers eight tools, callable directly in OpenCode: -1. Remove from `opencode.json`: -```json -{ - "plugins": [] -} -``` +| Tool | Purpose | +| --- | --- | +| `gts_status` | Shadow repo path, snapshot count, last snapshot, version | +| `gts_list` | List recent snapshots | +| `gts_diff` | Diff introduced by a snapshot | +| `gts_restore` | Restore (`dry_run`/`yes`/`force`/`to_temp`) | +| `gts_snapshot` | Manual snapshot | +| `gts_prune` | Preview or prune (`keep`/`yes`) | +| `gts_config` | Effective configuration | +| `gts_doctor` | Health diagnostics | -2. Optionally delete the shadow repo: -```bash -rm -rf .git-that-shit -``` +All destructive tools are safe by default: `gts_restore` is a dry run unless `dry_run:false` **and** `yes:true`, and +`gts_prune` only previews unless `yes:true`. --- ## Testing ```bash -npm run build -npm test +npm run typecheck # tsc --noEmit +npm run lint # tsc --noEmit +npm run build # clean + tsc +npm test # bun test ``` -### v0.2.0 Test Results - -| Test Suite | Tests | Status | -|---|---|---| -| CLI (`cli.test.ts`) | 20 | ✅ All pass | -| Tracker (`tracker.test.ts`) | 29 | ✅ All pass | -| Restore (`restore.test.ts`) | 6 | ✅ All pass | -| Snapshot (`snapshot.test.ts`) | 14 | ✅ All pass | -| Security (`security.test.ts`) | 15 | ✅ All pass | -| Detector (`detector.test.ts`) | 29 | ✅ All pass | -| Config (`config.test.ts`) | 12 | ✅ All pass | -| Disk (`disk.test.ts`) | 8 | ✅ All pass | -| Git (`git.test.ts`) | 16 | ✅ All pass | -| Lock (`lock.test.ts`) | 11 | ✅ All pass | -| Manifest (`manifest.test.ts`) | 10 | ✅ All pass | -| Paths (`paths.test.ts`) | 30 | ✅ All pass | -| Redactor (`redactor.test.ts`) | 18 | ✅ All pass | -| **Total** | **238** | **✅ 238 pass, 0 fail** | - -Test framework: `bun test` (bun 1.3.11). All tests are hermetic, use isolated `/tmp` directories, no real git remotes. +The suite contains **322 tests** across 24 files covering snapshots, snapshot-failure behavior, restore behavior and +restore security, filesystem permissions, concurrency, security, prune, CLI, packaging, independence/interoperability, +and the OpenCode V2 plugin surface (including strict-mode pre-execution blocking). Tests are hermetic and use isolated +`/tmp` directories and an isolated global-config directory. + +Test framework: `bun test`. + +### Docker Plugin Test Lab compatibility + +Git That Shit is designed to be exercised by an external, Docker-based OpenCode Plugin Test Lab without any special +integration framework inside the plugin: + +- Runs under an **isolated OpenCode V2 runtime** (`opencode run --standalone` / `opencode serve`) with independent + `XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, and `XDG_CACHE_HOME`. +- Works against a **disposable project directory** and a **local mock model provider** (no external network or keys). +- Registers a stable plugin id (`gitdamnit.git-that-shit`), eight tools, and its hooks for automated assertions. +- Owns all of its state under `.git-that-shit/`, so it never contends with other plugins' fixtures. +- Supports concurrency tests (serialized project lock) and the installation combinations in the companion matrix. + +Docker is never required for normal operation. The test lab's code is owned separately and is not part of this +repository. + +--- + +## Limitations + +- **Prune rewrites history** — surviving snapshot hashes change; a safety bundle is always written first. +- **No cross-machine restore** — snapshots are local only (git bundles are on the roadmap). +- **No selective restore** — restores all files in a snapshot. +- **Fail-closed skips** — malformed JSON config files are skipped rather than stored, because their contents cannot be + proven secret-free. +- **Redacted files are not restored** — snapshots store `[REDACTED]` placeholders rather than real secret values, so + restore skips those files and reports them; re-enter secrets manually after a restore. +- **Post-edit signal** — driven by `execute.after`; the `filesystem.changed` event does not fire for agent tool writes + and is used only as a fallback for out-of-band changes. +- **Disk space check** — requires `minDiskSpaceMb` free or the snapshot fails. +- **Historical secrets** — pre-existing secrets in old shadow commits are not rewritten automatically. +- **Strict-mode blocking is best-effort** — the SDK's `execute.before` hook cannot veto an operation, and the ordering + of `permission.evaluate` relative to it is unspecified. Strict mode fails open (with a loud warning) when no gate is + available. Restore's strict check is guaranteed because Git That Shit owns that path. +- **Private storage permissions are POSIX-only** — snapshot directories use `0700` and files `0600` on Linux/macOS. + Windows has no equivalent POSIX mode; permission enforcement there relies on the platform ACL model. +- **Symlink hardening is best-effort against a local attacker** — restore refuses symlinked destinations and resolves + the nearest existing ancestor, and writes atomically via `rename` so a symlink cannot be substituted between + validation and writing. A determined same-user attacker racing the filesystem is still out of scope. --- ## Roadmap -Future versions may include: -- Bundle export for portability (share snapshots as archive) -- Selective restore (single file restore) +- Bundle export/import for portability +- Selective (single-file) restore +- Integration with Checkpoint Orchestrator's export interface - Snapshot comparison UI -- Integration with more coding agents -- Cloud backup option --- @@ -398,7 +486,7 @@ Apache-2.0 ## Support -If you encounter issues: 1. Run `gts doctor` for diagnostics -2. Check `.git-that-shit/snapshots/.git-that-shit/manifest.jsonl` for snapshot history -3. Review `.git-that-shit/config.json` for configuration issues +2. Inspect `.git-that-shit/git-that-shit/snapshots/.git-that-shit/manifest.jsonl` for history +3. Review `.git-that-shit/git-that-shit/config.json` for configuration +4. See `docs/ARCHITECTURE.md`, `docs/SECURITY.md`, and `docs/INTEROP.md` diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..09c675b --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,6 @@ +[test] +# Slow CI runners occasionally exceed Bun's 5s default for tests that spawn +# node/git subprocesses (CLI snapshots, packaging, first-time module import). +# Raising the suite-wide default keeps CI deterministic without weakening any +# assertion; individual tests may still set a tighter bound. +timeout = 30000 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a9f81db --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,126 @@ +# Architecture + +Git That Shit has three layers that share one snapshot engine: + +``` +OpenCode V2 plugin ─┐ + ├──► engine (snapshot / restore / prune / doctor) ──► shadow git repo +gts CLI ─┘ +``` + +Nothing in the engine imports `@opencode/plugin`. The plugin is a thin adapter; the CLI is another adapter. Both call +the same `src/engine` functions, so snapshot and restore logic is never duplicated. + +## Module map + +| Path | Responsibility | +| --- | --- | +| `src/shared/paths.ts` | Canonical layout, legacy migration source path, global config discovery | +| `src/shared/permissions.ts` | `0700`/`0600` private-storage hardening (POSIX) | +| `src/shared/types.ts` | Shared types | +| `src/shared/errors.ts` | Typed errors | +| `src/shared/version.ts` | Reads the packaged version | +| `src/engine/lock.ts` | Atomic project-scoped exclusive lock | +| `src/engine/git.ts` | Git subprocess wrapper (init, add, commit, tree, bundle, refs) | +| `src/engine/tracker.ts` | File selection, hard/sensitive excludes, secret keys, path safety | +| `src/engine/redactor.ts` | JSON + text redaction, fail-closed signal | +| `src/engine/snapshot.ts` | Snapshot orchestration (fully locked) | +| `src/engine/restore.ts` | Restore orchestration (single execution under lock) | +| `src/engine/prune.ts` | Preview and history-rewriting prune | +| `src/engine/manifest.ts` | Append/read/rewrite the JSONL manifest, rebuild from git | +| `src/engine/detector.ts` | Destructive/risky/config-write command detection | +| `src/engine/safety.ts` | Snapshot-failure policy (standard/strict), message sanitization, denial registry | +| `src/engine/doctor.ts` | Health checks | +| `src/engine/config.ts` | Config defaults and loading | +| `src/plugin/index.ts` | OpenCode V2 plugin (`Plugin.define`) | +| `src/cli/**` | `gts` CLI commands | + +## Canonical layout + +``` +/ +└── .git-that-shit/ + └── git-that-shit/ + ├── config.json + └── snapshots/ # shadow git repository (own .git) + ├── .gts.lock + ├── .gitignore # ignores .gts.lock and the manifest + ├── .git-that-shit/ + │ └── manifest.jsonl + ├── opencode.json # redacted copies of tracked files + └── global-opencode/ # redacted global config (never restored) +``` + +A legacy `.git-that-shit/snapshots` repository is migrated into the canonical location on first use, preserving history. + +## Snapshot flow + +1. Acquire the project lock (atomic exclusive create). +2. `initShadowRepo` (idempotent; migrates legacy layout; applies `0700`/`0600` private permissions). +3. Disk-space check. +4. Collect tracked files (`getProjectConfigPaths` + include globs + `.opencode/state`). +5. For each file: path-safety check → size check → hard/sensitive exclude → redact (fail closed) → copy/write. +6. Redact and store each global OpenCode config under `global-opencode/`. +7. `git add -A`, commit (if staged changes), **verify the commit resolves**, append manifest entry, release lock. + +Because the whole flow is under one lock, concurrent sessions serialize and cannot corrupt the shadow repository. +`operation.preview` and `reason` are text-redacted before they reach the commit message, the manifest or the result. + +If any step fails, `snapshot` returns a structured `SnapshotResult` with `committed:false`, a `failureCode`, and no +`hash`; it never throws to its caller and never claims a snapshot exists unless `verified:true`. + +## Restore flow + +1. If enabled and not forced, take a `pre-restore` snapshot. A `no-changes` result is fine (the latest snapshot is + already the recovery point). In **standard** mode any other failure continues with a warning; in **strict** mode the + restore is refused because Git That Shit owns this path and can guarantee the check. +2. Acquire the project lock and run the restore **once**. +3. Per file: skip global/shadow-internal files; **skip redacted files** (their stored content contains `[REDACTED]` + placeholders, so restoring them would destroy live secrets); create missing files; leave unchanged files; report + conflicts and only overwrite them when `force` is set. +4. Writes are symlink-checked and atomic (temp file + `rename`), so a destination cannot be redirected outside the + project or swapped between validation and writing. + +## Prune flow + +1. Preview: compute `total`, `keep`, `pruneCount`, and the snapshots to drop. No mutation. +2. On confirm, under the lock: + - write a safety `git bundle` of the whole shadow repo, + - replay the kept commits with `git commit-tree` to form a fresh linear chain (preserving author/committer/date and + message), + - atomically move the branch with `git update-ref` (compare-and-swap on the old head), + - `git reset --hard` the working tree, + - rebuild the manifest so entries reference real commits. + +## Lock model + +`acquireLock` uses `fs.open(lockPath, "wx")`. Only one process can ever create the lock; there is no read-then-write +window. A lock is stale when its PID is dead (except `EPERM`) or it is older than 30 s. Removal is ownership-checked so +a process never deletes a lock it did not observe and prove stale. `withLock` always releases the lock, even on throw. + +## Multi-session model + +There is a single project-wide shadow history. Each snapshot records the `sessionId` that triggered it, so multiple +OpenCode sessions can audit who created what without fragmenting storage. + +## OpenCode V2 wiring + +`setup(ctx)`: + +- registers an `execute.before` tool hook that detects destructive commands and pre-edits, +- registers an `execute.after` tool hook that takes post-edit snapshots when an edit tool completes successfully + (the reliable V2 signal; `filesystem.changed` does not fire for agent tool writes), +- registers a `permission.evaluate` hook (when the SDK exposes one) used only by **strict mode** to deny the exact tool + call whose required protective snapshot failed, +- subscribes to the server event stream for `session.created`, `session.compaction.ended`, and + `filesystem.changed` (out-of-band changes), +- registers the eight tools via a single synchronous `ctx.tool.transform`, +- returns a cleanup function that aborts the subscription, clears pending strict-mode denials, and clears the edit + debounce timer. + +### Failure modes + +- **standard** (default): a failed protective snapshot warns and lets the operation continue. +- **strict** (opt-in): a failed protective snapshot requests a deny for that one destructive tool call via + `permission.evaluate`. `execute.before` cannot veto a tool, and the SDK does not specify hook ordering, so blocking is + best-effort and fails open with a warning when no gate is available. The restore path is guaranteed to fail closed. diff --git a/docs/INTEROP.md b/docs/INTEROP.md new file mode 100644 index 0000000..400bbab --- /dev/null +++ b/docs/INTEROP.md @@ -0,0 +1,138 @@ +# Interoperability + +Git That Shit is developed concurrently with other OpenCode plugins. It is intentionally self-contained: it does not +import, call, or depend on their code, storage, APIs, or initialization order. Interoperability is an optional +enhancement, never a requirement. + +## Compatibility matrix + +| Component | Version targeted | Notes | +| --- | --- | --- | +| OpenCode | 2.0.x | Plugin uses the V2 API; runtime behavior verified against 2.0.21 | +| `@opencode/plugin` | 2.0.21 (pinned) | Only runtime dependency; matches the host release targeted | +| Node.js | >= 18 | ESM package | +| git | any recent | Shadow repository operations | + +## Independence standard + +Git That Shit conforms to the shared independence standard for toolkit plugins: + +- Works installed alone, alongside either companion, or alongside both. +- Independent versioning and release cycle. +- Never requires another plugin for startup or normal operation. +- Never imports another plugin's internal modules; never uses `@gitdamnit/*` imports. +- Never depends on another plugin's storage or private details. +- Never assumes installation or initialization order. +- No global mutable state shared with other plugins. +- Namespaced configuration (`.git-that-shit/git-that-shit/config.json`), storage + (`.git-that-shit/git-that-shit/snapshots/`), and logging (`[git-that-shit]`). +- Preserves its standalone `gts` CLI, which needs no OpenCode or companion plugin. + +### Hook independence + +- Git That Shit registers `ctx.tool.hook("execute.before")`, `ctx.tool.hook("execute.after")`, a + `ctx.event.subscribe()` subscription, and — when the SDK exposes it — a `permission.evaluate` hook. +- Correctness does **not** depend on another plugin running first. It never reads another plugin's output and never + assumes tool arguments were modified by another plugin. +- Its hooks are **read-only** with respect to the tool event; they never mutate `event` or `event.input`. The + `permission.evaluate` callback is the single exception: it may set `effect = "deny"` and `message` on the permission + evaluation (the SDK's documented gate), and only for the exact tool call whose protective snapshot failed. +- OpenCode awaits the async `execute.before` hook before running the tool (verified against 2.0.21), so the + pre-operation snapshot completes before the risky operation. There is no claimed protection that is not provided. + +### Pre-execution blocking (strict mode) + +Strict mode is the only feature that attempts to stop an operation. Its limits are documented rather than hidden: + +- `execute.before` returns `void`. It is awaited, but the SDK documents **no** way for it to veto a tool, so Git That + Shit does not treat it as a blocking gate. +- The only pre-execution gate in the 2.0.21 SDK is `permission.evaluate`, whose `PermissionEvaluation` exposes a mutable + `effect` (`"allow" | "deny" | "ask"`) and the originating tool call at `evaluation.source.id`. +- The SDK does not specify whether `permission.evaluate` runs before or after `execute.before`. Strict mode therefore + records the tool-call id when the protective snapshot fails and denies that id if the permission hook is consulted. +- If the SDK exposes no permission hook, strict mode **fails open**: it logs that the operation was *not* blocked and + does not advertise protection it cannot deliver. +- The one guaranteed strict check is inside Git That Shit's own restore path: a restore whose mandatory pre-restore + snapshot cannot be created is refused unless `--force` is passed. + +Runtime confirmation of the deny path against a live OpenCode host was not performed in the isolated development +environment (no live host); the gate is verified against the published 2.0.21 type definitions and exercised through the +V2 plugin harness. + +## Companion behavior + +| Companion state | Git That Shit behavior | +| --- | --- | +| Stranger Danger absent/disabled | Fully functional; redacts secrets independently | +| Checkpoint Orchestrator absent/disabled | Fully functional; snapshot history independent | +| Companion data malformed | Non-fatal; malformed files fail closed or are skipped | +| Companion initialization failed | No effect; Git That Shit does not await or require it | +| Missing optional interface | Graceful; the interface is never required | + +## Specific companions + +### Checkpoint Orchestrator + +Checkpoint Orchestrator is migrating its persistence to SQLite. Its schema, database location, export interface, and +APIs are **not finalised**, so Git That Shit does not integrate with its database directly and ships no speculative +integration code. + +- It **never** copies a live SQLite database. `*.sqlite`, `*.sqlite3`, `*.db`, `*.db-wal`, and `*.db-shm` are + hard-excluded, because copying a database mid-write can capture a torn, unusable file. +- It may back up stable, human-readable handoff files that Checkpoint Orchestrator produces + (`.opencode/state/checkpoint.json`, `.opencode/state/handoff.md`, `.opencode/state/checkpoint-history.json`) as + ordinary **project files** — never by writing into Checkpoint Orchestrator's storage. +- These files are optional: if they are absent or malformed, Git That Shit still works. + +**Future integration strategy (documented, not implemented):** when Checkpoint Orchestrator finalises an export +interface, prefer its supported backup/export command to produce a consistent point-in-time copy (for example +`VACUUM INTO`, `.backup`, or a JSON/NDJSON export) into a stable file, then add that file or directory to +`tracking.include`. Do not enable raw `.db` tracking. Until then, no coupling exists. + +### Stranger Danger + +Stranger Danger performs security inspection independently. Git That Shit assumes nothing about it: + +- Git That Shit redacts secrets in its own pipeline before storing anything, so snapshots are safe whether or not + Stranger Danger is installed, and regardless of hook order. +- Git That Shit never mutates tool input, so it cannot interfere with Stranger Danger's inspection. +- If both plugins register `execute.before`, each is independently correct. Git That Shit does not depend on, and does + not claim, a specific ordering. + +### Other plugins and agents + +Git That Shit only reads project config/state files and writes its own shadow repository. It does not modify another +plugin's storage, other projects, global configuration, or the OpenCode service. + +## Optional interoperability (implemented) + +The single implemented cross-plugin interaction is **read-only observation of well-known, human-readable project state +files** for backup purposes. This is a path-based interface with no imports, no shared database, and no shared mutable +state: + +- `.opencode/state/checkpoint.json` +- `.opencode/state/handoff.md` +- `.opencode/state/checkpoint-history.json` +- `.opencode/state/strangerdanger-audit.jsonl` + +Absence is normal and non-fatal. Malformed content fails closed (a config JSON that cannot be parsed is skipped rather +than stored). No plugin is required to produce these files. + +## Docker Plugin Test Lab + +The external test lab can validate this plugin without any in-plugin integration framework: + +- Isolated OpenCode V2 runtime (`opencode run --standalone` / `opencode serve`) with independent XDG directories. +- Disposable project directory and a local mock model provider. +- Reproducible install from the packed tarball (`npm pack` → install). +- Automated assertions on tool registration, hook execution, and snapshots. +- Concurrent session tests (the project-scoped lock serializes writes). +- Installation combinations from the companion matrix. + +The test lab owns its code; this repository does not. + +## Development constraints + +- Do not install development versions into the shared live plugin directory; use an isolated location or tarball. +- Do not restart or reconfigure the shared OpenCode service for testing; use isolated XDG dirs and a private server. +- Do not depend on unfinished APIs, paths, or storage formats from other plugins. diff --git a/docs/RELEASE-0.3.0-EVIDENCE.md b/docs/RELEASE-0.3.0-EVIDENCE.md new file mode 100644 index 0000000..1a620dc --- /dev/null +++ b/docs/RELEASE-0.3.0-EVIDENCE.md @@ -0,0 +1,74 @@ +# Release Evidence — v0.3.0 + +This file records the artefacts that back the claims in `PROJECT_STATUS.md` for +the **v0.3.0** release candidate. Both suites below were exercised against the +same verified source commit; no later commit is covered by them. + +**Verified source commit:** `6069f916a0fac14439c3335e682f994466961851` +**Package version:** `0.3.0` + +## 1. Repository suite (in-repo) + +| Item | Value | +| --- | --- | +| Command | `npm run typecheck` && `npm test` (`bun test`) | +| Result | 322 passed, 0 failed, 24 files, 623 assertions (35.6 s) | +| Typecheck / lint / build | pass (`tsc --noEmit`, `npm run build`) | +| Artefact (repo) | `docs/RELEASE-0.3.0.md` | + +## 2. Packaging and fresh install (in-repo) + +| Item | Value | +| --- | --- | +| Command | `npm pack` → `npm install ` in an isolated prefix | +| Tarball | `gitdamnit-opencode-git-that-shit-0.3.0.tgz`, 70 files | +| Contents (non-`dist/`) | `LICENSE`, `NOTICE`, `package.json`, `README.md` | +| Installed version | `0.3.0` | +| Entry point import | default export id `gitdamnit.git-that-shit` | +| CLI | `gts version` → `Git That Shit version 0.3.0` | +| Functional smoke | `init`, `snapshot`, dry-run `restore`, `restore --yes` (redacted file correctly skipped), `--to-temp`, `doctor` all behave as documented | + +## 3. Docker Plugin Test Lab (external, independent) + +The lab is owned separately (`/home/evan/Projects/opencode-plugin-lab`) and was +**not modified** by this release. Its original report files are the artefacts of +record: + +| Item | Value | +| --- | --- | +| Report | `/home/evan/Projects/opencode-plugin-lab/reports/test-report.json` | +| Report (readable) | `/home/evan/Projects/opencode-plugin-lab/reports/test-report.md` | +| RC record | `/home/evan/Projects/opencode-plugin-lab/reports/rc-evidence.json` | +| RC record (readable) | `/home/evan/Projects/opencode-plugin-lab/reports/rc-evidence.md` | +| Result | **145 passed, 0 failed, 0 skipped** | +| OpenCode runtime | `opencode v2.0.21` | +| Node runtime | `v22.23.3` (container), `linux/x64` | +| Docker image | `opencode-plugin-lab:local` — `sha256:76ebd805cd548a638b9b1508e5f16e680cd5b082cd3f3561d28f2f2301336ff1` | +| Run window (UTC) | 2026-10-17T00:10:41Z → 2026-10-17T00:11:45Z | + +Tested commits, one per plugin, all reported clean and version-consistent +(source = staged = packed = installed): + +| Plugin | Version | Commit | +| --- | --- | --- | +| git-that-shit | 0.3.0 | `6069f916a0fac14439c3335e682f994466961851` | +| checkpoint-orchestrator | 2.0.0-dev.2 | `65bf6981eee55e6cf031bdf37a6b13b0ccd24072` | +| stranger-danger | 0.2.0 | `9cfd8135429bffb2e5e32e97f23bec3f5b6277bd` | + +The image identifier was independently re-confirmed on this machine: the tag +`opencode-plugin-lab:local` resolves to exactly the digest above (created +2026-10-16). The image digest is content-addressed, so the run is reproducible +from that image and those commits. + +The lab covers plugin load-order permutations, security, concurrency, failure +isolation, shadow-repository snapshots, locking, restore dry runs and actual +restores, and the three plugins both alone and combined. + +## 4. Scope of these claims + +- The lab result covers the **live OpenCode V2 host** paths it exercises; the + in-repo suite covers engine, CLI and the V2 plugin harness. Neither suite is a + substitute for the other. +- Strict-mode pre-execution blocking is best-effort, not guaranteed (see + `docs/INTEROP.md`). +- No live model-provider credentials were used (`--online` disabled). diff --git a/docs/RELEASE-0.3.0.md b/docs/RELEASE-0.3.0.md new file mode 100644 index 0000000..9474b90 --- /dev/null +++ b/docs/RELEASE-0.3.0.md @@ -0,0 +1,103 @@ +# Release 0.3.0 — Hardening Audit + +This document records the final security-hardening and release-readiness audit of Git That Shit v0.3.0. It is a +companion to `docs/SECURITY.md`, `docs/ARCHITECTURE.md`, and `docs/INTEROP.md`. + +## Scope + +Focused hardening on four areas — snapshot-failure safety, filesystem permissions and secret protection, restore +security, and plugin independence — without changing the architecture or adding features. No live services or provider +credentials were touched; all testing used disposable projects under `/tmp` and isolated config directories. + +## 1. Snapshot failure safety + +A protective snapshot can legitimately fail. Findings: + +- The engine already returned structured failures instead of throwing; it did **not** verify that a reported commit + actually existed, and a commit failure surfaced an opaque message. +- The plugin treated a `no-changes` snapshot as a failure, which would have blocked a destructive command on a clean + tree even though a recovery point existed. +- Command previews and manual snapshot messages were stored verbatim in the commit message, the manifest, and the + result. + +Changes: + +- `SnapshotResult.verified` is `true` only when the commit is confirmed to resolve in the shadow repository + (`git rev-parse --verify ^{commit}`). Callers key protection off `verified`, never off `committed` alone. +- `snapshot.failureMode` (`standard` default, `strict` opt-in) is applied through `src/engine/safety.ts`. + - **standard**: continue, warn clearly, never claim a recovery point. + - **strict**: deny classified *destructive* operations only. Risky package-manager operations, config writes and + harmless commands are never blocked. +- `snapshot.gitTimeoutMs` bounds shadow-repository git subprocesses; a timeout becomes a structured failure. +- `no-changes` is treated as an existing recovery point everywhere (plugin and restore). +- Preview/message/error strings are text-redacted before they can reach any durable or logged location. + +Injected failure scenarios exercised by tests: insufficient disk space, git commit failure (pre-commit hook), git +subprocess timeout (sleeping hook), corrupted shadow repository (invalid gitfile), unreadable tracked file (fail +closed), and a held project lock. + +### Strict-mode blocking — verified limitation + +The OpenCode V2 `execute.before` tool hook is awaited but returns `void`; the SDK exposes **no** throw-to-block +contract. The only pre-execution gate is `permission.evaluate`, whose `PermissionEvaluation` carries a mutable `effect` +(`"allow" | "deny" | "ask"`) and the originating tool call at `source.id`. Strict mode records the failing tool-call id +and denies exactly that call. The SDK does not specify hook ordering, so this is **best-effort**; when no permission gate +is exposed strict mode **fails open** with an explicit warning rather than advertising protection it cannot deliver. The +restore path's strict check is **guaranteed**, because Git That Shit owns that code path. + +No live OpenCode host was available in this environment, so the deny path is verified against the published 2.0.21 type +definitions and the V2 plugin harness, not a live server. This is recorded as a known limitation, not a claim. + +## 2. Filesystem permissions and secret protection + +- Git That Shit's own storage is created `0700` (directories) and `0600` (files): `.git-that-shit/` and nested state + directories, the shadow repository, stored snapshot copies, the manifest, the configuration, the lock file, and + pre-prune safety bundles. +- `chmod` runs after `mkdir` so the process umask cannot widen a directory. Only `.git-that-shit/` is touched; user + project files and unrelated directories keep their existing modes. +- POSIX modes apply on Linux/macOS only; Windows relies on ACLs. Filesystems that reject `chmod` degrade to a no-op. +- `gts doctor` reports storage permissions and warns if any storage path is group/other-accessible. +- Verified that no credential reaches the shadow history, the manifest, results, logs, or bundles, including via command + previews; fail-closed redaction is preserved. + +## 3. Restore security + +Adversarial checks and fixes: + +- `../` traversal and absolute paths: rejected lexically (existing) plus the real-path guard below. +- Symlink destination file: **refused**. +- Symlink ancestor directory (`evil/payload` where `evil` is a symlink out of the project): **refused** by resolving the + nearest existing ancestor with `realpath`. +- Symlink substitution between validation and writing: writes are atomic — a `O_EXCL|O_NOFOLLOW` sibling temp file is + `rename`d into place, and `rename` replaces a symlink instead of writing through it. +- Interrupted/partial restore: a failed write is reported in `skipped`, its temp file is removed, and no stray files + remain. An existing non-empty directory at the destination produces a clean failure, not a corrupted state. +- Redacted content is never restored, even with `force`. +- Overwriting a conflicting file still requires `force`; dry-run remains the default. +- Restores remain under the project lock; a held lock aborts the restore with a clear error. +- Strict mode refuses a restore whose mandatory pre-restore snapshot cannot be created; standard mode continues with a + warning and claims no recovery point. + +## 4. Plugin independence + +Re-verified: no `@gitdamnit/*`, Stranger Danger, Checkpoint Orchestrator, or CheapCode imports anywhere in `src/`; the +only runtime dependency is `@opencode/plugin`. Secret handling is self-contained; snapshots do not depend on any +companion; hooks are read-only with respect to the tool event (except the SDK's own `permission.evaluate` gate, used +only to deny a specific failing destructive call); state is namespaced under `.git-that-shit/`; multiple sessions +serialize on the project lock; no companion storage is mutated; no hook assumes an execution order. + +## 5. Validation performed + +Clean install, `tsc --noEmit` typecheck, lint (`tsc --noEmit`), build, the full `bun test` suite, CLI smoke tests, +`npm pack` validation, and the installed-package entrypoint import are run in CI. Isolated OpenCode V2 runtime behavior +was exercised through the V2 plugin harness (tool registration, `execute.before`/`execute.after`, event subscription, +strict-mode denial) with disposable projects and no live provider credentials. + +## Remaining risks / limitations + +- Strict-mode blocking is best-effort (see above); no live-host confirmation was possible here. +- Private-storage permissions are POSIX-only. +- Symlink hardening does not defend against a same-user attacker racing the filesystem; the intended threat model is + accidental/malicious snapshot content and stale/planted links, not an active local adversary with the same UID. +- Prune still rewrites history (unchanged by design), always writing a safety bundle first. +- Historical shadow commits are not rewritten to remove secrets (unchanged by design). diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..6fff760 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,124 @@ +# Security + +## Threat model + +Git That Shit copies project configuration and agent state into a local git repository. The main risks it guards +against are: + +1. **Leaking secrets into the shadow repository** — API keys, tokens, passwords, private keys, and credentials. +2. **Corrupting the shadow repository** through concurrent access. +3. **Silently overwriting user work** during restore. +4. **Path traversal** writing outside the project. + +## Exclusions + +Hard excludes (never tracked, regardless of config) include: + +- `node_modules`, `dist`, `build`, `coverage`, `.cache` +- `.env` (real env files) — only `.env.example` / `.env.sample` / `.env.template` are eligible +- `*.pem`, `*.key`, `*.p12`, `*.pfx`, `id_rsa`, `id_ed25519`, `credentials.json`, `service-account*.json` +- `*.log`, `*.gguf`, `*.bin` +- `*.sqlite`, `*.sqlite3`, `*.db`, `*.db-wal`, `*.db-shm` — live databases are never copied mid-write + +## Redaction + +Two complementary strategies run over every stored file: + +- **Structured JSON redaction** replaces values under secret-like keys (default: `token`, `apiKey`, `api_key`, + `secret`, `password`, `passwd`, `bearer`, `authorization`, `credential`, `privateKey`, `private_key`, `accessKey`, + `access_key`, `clientSecret`, `client_secret`) at any depth, including inside arrays and nested objects. +- **Text redaction** handles non-JSON configuration: `key: value` and `key=value` assignments, `Authorization: + Bearer/Basic/Token …` headers, and multiline PEM private-key/certificate blocks. + +The **global OpenCode config** (`config.json`, `opencode.json`, `opencode.jsonc` under the OpenCode config directory) is +always redacted before being stored as `global-opencode/*`. + +## Fail closed + +If a file cannot be processed safely — most importantly a `.json` file that does not parse — Git That Shit **skips** +it (`unredactable content (failed closed)`) and removes any stale shadow copy. It never stores content it cannot prove +is secret-free. This is a deliberate trade-off: during an incident you prefer a skipped file over a leaked secret. + +## Logging + +Secret values never appear in snapshot results, tool output, logs, or errors. Snapshot results contain file paths and +hashes, not contents. Plugin logs report only hashes, types, and reasons. + +Diagnostic strings that could carry a credential — a destructive-command preview, a manual snapshot message, a git +subprocess error — are passed through text redaction before they reach a commit message, the manifest, a result object, +or a log line. + +## Filesystem permissions + +Private snapshot storage is created with owner-only permissions: + +| Path | Mode | +| --- | --- | +| `.git-that-shit/` (and nested state dirs) | `0700` | +| Shadow repository (`…/snapshots/`) | `0700` | +| Stored snapshot copies, manifest, config, lock file, prune bundle | `0600` | + +Newly created directories are `chmod`-ed after `mkdir` so the process umask cannot widen them. **Only directories and +files Git That Shit owns under `.git-that-shit/` are modified**; user project files and unrelated directories are never +recursively `chmod`-ed. Restored files are written with the platform default mode, exactly like ordinary user files. + +POSIX modes are applied on Linux and macOS only. Windows has no POSIX mode; enforcement there relies on the platform +ACL model. Filesystems that reject `chmod` (some network/FAT mounts, hardened containers) degrade to a no-op — a failed +`chmod` never fails a snapshot or restore. + +## Snapshot failure handling + +A protective snapshot can fail. Git That Shit (a) never reports a snapshot as existing unless the commit was verified to +resolve in the shadow repository, and (b) applies a configurable `snapshot.failureMode`: + +- **`standard`** (default) — the requested operation continues; a credential-free warning is emitted; no recovery point + is claimed. +- **`strict`** (opt-in) — exclusively classified *destructive* operations are denied through the OpenCode V2 + `permission.evaluate` gate when their required snapshot fails. Risky package-manager operations, config writes and + harmless commands are never blocked. Blocking is **best-effort** (the SDK does not specify hook ordering) and fails + open with a warning if no permission gate is exposed; the restore path's strict check is guaranteed. + +## Concurrent access + +All mutations run under an atomic project-scoped lock, so two sessions or processes cannot corrupt the shadow +repository. See `docs/ARCHITECTURE.md`. + +## Restore and redacted files + +Snapshots store redacted copies, so a redacted file's stored content contains `[REDACTED]` placeholders rather than the +real secret values. Restore therefore **never** writes a redacted file: it is skipped and reported. Writing placeholders +over a live config would silently destroy working credentials. After restoring, re-enter any secrets manually. + +## Path safety + +`Tracker.shouldTrack` rejects absolute paths and any path containing a `..` segment. The snapshot and restore engines +additionally verify that every resolved source/destination stays within the project root (or the shadow repo / temp +directory). Restore refuses to write outside the project. + +Restore goes further against symlink attacks, because a lexical `..` check cannot see symlinks: + +- A destination that is itself a symlink is **refused**. +- The nearest existing ancestor of each destination is resolved with `realpath` and must stay within the real project + root, so a symlinked directory cannot redirect a write outside the project. +- Writes are **atomic**: content is written to a sibling temporary file opened with `O_EXCL|O_NOFOLLOW`, then `rename`d + into place. `rename` replaces a symlink at the destination instead of writing through it, closing the + validate-then-write race. +- A failed write is reported in `skipped` and its temporary file is removed, so a partially completed restore leaves no + stray files. + +Residual risk: a same-user attacker racing the filesystem is out of scope. + +## Secrets already in history + +Git That Shit does **not** rewrite historical shadow commits to remove secrets, because rewriting would invalidate +snapshot hashes and could destroy recovery points. If a secret was committed before redaction was corrected: + +1. Rotate the credential immediately. +2. Identify the affected commits: `git -C .git-that-shit/git-that-shit/snapshots log -p -S''`. +3. Either discard the shadow repository (`rm -rf .git-that-shit/git-that-shit/snapshots`) and re-initialize, or rewrite + history manually if you understand the trade-offs. +4. Report the exposure so the redaction rules can be extended. + +## Reporting + +Do not paste raw secrets into issues. Redact first. diff --git a/package-lock.json b/package-lock.json index 2b336cd..3508237 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,444 +1,532 @@ { - "name": "@gitdamnit/git-that-shit", - "version": "0.1.0", + "name": "@gitdamnit/opencode-git-that-shit", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@gitdamnit/git-that-shit", - "version": "0.1.0", + "name": "@gitdamnit/opencode-git-that-shit", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { - "@opencode-ai/plugin": "^1.14.48", - "minimist": "^1.2.8" + "@opencode/plugin": "2.0.21" }, "bin": { "gts": "dist/cli/index.js" }, "devDependencies": { "@types/node": "^20.10.0", - "typescript": "^5.3.3", - "vitest": "^1.0.0" + "typescript": "^5.3.3" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=14.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=14.0.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=16.0.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=14.0.0" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=14.0.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1057.0.tgz", + "integrity": "sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/core": { + "version": "3.978.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.3.tgz", + "integrity": "sha512-nslpzUCTI9b2IQMWSd6mSAXldBexPc8rr0d3dXUutXYFLFaTdsHa2GD7sJdsn3PhyjK0O5K8ypd6zi+RXXPVlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.2", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.73.tgz", + "integrity": "sha512-m4NXseF9dURaZISxetzArUAE+GfaFfbMitL0QyV4F2VH93pnhb+LHcNM5yzbvHutFDTT/kV5HfJ+JZWivumFeA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.74.tgz", + "integrity": "sha512-rsRJcW2iFUhCgclyD6OSbMEaPl8Eu0qSeRZb2uuCK1ViZ66hl5wOe/p6Vr6MO3b+ydyKgFeqza2p6tZThvILlw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.76.tgz", + "integrity": "sha512-pnKdh0Br7OJPj+7qKjzgTfDTFW0RhEMKXNDR2jv9CAM4oHuG/4z3hRh8KtutLfpmItuRtDbKBmwWkDqHwwAWXw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.19.tgz", + "integrity": "sha512-kcjvAxqQEV8yuwkX58RelNn3ne65o7JgRWf3Z/gVvbi7Xuw1qSDLJlatrLD76+40cmRxmNkLxBoyp/vAQChQ5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/credential-provider-env": "^3.972.74", + "@aws-sdk/credential-provider-http": "^3.972.76", + "@aws-sdk/credential-provider-login": "^3.972.81", + "@aws-sdk/credential-provider-process": "^3.972.74", + "@aws-sdk/credential-provider-sso": "^3.973.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.80", + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.81", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.81.tgz", + "integrity": "sha512-CeQHSbE2CnVdaHIJ/NayRaCecIER+Pm9faUPkmRATO54rCoMTEPSbVieGHzEwvm1dZM2HIs6qQZCznhayNTWJQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.86", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.86.tgz", + "integrity": "sha512-WNttSyr6e8innPtBSty//CdnqJcgZN6eAbtw3v/CR2IObz6MYZEo8eusdtuCHJ2ujwFqC2bSPHVyhXGHx8HruQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.74", + "@aws-sdk/credential-provider-http": "^3.972.76", + "@aws-sdk/credential-provider-ini": "^3.973.19", + "@aws-sdk/credential-provider-process": "^3.972.74", + "@aws-sdk/credential-provider-sso": "^3.973.18", + "@aws-sdk/credential-provider-web-identity": "^3.972.80", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.74.tgz", + "integrity": "sha512-Yj6kZFJQdbR+PygTkqdVLPHHpdDpOS9GCYVcDCuweCRRig9LF24ELQBMfyUw1vspvrZJUfnOGVyMjhaL094ahw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.18.tgz", + "integrity": "sha512-FnpPhdY4qUGtX/8X6eKxh+uD+9Y19i9FYibgk4SBe0M935s9vocHkHzaxaMNk+YkHmvzgdu8cHA5SCoeU+kkew==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/token-providers": "3.1150.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.80.tgz", + "integrity": "sha512-TJF25Ndwy2HxcVj6oxMgp8qVlJAawlExHYBXqhuC6Zt2m+A7WpdkjBD6QglNbiArlteYljZHEY3R7KKmno6ISA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "node_modules/@aws-sdk/credential-providers": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1057.0.tgz", + "integrity": "sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.1057.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-ini": "^3.972.46", + "@aws-sdk/credential-provider-login": "^3.972.45", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.6", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.48.tgz", + "integrity": "sha512-B01jdXJtAW/XOiUzGdI2mz67WyEHbGEc9X6Euzw3bjLAa+txTqdZDER4X2FsIEcXzV2ABqudVZ5thali8tCCzQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.48.tgz", + "integrity": "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], + "node_modules/@aws-sdk/token-providers": { + "version": "3.1150.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1150.0.tgz", + "integrity": "sha512-wDU36ofhabZYu+d2KSriyDbwzxNoaOp+4dG0p3qkabk7HeALR5nIWyQbjoyJZLn3iqWkv/xNdyUfbfevDVDzzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.3", + "@aws-sdk/nested-clients": "^3.997.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, "engines": { - "node": ">=12" + "node": ">=20.0.0" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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==", + "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/@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", + "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==", + "license": "ISC", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "minipass": "^7.0.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=18.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/@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==", + "license": "ISC" }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -449,9 +537,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], @@ -462,9 +550,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ "arm" ], @@ -475,9 +563,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], @@ -488,9 +576,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], @@ -501,9 +589,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], @@ -513,1053 +601,2308 @@ "win32" ] }, - "node_modules/@opencode-ai/plugin": { - "version": "1.14.48", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.48.tgz", - "integrity": "sha512-pb2ywByzn4i35WWJquEYyb8lDC/ph1PLXT+heucJN6Y9U/oeSw98JQV93IG7M6BUBks6MKD3DGDJdQfyD6x0rA==", + "node_modules/@npmcli/agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/arborist": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@npmcli/arborist/-/arborist-9.4.0.tgz", + "integrity": "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA==", + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.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/config": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/@npmcli/config/-/config-10.8.1.tgz", + "integrity": "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ==", + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.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": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/git/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/map-workspaces/-/map-workspaces-5.0.3.tgz", + "integrity": "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw==", + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/metavuln-calculator/-/metavuln-calculator-9.0.3.tgz", + "integrity": "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg==", + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/name-from-folder/-/name-from-folder-4.0.0.tgz", + "integrity": "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/@npmcli/promise-spawn/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-5.0.0.tgz", + "integrity": "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@opencode/ai": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.0.21.tgz", + "integrity": "sha512-eB4M0O44VoX075Kv+vIVxIpiKSrfjSXpNaeBSjtPgZdhZCjKN5CxjJXtyPbhrDxvmDf0PxLcvjGNKQBRMkZ0fw==", + "license": "MIT", + "dependencies": { + "@aws-sdk/credential-providers": "3.1057.0", + "@opencode/schema": "2.0.21", + "@smithy/eventstream-codec": "4.2.14", + "@smithy/util-utf8": "4.2.2", + "aws4fetch": "1.0.20", + "effect": "4.0.0-rc.112", + "google-auth-library": "10.5.0" + } + }, + "node_modules/@opencode/ai/node_modules/effect": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", + "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", + "license": "MIT", + "dependencies": { + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, + "node_modules/@opencode/ai/node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/@opencode/plugin": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.0.21.tgz", + "integrity": "sha512-ZLagbklGyYPg77GvK//ir3LaNXBsZQENU8APCnaT4elsBk7YzoVt8stR6+b4wH7Sfadw76cWSaNiWgDo2QOSHA==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.14.48", - "effect": "4.0.0-beta.59", + "@ai-sdk/provider": "3.0.8", + "@opencode/ai": "2.0.21", + "@opencode/client": "2.0.21", + "@opencode/protocol": "2.0.21", + "@opencode/schema": "2.0.21", + "@opencode/util": "2.0.21", + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-rc.112", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.2.6", - "@opentui/keymap": ">=0.2.6", - "@opentui/solid": ">=0.2.6" + "@opencode/theme": "2.0.21", + "@opentui/core": ">=0.5.14", + "@opentui/solid": ">=0.5.14", + "solid-js": ">=1.9.0" }, "peerDependenciesMeta": { - "@opentui/core": { + "@opencode/theme": { "optional": true }, - "@opentui/keymap": { + "@opentui/core": { "optional": true }, "@opentui/solid": { "optional": true + }, + "solid-js": { + "optional": true } } }, - "node_modules/@opencode-ai/sdk": { - "version": "1.14.48", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.48.tgz", - "integrity": "sha512-wKM86jCzV/ZApyWrdm3uP8XdWcS0LMbu3FV+OWz1ChiGGg1wiIWNGMJs5CY8/QX2/rUuZrd1Q1DqvdamZ0zLeg==", + "node_modules/@opencode/plugin/node_modules/@opencode/client": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.21.tgz", + "integrity": "sha512-cX9sj9qpaTlkVfdaeLCFeV43+1mcSHVhvgjkVLCYyfQJj0MmBGgx/x9AM/GHVZDEX6T2mUljn0Qh5XhFM9CgrQ==", "license": "MIT", "dependencies": { - "cross-spawn": "7.0.6" + "@opencode/protocol": "2.0.21", + "@opencode/schema": "2.0.21" + }, + "peerDependencies": { + "effect": "4.0.0-rc.112", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", - "cpu": [ - "arm" - ], - "dev": true, + "node_modules/@opencode/plugin/node_modules/effect": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", + "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@opencode/plugin/node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@opencode/protocol": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.21.tgz", + "integrity": "sha512-KvcpCPpIXPMkJaffUFwLxUAZwf6SiNf/8qFW/ZR7LEHFBcnmzyl7hPk5QthAHmnTRoR+dU2U/WMakj06ePRPyg==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@opencode/schema": "2.0.21", + "effect": "4.0.0-rc.112" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@opencode/protocol/node_modules/effect": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", + "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", + "license": "MIT", + "dependencies": { + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, + "node_modules/@opencode/protocol/node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/@opencode/schema": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.21.tgz", + "integrity": "sha512-HMIU9sCNOvbMWQeslxbTtCLfvhMyar40+VHkZq9hNjXTaPWJrGxSzbuHJQhKzeOeDaiP7t1zllUDHTxTA/5SOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-rc.112" + } + }, + "node_modules/@opencode/schema/node_modules/effect": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", + "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", + "license": "MIT", + "dependencies": { + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, + "node_modules/@opencode/schema/node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/@opencode/util": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.0.21.tgz", + "integrity": "sha512-wQaa4Q19zbK+B76Pzf2NV3eE8orwWw7IDlBi/Y4WpUaHwaaF+fCQGQxkByyOeWmSy38IQEX93c8RyLPB+WWjog==", + "license": "MIT", + "dependencies": { + "@effect/opentelemetry": "4.0.0-rc.112", + "@effect/platform-node": "4.0.0-rc.112", + "@effect/platform-node-shared": "4.0.0-rc.112", + "@npmcli/arborist": "9.4.0", + "@npmcli/config": "10.8.1", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/context-async-hooks": "2.8.0", + "@opentelemetry/exporter-trace-otlp-http": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0", + "@opentelemetry/sdk-trace-node": "2.8.0", + "cross-spawn": "7.0.6", + "effect": "4.0.0-rc.112", + "glob": "13.0.5", + "mime-types": "3.0.2", + "minimatch": "10.2.5", + "npm-package-arg": "13.0.2", + "open": "11.0.4", + "pacote": "21.5.1" + } + }, + "node_modules/@opencode/util/node_modules/@effect/opentelemetry": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/@effect/opentelemetry/-/opentelemetry-4.0.0-rc.112.tgz", + "integrity": "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <2.0.0", + "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", + "@opentelemetry/resources": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", + "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", + "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", + "effect": "^4.0.0-rc.112" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/api-logs": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-logs": { + "optional": true + }, + "@opentelemetry/sdk-metrics": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/sdk-trace-node": { + "optional": true + }, + "@opentelemetry/sdk-trace-web": { + "optional": true + } + } + }, + "node_modules/@opencode/util/node_modules/@effect/platform-node": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/@effect/platform-node/-/platform-node-4.0.0-rc.112.tgz", + "integrity": "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ==", + "license": "MIT", + "dependencies": { + "@effect/platform-node-shared": "^4.0.0-rc.112", + "mime": "^4.1.0", + "undici": "^8.10.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "effect": "^4.0.0-rc.112", + "redis": ">=5.0.0 <7.0.0" + } + }, + "node_modules/@opencode/util/node_modules/@effect/platform-node-shared": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/@effect/platform-node-shared/-/platform-node-shared-4.0.0-rc.112.tgz", + "integrity": "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ==", + "license": "MIT", + "dependencies": { + "@types/ws": "^8.18.1", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "effect": "^4.0.0-rc.112" + } + }, + "node_modules/@opencode/util/node_modules/effect": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", + "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", + "license": "MIT", + "dependencies": { + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, + "node_modules/@opencode/util/node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/@opencode/util/node_modules/undici": { + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.1.tgz", + "integrity": "sha512-usz3OVQBeh1LxNn0DzU0voLXMgn5CU/WUdpBsY6Ti5afNao+vTkmn4H/NxoXolc10cOqe2+YIG0Hspq2rsl64w==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", + "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.219.0.tgz", + "integrity": "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", + "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-transformer": "0.219.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", + "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz", + "integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.8.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "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==", "license": "MIT", "optional": true, - "os": [ - "darwin" - ] + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/bloom": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.3.0.tgz", + "integrity": "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/client": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.3.0.tgz", + "integrity": "sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-6.3.0.tgz", + "integrity": "sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/search": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-6.3.0.tgz", + "integrity": "sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/time-series": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.3.0.tgz", + "integrity": "sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@sigstore/bundle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/core": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.2.tgz", + "integrity": "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", + "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", + "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.36.0.tgz", + "integrity": "sha512-3hJiwG1F5t1SoXqp/yOCmYXziyx7ZVqF63S5W6YU201e/qwVaHPex+E/3vvyY9a3ORq/b+BydeNZpP2saQ9X2g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.5.2.tgz", + "integrity": "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.5.2.tgz", + "integrity": "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@types/node": "*" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": ">= 14" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "18 || 20 || >=22" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" + "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==", + "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/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": "*" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], + "node_modules/bin-links": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.2.tgz", + "integrity": "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w==", + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], + "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==", + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "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": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=8" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], + "node_modules/cmd-shim": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.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==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], + "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==", + "license": "MIT" + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], + "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==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">= 12" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "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.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", - "cpu": [ - "x64" - ], - "dev": true, + "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==", "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", "optional": true, - "os": [ - "openbsd" - ] + "engines": { + "node": ">=8" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "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==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "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==", + "license": "MIT" + }, + "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==", "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "engines": { + "node": ">=6" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", - "cpu": [ - "arm64" + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", - "cpu": [ - "ia32" - ], - "dev": true, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", - "cpu": [ - "x64" + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", - "cpu": [ - "x64" - ], - "dev": true, + "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==", + "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/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" + "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==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" + "node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } }, - "node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, - "license": "MIT", + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", "dependencies": { - "undici-types": "~6.21.0" + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/expect": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", - "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", - "dev": true, - "license": "MIT", + "node_modules/gcp-metadata/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/glob": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.5.tgz", + "integrity": "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw==", + "license": "BlueOak-1.0.0", "dependencies": { - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "chai": "^4.3.10" + "minimatch": "^10.2.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@vitest/runner": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", - "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", - "dev": true, - "license": "MIT", + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", "dependencies": { - "@vitest/utils": "1.6.1", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/snapshot": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", - "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", - "dev": true, + "node_modules/google-logging-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.2.0.tgz", + "integrity": "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "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==", + "license": "ISC" + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", "license": "MIT", "dependencies": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" + "gaxios": "^7.0.0", + "jws": "^4.0.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=18" } }, - "node_modules/@vitest/spy": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", - "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", - "dev": true, - "license": "MIT", + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", "dependencies": { - "tinyspy": "^2.2.0" + "lru-cache": "^11.1.0" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@vitest/utils": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", - "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", - "dev": true, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "license": "BSD-2-Clause" + }, + "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==", "license": "MIT", "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">= 14" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, + "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==", "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": ">=0.4.0" + "node": ">= 14" } }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", + "optional": true, "dependencies": { - "acorn": "^8.11.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">=0.4.0" + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "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, + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 12" + } + }, + "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==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, + "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==", "license": "MIT", "engines": { - "node": "*" + "node": ">=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, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", "license": "MIT", "engines": { - "node": ">=8" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "dev": true, + "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==", "license": "MIT", "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" }, "engines": { - "node": ">=4" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "license": "MIT", "dependencies": { - "get-func-name": "^2.0.2" + "is-inside-container": "^1.0.0" }, "engines": { - "node": "*" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "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==", + "license": "BlueOak-1.0.0", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": ">= 8" + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "bignumber.js": "^9.0.0" } }, - "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "dev": true, + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, "engines": { - "node": ">=6" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "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==", + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "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, + "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/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==", + "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==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" } }, - "node_modules/effect": { - "version": "4.0.0-beta.59", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.59.tgz", - "integrity": "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA==", + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", - "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "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": "20 || >=22" } }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "license": "MIT", + "node_modules/make-fetch-happen": { + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", + "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" }, "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/fast-check": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", - "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "node_modules/mime": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.1.tgz", + "integrity": "sha512-YAApRsTpM2+gnUFHhRNGfkX8NK0V5xj1nyQLufGlwEI+eX4xRsTcel2IXDuMEGSGjBvzykNW4VuoEACgwYe7BQ==", "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } + "https://github.com/sponsors/broofa" ], "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" + "bin": { + "mime": "bin/cli.js" }, "engines": { - "node": ">=12.17.0" + "node": ">=16" } }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "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, + "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==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">= 0.6" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, "engines": { - "node": "*" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, "engines": { - "node": ">=16" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16.17.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "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==", "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^20.17.0 || >=22.9.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "iconv-lite": "^0.7.2" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "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/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/local-pkg": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", - "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", - "dev": true, - "license": "MIT", + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "license": "BlueOak-1.0.0", "dependencies": { - "mlly": "^1.7.3", - "pkg-types": "^1.2.1" + "minipass": "^3.0.0" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "node": ">= 8" } }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "license": "MIT", + "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==", + "license": "ISC", "dependencies": { - "get-func-name": "^2.0.1" + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", + "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==", + "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==", + "license": "ISC", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, - "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/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" + "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==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=8" } }, - "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/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==", + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" } }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "dev": true, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" } }, - "node_modules/mlly/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/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/msgpackr": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", - "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, "optionalDependencies": { - "msgpackr-extract": "^3.0.2" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" } }, - "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", - "hasInstallScript": true, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "license": "MIT", - "optional": true, "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" - } - }, - "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", - "license": "MIT" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, "bin": { - "nanoid": "bin/nanoid.cjs" + "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-gyp-build-optional-packages": { @@ -1577,60 +2920,165 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "license": "MIT", + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "license": "ISC", "dependencies": { - "path-key": "^4.0.0" + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, "engines": { - "node": ">=12" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/open": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", "license": "MIT", "dependencies": { - "mimic-fn": "^4.0.0" + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", - "dev": true, + "node_modules/p-map": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", + "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, "engines": { "node": ">=18" }, @@ -1638,6 +3086,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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==", + "license": "BlueOak-1.0.0" + }, + "node_modules/pacote": { + "version": "21.5.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", + "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/parse-conflict-json": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-5.0.1.tgz", + "integrity": "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==", + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1647,91 +3146,93 @@ "node": ">=8" } }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "license": "MIT", + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "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/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, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pkg-types/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/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "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" - } - ], + "node_modules/postcss-selector-parser": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=4" } }, - "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, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "engines": { + "node": ">=20" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/proggy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/proggy/-/proggy-4.0.0.tgz", + "integrity": "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ==", + "license": "ISC", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^20.17.0 || >=22.9.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==", + "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==", + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/pure-rand": { @@ -1750,65 +3251,171 @@ ], "license": "MIT" }, - "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, + "node_modules/read-cmd-shim": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/redis": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-6.3.0.tgz", + "integrity": "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@redis/bloom": "6.3.0", + "@redis/client": "6.3.0", + "@redis/json": "6.3.0", + "@redis/search": "6.3.0", + "@redis/time-series": "6.3.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/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/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", - "dev": true, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "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": { - "rollup": "dist/bin/rollup" + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/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==", + "license": "ISC" + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" + "node": ">=16 || 14 >=14.17" }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup/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, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/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==", + "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/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "optional": true + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1830,18 +3437,10 @@ "node": ">=8" } }, - "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" @@ -1850,36 +3449,105 @@ "url": "https://github.com/sponsors/isaacs" } }, - "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", + "node_modules/sigstore": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" + }, "engines": { - "node": ">=0.10.0" + "node": "^20.17.0 || >=22.9.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/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, + "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==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "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==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "license": "CC0-1.0" + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "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==", "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, "engines": { "node": ">=12" }, @@ -1887,63 +3555,144 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", - "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", - "dev": true, + "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==", "license": "MIT", "dependencies": { - "js-tokens": "^9.0.1" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "engines": { + "node": ">=8" } }, - "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, + "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==", + "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==", "license": "MIT" }, - "node_modules/tinypool": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", - "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", - "dev": true, + "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==", "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=14.0.0" + "node": ">=8" } }, - "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", - "dev": true, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, "engines": { - "node": ">=14.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "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==", "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, "engines": { - "node": ">=20" + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/treeverse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", + "integrity": "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/type-detect": { + "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==", + "license": "0BSD" + }, + "node_modules/tuf-js": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, "engines": { - "node": ">=4" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/typescript": { @@ -1960,242 +3709,230 @@ "node": ">=14.17" } }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" + "node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", - "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" + "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/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": 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==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "isexe": "^2.0.0" }, "bin": { - "vite": "bin/vite.js" + "node-which": "bin/node-which" }, "engines": { - "node": "^18.0.0 || >=20.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", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } + "node": ">= 8" } }, - "node_modules/vite-node": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", - "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", - "dev": true, + "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==", "license": "MIT", "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=12" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/vitest": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", - "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", - "dev": true, + "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==", "license": "MIT", "dependencies": { - "@vitest/expect": "1.6.1", - "@vitest/runner": "1.6.1", - "@vitest/snapshot": "1.6.1", - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.3", - "vite": "^5.0.0", - "vite-node": "1.6.1", - "why-is-node-running": "^2.2.2" - }, - "bin": { - "vitest": "vitest.mjs" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=10" }, "funding": { - "url": "https://opencollective.com/vitest" + "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==", + "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==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.6.1", - "@vitest/ui": "1.6.1", - "happy-dom": "*", - "jsdom": "*" + "engines": { + "node": ">=8" }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">= 8" + "node": ">=8" } }, - "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, + "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==", "license": "MIT", "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" + "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" } }, - "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/write-file-atomic": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", "license": "ISC", - "bin": { - "yaml": "bin.mjs" + "dependencies": { + "signal-exit": "^4.0.1" }, "engines": { - "node": ">= 14.6" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" }, - "funding": { - "url": "https://github.com/sponsors/eemeli" + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, + "node_modules/wsl-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.2.0" + }, "engines": { - "node": ">=12.20" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "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==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/zod": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", diff --git a/package.json b/package.json index bd1c2c4..87467c1 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,43 @@ { "name": "@gitdamnit/opencode-git-that-shit", - "version": "0.2.0", - "description": "automatic pre-disaster snapshots for OpenCode AI coding agent config and state files", + "version": "0.3.0", + "description": "Automatic pre-disaster snapshots for OpenCode AI coding agent config and state files.", "license": "Apache-2.0", + "type": "module", "main": "./dist/index.js", - "files": [ - "dist/" - ], + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./cli": { + "import": "./dist/cli/index.js" + } + }, "bin": { "gts": "./dist/cli/index.js" }, + "files": [ + "dist/", + "NOTICE" + ], "scripts": { - "build": "tsc", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "build": "npm run clean && tsc", "dev": "tsc --watch", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit", "test": "bun test", - "test:watch": "bun test --watch" + "pretest": "npm run build", + "prepublishOnly": "npm run build" }, "dependencies": { - "@opencode-ai/plugin": "^1.14.48", - "minimist": "^1.2.8" + "@opencode/plugin": "2.0.21" }, "devDependencies": { "@types/node": "^20.10.0", - "typescript": "^5.3.3", - "vitest": "^1.0.0" + "typescript": "^5.3.3" }, "engines": { "node": ">=18.0.0" diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index cd17ec7..b5e03b1 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -25,7 +25,6 @@ export default async function initCommand(args: string[]): Promise { try { await snapshot.initShadowRepo(projectRoot); - const shadowPath = projectRoot + "/.git-that-shit/git-that-shit/snapshots"; console.log(`Shadow repo initialized at: ${shadowPath}`); console.log("Creating initial snapshot..."); diff --git a/src/cli/commands/prune.ts b/src/cli/commands/prune.ts index b398f4a..f549a00 100644 --- a/src/cli/commands/prune.ts +++ b/src/cli/commands/prune.ts @@ -1,44 +1,59 @@ import { resolveProjectRoot } from "../../shared/paths.js"; -import * as manifest from "../../engine/manifest.js"; -import { getShadowRepoPath } from "../../shared/paths.js"; +import { previewPrune, pruneSnapshots } from "../../engine/prune.js"; export default async function pruneCommand(args: string[]): Promise { const projectRoot = resolveProjectRoot(); let keep = 50; - let dryRun = true; + let confirm = false; - for (const arg of args) { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; if (arg.startsWith("-k=") || arg.startsWith("--keep=")) { keep = parseInt(arg.split("=")[1], 10); - } else if (arg === "--yes") { - dryRun = false; + } else if (arg === "-k" || arg === "--keep") { + if (args[i + 1]) { + keep = parseInt(args[i + 1], 10); + i++; + } + } else if (arg === "--yes" || arg === "-y") { + confirm = true; } } - const snapshots = await manifest.readManifest(projectRoot); + const plan = await previewPrune(projectRoot, keep); - if (snapshots.length <= keep) { - console.log(`Nothing to prune. ${snapshots.length} snapshots, keeping ${keep}.`); + if (plan.pruneCount === 0) { + console.log(`Nothing to prune. ${plan.totalSnapshots} snapshots, keeping ${plan.keep}.`); return; } - const toDelete = snapshots.slice(0, snapshots.length - keep); - const toKeep = snapshots.slice(snapshots.length - keep); + console.log( + `Would prune ${plan.pruneCount} snapshot(s), keeping the newest ${plan.keep}.\n` + ); - console.log(`Would delete ${toDelete.length} snapshot(s), keeping ${toKeep.length}.\n`); - - for (const snap of toDelete) { - console.log(` Would delete: ${snap.shortHash} - ${snap.reason}`); + for (const snap of plan.toPrune) { + console.log(` ${snap.shortHash} | ${snap.type} | ${snap.reason}`); } - if (dryRun) { + if (!confirm) { console.log("\nDry-run mode. Use --yes to actually prune."); return; } - console.log("\nPruning is not implemented in v0.1."); - console.log("Use git directly on the shadow repo if needed:"); - console.log(` cd ${getShadowRepoPath(projectRoot)}`); - console.log(" git reflog ... or git reset --hard "); -} \ No newline at end of file + const result = await pruneSnapshots(projectRoot, keep, true); + + if (result.error) { + console.error(`\nPrune failed: ${result.error}`); + if (result.backupPath) { + console.error(`A safety backup was written to: ${result.backupPath}`); + } + process.exit(1); + } + + console.log(`\nPruned ${result.pruneCount} snapshot(s), kept ${result.keep}.`); + if (result.backupPath) { + console.log(`Safety backup: ${result.backupPath}`); + } + console.log("Remaining snapshots reference real commits; run 'gts list' to verify."); +} diff --git a/src/cli/commands/restore.ts b/src/cli/commands/restore.ts index 002f90f..03015af 100644 --- a/src/cli/commands/restore.ts +++ b/src/cli/commands/restore.ts @@ -5,7 +5,9 @@ export default async function restoreCommand(args: string[]): Promise { const projectRoot = resolveProjectRoot(); if (args.length === 0) { - console.error("Usage: gts restore [--dry-run] [--yes] [--overwrite] [--to-temp]"); + console.error( + "Usage: gts restore [--dry-run] [--yes] [--overwrite] [--force] [--to-temp]" + ); process.exit(1); } @@ -16,7 +18,8 @@ export default async function restoreCommand(args: string[]): Promise { ? "overwrite" : "dry-run"; - const force = args.includes("--force"); + // --overwrite and --force both mean "overwrite conflicting files". + const force = args.includes("--force") || args.includes("--overwrite"); const targetHash = await restore.getRestoreTarget(projectRoot, hash); @@ -51,14 +54,32 @@ export default async function restoreCommand(args: string[]): Promise { } if (result.missing.length > 0) { - console.log("\nMissing from project (will be skipped):"); + console.log("\nMissing from project (will be created):"); for (const file of result.missing) { console.log(` ${file}`); } } + if (result.conflicts.length > 0) { + console.log("\nConflicting files (changed since snapshot):"); + for (const file of result.conflicts) { + console.log(` ${file}`); + } + console.log("\nConflicts are only overwritten with --force/--overwrite."); + } + + if (result.redacted.length > 0) { + console.log("\nRedacted files (snapshot holds placeholders; never restored):"); + for (const file of result.redacted) { + console.log(` ${file}`); + } + } + console.log("\nTo perform restore, use: gts restore --yes"); - } else if (mode === "to-temp") { + return; + } + + if (mode === "to-temp") { console.log("=== Restore to temp mode ===\n"); if (result.tempDir) { @@ -68,34 +89,41 @@ export default async function restoreCommand(args: string[]): Promise { if (result.restored.length === 0) { console.log("No files were restored."); } - } else { - console.log("=== Restore mode ===\n"); + return; + } - console.log(`Restored ${result.restored.length} files`); + console.log("=== Restore mode ===\n"); + console.log(`Restored ${result.restored.length} file(s)`); - if (result.created.length > 0) { - console.log("\nCreated new files:"); - for (const file of result.created) { - console.log(` ${file}`); - } + if (result.created.length > 0) { + console.log("\nCreated new files:"); + for (const file of result.created) { + console.log(` ${file}`); } + } - if (result.overwritten.length > 0) { - console.log("\nOverwritten files:"); - for (const file of result.overwritten) { - console.log(` ${file}`); - } + if (result.overwritten.length > 0) { + console.log("\nOverwritten files:"); + for (const file of result.overwritten) { + console.log(` ${file}`); } + } - if (result.conflicts.length > 0) { - console.log("\nFiles with conflicts:"); - for (const file of result.conflicts) { - console.log(` ${file}`); - } + if (result.conflicts.length > 0) { + console.log("\nConflicting files (need --force to overwrite):"); + for (const file of result.conflicts) { + console.log(` ${file}`); } + } - if (result.preRestoreHash) { - console.log(`\nPre-restore snapshot: ${result.preRestoreHash.slice(0, 7)}`); + if (result.redacted.length > 0) { + console.log("\nRedacted files skipped (snapshot holds placeholders; re-enter secrets manually):"); + for (const file of result.redacted) { + console.log(` ${file}`); } } -} \ No newline at end of file + + if (result.preRestoreHash) { + console.log(`\nPre-restore snapshot: ${result.preRestoreHash.slice(0, 7)}`); + } +} diff --git a/src/cli/commands/snapshot.ts b/src/cli/commands/snapshot.ts index 0d920c7..863b830 100644 --- a/src/cli/commands/snapshot.ts +++ b/src/cli/commands/snapshot.ts @@ -29,6 +29,9 @@ export default async function snapshotCommand(args: string[]): Promise { if (!result.committed) { console.log(`Snapshot ${result.reason}`); + if (result.reason === "no-changes") { + return; + } if (result.error) { console.error(`Error: ${result.error}`); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 2b486a5..98b4203 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,5 +1,7 @@ #!/usr/bin/env node +import { getPackageVersion } from "../shared/version.js"; + const main = async () => { const args = process.argv.slice(2); const cmd = args[0]; @@ -25,8 +27,8 @@ Commands: return; } - if (cmd === "version" || cmd === "-v") { - console.log("Git That Shit version 0.1.0"); + if (cmd === "version" || cmd === "-v" || cmd === "--version") { + console.log(`Git That Shit version ${getPackageVersion()}`); return; } diff --git a/src/engine/config.ts b/src/engine/config.ts index ef50f22..8671dd6 100644 --- a/src/engine/config.ts +++ b/src/engine/config.ts @@ -1,6 +1,7 @@ import * as fs from "fs/promises"; import * as path from "path"; -import { getConfigPath, ensureDir } from "../shared/paths.js"; +import { getConfigPath } from "../shared/paths.js"; +import { writePrivateFile, hardenFile } from "../shared/permissions.js"; import type { GitThatShitConfig } from "../shared/types.js"; import { ConfigError } from "../shared/errors.js"; @@ -9,6 +10,7 @@ export const DEFAULT_CONFIG: GitThatShitConfig = { tracking: { include: [ "opencode.json", + "opencode.jsonc", ".opencode/state/checkpoint.json", ".opencode/state/handoff.md", ".opencode/state/checkpoint-history.json", @@ -69,6 +71,8 @@ export const DEFAULT_CONFIG: GitThatShitConfig = { snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100, + gitTimeoutMs: 30000, + failureMode: "standard", }, restore: { autoPreRestoreSnapshot: true, @@ -110,8 +114,10 @@ export async function loadConfig(projectRoot: string): Promise { const configPath = getConfigPath(projectRoot); - ensureDir(path.dirname(configPath)); - await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf-8"); + // The configuration names secret keys and points at private storage; keep it + // owner-only. + await writePrivateFile(configPath, JSON.stringify(config, null, 2)); + hardenFile(configPath); } export async function resetConfig(projectRoot: string): Promise { @@ -151,6 +157,8 @@ function mergeConfig(defaultConfig: GitThatShitConfig, userConfig: Partial { const checks: DoctorCheck[] = []; @@ -34,6 +36,7 @@ export async function runDoctor(projectRoot: string): Promise { checks.push(checkEnvExample(projectRoot)); checks.push(checkEnvExcluded(projectRoot)); checks.push(await checkProjectGitUntouched(projectRoot)); + checks.push(checkStoragePermissions(projectRoot)); const hasFail = checks.some((c) => c.status === "fail"); const hasWarn = checks.some((c) => c.status === "warn"); @@ -286,6 +289,40 @@ function checkEnvExcluded(projectRoot: string): DoctorCheck { }; } +function checkStoragePermissions(projectRoot: string): DoctorCheck { + if (!POSIX_SUPPORTED) { + return { + name: "storage-permissions", + status: "skipped", + message: "POSIX permissions are not applicable on this platform", + }; + } + + const tooOpen: string[] = []; + for (const dir of getPrivateStateDirs(projectRoot)) { + try { + const stat = fs.statSync(dir); + if ((stat.mode & 0o077) !== 0) { + tooOpen.push(path.relative(projectRoot, dir) || dir); + } + } catch { + // Directory not created yet; nothing to check. + } + } + + return tooOpen.length === 0 + ? { + name: "storage-permissions", + status: "ok", + message: "Git That Shit storage is owner-only (0700)", + } + : { + name: "storage-permissions", + status: "warn", + message: `Group/other-accessible storage: ${tooOpen.join(", ")}`, + }; +} + async function checkProjectGitUntouched(projectRoot: string): Promise { const isProjectGitRepo = await git.isGitRepo(projectRoot); diff --git a/src/engine/git.ts b/src/engine/git.ts index bf3cc5a..bc79082 100644 --- a/src/engine/git.ts +++ b/src/engine/git.ts @@ -3,6 +3,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import { GitOperationError } from "../shared/errors.js"; import { fileExists } from "../shared/paths.js"; +import { PRIVATE_FILE_MODE, hardenFile } from "../shared/permissions.js"; import type { GitLogEntry } from "../shared/types.js"; const DEFAULT_TIMEOUT = 30000; @@ -13,14 +14,16 @@ async function spawnGit( cwd?: string; timeout?: number; captureStderr?: boolean; + env?: Record; } = {} ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const { cwd = process.cwd(), timeout = DEFAULT_TIMEOUT, captureStderr = true } = options; + const { cwd = process.cwd(), timeout = DEFAULT_TIMEOUT, captureStderr = true, env } = options; return new Promise((resolve) => { const proc = spawn("git", args, { cwd, stdio: captureStderr ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "ignore"], + env: env ? { ...process.env, ...env } : process.env, }); let stdout = ""; @@ -72,8 +75,8 @@ export async function getGitVersion(): Promise { } } -export async function initRepo(repoPath: string): Promise { - const result = await spawnGit(["init"], { cwd: repoPath }); +export async function initRepo(repoPath: string, timeout: number = DEFAULT_TIMEOUT): Promise { + const result = await spawnGit(["init"], { cwd: repoPath, timeout }); if (result.exitCode !== 0) { throw new GitOperationError(`Failed to init repo: ${result.stderr}`, "git init", result.exitCode); } @@ -115,8 +118,8 @@ export async function getLocalConfig(repoPath: string, key: string): Promise { - const result = await spawnGit(["diff", "--cached", "--quiet"], { cwd: repoPath }); +export async function hasStagedChanges(repoPath: string, timeout: number = DEFAULT_TIMEOUT): Promise { + const result = await spawnGit(["diff", "--cached", "--quiet"], { cwd: repoPath, timeout }); return result.exitCode === 1; } @@ -125,23 +128,57 @@ export async function hasUnstagedChanges(repoPath: string): Promise { return result.exitCode === 1; } -export async function addAll(repoPath: string): Promise { - const result = await spawnGit(["add", "-A"], { cwd: repoPath }); +export async function addAll(repoPath: string, timeout: number = DEFAULT_TIMEOUT): Promise { + const result = await spawnGit(["add", "-A"], { cwd: repoPath, timeout }); if (result.exitCode !== 0) { throw new GitOperationError(`Failed to stage files: ${result.stderr}`, "git add -A", result.exitCode); } } -export async function commit(repoPath: string, message: string): Promise { - const result = await spawnGit(["commit", "-m", message], { cwd: repoPath, captureStderr: false }); +export async function commit( + repoPath: string, + message: string, + timeout: number = DEFAULT_TIMEOUT +): Promise { + const result = await spawnGit(["commit", "-m", message], { + cwd: repoPath, + captureStderr: false, + timeout, + }); if (result.exitCode !== 0) { - throw new GitOperationError(`Failed to commit: ${result.stderr}`, `git commit -m "${message}"`, result.exitCode); + // stderr is not captured for commit: it may echo tracked file names and, in + // pathological cases, content. The exit code alone identifies the failure. + throw new GitOperationError( + `Failed to commit (exit ${result.exitCode})`, + `git commit`, + result.exitCode + ); } - const hashResult = await spawnGit(["rev-parse", "HEAD"], { cwd: repoPath }); + const hashResult = await spawnGit(["rev-parse", "HEAD"], { cwd: repoPath, timeout }); return hashResult.stdout; } +/** + * Verify that `hash` resolves to a real commit object in the repository. + * Used so a snapshot is never reported as existing unless its creation is + * confirmed. + */ +export async function commitExists( + repoPath: string, + hash: string, + timeout: number = DEFAULT_TIMEOUT +): Promise { + if (!hash) { + return false; + } + const result = await spawnGit(["rev-parse", "--verify", "--quiet", `${hash}^{commit}`], { + cwd: repoPath, + timeout, + }); + return result.exitCode === 0 && result.stdout.length > 0; +} + export async function log(repoPath: string, count: number = 50): Promise { const result = await spawnGit( ["log", `--max-count=${count}`, "--pretty=format:%H%n%at%n%s%n---"], @@ -267,7 +304,8 @@ export async function getCommitDate(repoPath: string, hash: string): Promise { const gitignorePath = path.join(repoPath, ".gitignore"); - await fs.writeFile(gitignorePath, content, "utf-8"); + await fs.writeFile(gitignorePath, content, { encoding: "utf-8", mode: PRIVATE_FILE_MODE }); + hardenFile(gitignorePath, PRIVATE_FILE_MODE); } export async function getTrackingFiles(repoPath: string): Promise { @@ -276,4 +314,108 @@ export async function getTrackingFiles(repoPath: string): Promise { return []; } return result.stdout.split("\n").filter((f) => f); -} \ No newline at end of file +} + +/** All commit hashes, oldest first. */ +export async function revListReverse(repoPath: string): Promise { + const result = await spawnGit(["rev-list", "--reverse", "HEAD"], { cwd: repoPath }); + if (result.exitCode !== 0) { + return []; + } + return result.stdout.split("\n").filter((f) => f); +} + +export interface CommitMeta { + tree: string; + message: string; + authorName: string; + authorEmail: string; + authorDate: string; + committerName: string; + committerEmail: string; + committerDate: string; +} + +export async function getCommitMeta(repoPath: string, hash: string): Promise { + const format = "%T%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%B"; + const result = await spawnGit(["show", "-s", `--format=${format}`, hash], { cwd: repoPath }); + if (result.exitCode !== 0) { + return null; + } + const parts = result.stdout.split("\u0000"); + if (parts.length < 8) { + return null; + } + return { + tree: parts[0].trim(), + authorName: parts[1], + authorEmail: parts[2], + authorDate: parts[3], + committerName: parts[4], + committerEmail: parts[5], + committerDate: parts[6], + message: parts.slice(7).join("\u0000").trim(), + }; +} + +export async function commitTree( + repoPath: string, + tree: string, + parent: string | null, + meta: CommitMeta +): Promise { + const args = ["commit-tree", tree]; + if (parent) { + args.push("-p", parent); + } + args.push("-m", meta.message); + + const result = await spawnGit(args, { + cwd: repoPath, + env: { + GIT_AUTHOR_NAME: meta.authorName, + GIT_AUTHOR_EMAIL: meta.authorEmail, + GIT_AUTHOR_DATE: meta.authorDate, + GIT_COMMITTER_NAME: meta.committerName, + GIT_COMMITTER_EMAIL: meta.committerEmail, + GIT_COMMITTER_DATE: meta.committerDate, + }, + }); + + if (result.exitCode !== 0) { + throw new GitOperationError(`Failed to create commit: ${result.stderr}`, "git commit-tree", result.exitCode); + } + return result.stdout.trim(); +} + +/** Move `refPath` from `expectedOld` to `newHash` atomically (compare-and-swap). */ +export async function updateRef( + repoPath: string, + refPath: string, + newHash: string, + expectedOld?: string +): Promise { + const args = ["update-ref", refPath, newHash]; + if (expectedOld) { + args.push(expectedOld); + } + const result = await spawnGit(args, { cwd: repoPath }); + return result.exitCode === 0; +} + +export async function resetHard(repoPath: string, hash: string): Promise { + const result = await spawnGit(["reset", "--hard", hash], { cwd: repoPath }); + if (result.exitCode !== 0) { + throw new GitOperationError(`Failed to reset: ${result.stderr}`, "git reset --hard", result.exitCode); + } +} + +/** The branch HEAD currently points at, or "master" on an unborn branch. */ +export async function getHeadRef(repoPath: string): Promise { + const result = await spawnGit(["symbolic-ref", "--short", "HEAD"], { cwd: repoPath }); + if (result.exitCode === 0 && result.stdout) { + return result.stdout; + } + const branch = await getCurrentBranch(repoPath); + return branch || "master"; +} diff --git a/src/engine/index.ts b/src/engine/index.ts index 7eb28d0..1e00bd7 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -8,4 +8,6 @@ export * from "./disk.js"; export * from "./snapshot.js"; export * from "./restore.js"; export * from "./detector.js"; -export * from "./doctor.js"; \ No newline at end of file +export * from "./doctor.js"; +export * from "./prune.js"; +export * from "./safety.js"; \ No newline at end of file diff --git a/src/engine/lock.ts b/src/engine/lock.ts index 11a070c..771207e 100644 --- a/src/engine/lock.ts +++ b/src/engine/lock.ts @@ -1,13 +1,28 @@ import * as fs from "fs/promises"; import * as path from "path"; -import { getLockPath, ensureDir } from "../shared/paths.js"; +import { getLockPath } from "../shared/paths.js"; +import { PRIVATE_FILE_MODE, ensurePrivateDir, hardenFile } from "../shared/permissions.js"; import type { LockFile } from "../shared/types.js"; import { LockError } from "../shared/errors.js"; +/** A lock older than this is considered stale even if the PID looks alive. */ const LOCK_TIMEOUT_MS = 30000; -const RETRY_DELAY_MS = 500; -const MAX_RETRIES = 3; - +/** Delay between acquisition attempts. */ +const RETRY_DELAY_MS = 200; +/** Maximum acquisition attempts before giving up (~3s of contention). */ +const MAX_RETRIES = 15; + +/** + * Acquire an exclusive, project-scoped lock. + * + * Acquisition is atomic: the lock file is created with the `wx` flag so only a + * single process can ever win the race. We never do read-then-write, which + * would allow two processes to both believe they hold the lock. + * + * If a lock already exists it is inspected for staleness. A stale lock (dead + * PID or older than {@link LOCK_TIMEOUT_MS}) is removed and the attempt is + * retried. A lock held by a live process is waited on, then reported. + */ export async function acquireLock( projectRoot: string, operation: string, @@ -17,58 +32,79 @@ export async function acquireLock( const pid = process.pid; const timestamp = new Date().toISOString(); + ensurePrivateDir(path.dirname(lockPath)); + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { try { - const existingLock = await readLock(projectRoot); - - if (existingLock) { - const lockAge = Date.now() - new Date(existingLock.timestamp).getTime(); - - if (lockAge < LOCK_TIMEOUT_MS) { - const isStale = await isLockStale(existingLock); - if (!isStale) { - if (attempt < MAX_RETRIES - 1) { - await sleep(RETRY_DELAY_MS); - continue; - } - throw new LockError(`Lock held by PID ${existingLock.pid}, operation: ${existingLock.operation}`); - } + // Atomic create. O_EXCL fails if the file exists. Mode 0600 so the lock + // (which records the operation and reason) is never world-readable. + const handle = await fs.open(lockPath, "wx", PRIVATE_FILE_MODE); + try { + const lockFile: LockFile = { pid, timestamp, operation, reason }; + await handle.writeFile(JSON.stringify(lockFile, null, 2), "utf-8"); + await handle.sync(); + } finally { + await handle.close(); + } + hardenFile(lockPath, PRIVATE_FILE_MODE); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + if (attempt < MAX_RETRIES - 1) { + await sleep(RETRY_DELAY_MS); + continue; } - - await removeLock(projectRoot); + throw new LockError(`Failed to acquire lock: ${(error as Error).message}`); } - const lockFile: LockFile = { - pid, - timestamp, - operation, - reason, - }; + // Lock exists. Decide whether it is stale. + const existing = await readLock(projectRoot); - ensureDir(path.dirname(lockPath)); - await fs.writeFile(lockPath, JSON.stringify(lockFile, null, 2), "utf-8"); - return true; - } catch (error) { - if (error instanceof LockError) { - throw error; + if (existing) { + const stale = await isLockStale(existing); + if (stale) { + // Best-effort removal; if another process removed it first that is fine. + await safeUnlink(lockPath); + continue; + } + + if (attempt < MAX_RETRIES - 1) { + await sleep(RETRY_DELAY_MS); + continue; + } + + throw new LockError( + `Lock held by PID ${existing.pid}, operation: ${existing.operation}` + ); } + + // Lock file exists but is unreadable (e.g. truncated mid-write). Treat a + // persistently unreadable lock as stale after the retry budget. if (attempt < MAX_RETRIES - 1) { await sleep(RETRY_DELAY_MS); + continue; } + await safeUnlink(lockPath); } } throw new LockError("Failed to acquire lock after max retries"); } -export async function releaseLock(projectRoot: string): Promise { +/** + * Release the lock. Only removes the lock when it is owned by `pid` + * (defaults to the current process) or when it is unreadable, so a process + * can never delete a lock that another process legitimately holds. + */ +export async function releaseLock(projectRoot: string, pid: number = process.pid): Promise { const lockPath = getLockPath(projectRoot); + const existing = await readLock(projectRoot); - try { - await fs.unlink(lockPath); - } catch { - // Lock file doesn't exist, that's fine + if (existing && existing.pid !== pid) { + return; } + + await safeUnlink(lockPath); } export async function readLock(projectRoot: string): Promise { @@ -76,7 +112,11 @@ export async function readLock(projectRoot: string): Promise { try { const content = await fs.readFile(lockPath, "utf-8"); - return JSON.parse(content) as LockFile; + const parsed = JSON.parse(content) as LockFile; + if (typeof parsed?.pid !== "number") { + return null; + } + return parsed; } catch { return null; } @@ -85,14 +125,18 @@ export async function readLock(projectRoot: string): Promise { export async function isLockStale(lock: LockFile): Promise { const lockAge = Date.now() - new Date(lock.timestamp).getTime(); - if (lockAge > LOCK_TIMEOUT_MS) { + if (!Number.isFinite(lockAge) || lockAge > LOCK_TIMEOUT_MS) { return true; } try { process.kill(lock.pid, 0); return false; - } catch { + } catch (error) { + // ESRCH: no such process. EPERM: process exists but we cannot signal it. + if ((error as NodeJS.ErrnoException).code === "EPERM") { + return false; + } return true; } } @@ -106,20 +150,31 @@ export async function isLocked(projectRoot: string): Promise { const stale = await isLockStale(lock); if (stale) { - await removeLock(projectRoot); + await removeStaleLock(projectRoot, lock.pid); return false; } return true; } -async function removeLock(projectRoot: string): Promise { - const lockPath = getLockPath(projectRoot); +/** + * Remove the lock only if it still belongs to the process we observed as stale. + * This protects against deleting a fresh lock created by a competing process + * between our staleness check and the unlink. + */ +async function removeStaleLock(projectRoot: string, pid: number): Promise { + const current = await readLock(projectRoot); + if (current && current.pid !== pid) { + return; + } + await safeUnlink(getLockPath(projectRoot)); +} +async function safeUnlink(lockPath: string): Promise { try { await fs.unlink(lockPath); } catch { - // Ignore errors + // Ignore: already gone. } } @@ -127,6 +182,10 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Run `fn` while holding the exclusive lock. The lock is always released, even + * when `fn` throws. The entire `fn` body is protected. + */ export async function withLock( projectRoot: string, operation: string, @@ -152,9 +211,9 @@ export async function cleanupStaleLocks(projectRoot: string): Promise { const stale = await isLockStale(lock); if (stale) { - await removeLock(projectRoot); + await removeStaleLock(projectRoot, lock.pid); return 1; } return 0; -} \ No newline at end of file +} diff --git a/src/engine/manifest.ts b/src/engine/manifest.ts index c1ffebe..d90a66a 100644 --- a/src/engine/manifest.ts +++ b/src/engine/manifest.ts @@ -1,6 +1,7 @@ import * as fs from "fs/promises"; import * as path from "path"; -import { getManifestPath, ensureDir } from "../shared/paths.js"; +import { getManifestPath, getShadowRepoPath } from "../shared/paths.js"; +import { PRIVATE_FILE_MODE, ensurePrivateDir, hardenFile } from "../shared/permissions.js"; import type { ManifestEntry, SnapshotType } from "../shared/types.js"; import * as git from "./git.js"; @@ -9,10 +10,30 @@ export async function appendManifestEntry( entry: ManifestEntry ): Promise { const manifestPath = getManifestPath(projectRoot); - ensureDir(path.dirname(manifestPath)); + ensurePrivateDir(path.dirname(manifestPath)); const line = JSON.stringify(entry) + "\n"; - await fs.appendFile(manifestPath, line, "utf-8"); + await fs.appendFile(manifestPath, line, { encoding: "utf-8", mode: PRIVATE_FILE_MODE }); + hardenFile(manifestPath, PRIVATE_FILE_MODE); +} + +/** Atomically replace the entire manifest. */ +export async function writeManifest( + projectRoot: string, + entries: ManifestEntry[] +): Promise { + const manifestPath = getManifestPath(projectRoot); + ensurePrivateDir(path.dirname(manifestPath)); + + const tmpPath = `${manifestPath}.tmp-${process.pid}`; + const body = entries.map((e) => JSON.stringify(e)).join("\n"); + await fs.writeFile(tmpPath, body ? body + "\n" : "", { + encoding: "utf-8", + mode: PRIVATE_FILE_MODE, + }); + hardenFile(tmpPath, PRIVATE_FILE_MODE); + await fs.rename(tmpPath, manifestPath); + hardenFile(manifestPath, PRIVATE_FILE_MODE); } export async function readManifest( @@ -53,25 +74,21 @@ export async function readManifest( export async function rebuildManifestFromGit( projectRoot: string ): Promise { - const shadowRepoPath = path.join( - projectRoot, - ".git-that-shit", - "git-that-shit", - "snapshots" - ); + const shadowRepoPath = getShadowRepoPath(projectRoot); try { const logs = await git.log(shadowRepoPath, 100); const entries: ManifestEntry[] = []; for (const log of logs) { - const entry = parseCommitMessageToManifestEntry(log.message, log.hash); + const entry = parseCommitMessageToManifestEntry(log.message, log.hash, log.timestamp); if (entry) { entries.push(entry); } } - return entries; + // git.log returns newest first; the manifest is oldest first. + return entries.reverse(); } catch { return []; } @@ -79,7 +96,8 @@ export async function rebuildManifestFromGit( function parseCommitMessageToManifestEntry( message: string, - hash: string + hash: string, + timestampSeconds?: number ): ManifestEntry | null { const shortHash = hash.slice(0, 7); @@ -94,17 +112,25 @@ function parseCommitMessageToManifestEntry( { regex: /^pre-restore:/, type: "pre-restore" }, ]; + const iso = timestampSeconds + ? new Date(timestampSeconds * 1000).toISOString() + : new Date().toISOString(); + + const sessionMatch = message.match(/^Session:\s*(.+)$/m); + const triggerMatch = message.match(/^Triggered by:\s*(.+)$/m); + for (const { regex, type } of patterns) { if (regex.test(message)) { - const reason = message.replace(regex, "").trim(); + const reason = message.replace(regex, "").split("\n")[0].trim(); return { hash, shortHash, - timestamp: new Date().toISOString(), + timestamp: iso, type, - trigger: "rebuilt from git", + trigger: triggerMatch ? triggerMatch[1].trim() : "rebuilt from git", reason, + sessionId: sessionMatch ? sessionMatch[1].trim() : undefined, filesCopied: [], filesSkipped: [], filesRedacted: [], diff --git a/src/engine/prune.ts b/src/engine/prune.ts new file mode 100644 index 0000000..ec524d4 --- /dev/null +++ b/src/engine/prune.ts @@ -0,0 +1,159 @@ +import * as path from "path"; +import * as git from "./git.js"; +import * as manifest from "./manifest.js"; +import * as lock from "./lock.js"; +import { getProjectStateDir, getShadowRepoPath, ensureDir } from "../shared/paths.js"; +import { ensurePrivateDir, hardenFile } from "../shared/permissions.js"; +import { LockError } from "../shared/errors.js"; +import type { ManifestEntry } from "../shared/types.js"; + +export interface PrunePlan { + totalSnapshots: number; + keep: number; + pruneCount: number; + toPrune: Array<{ shortHash: string; type: string; reason: string; timestamp: string }>; +} + +export interface PruneResult extends PrunePlan { + performed: boolean; + backupPath?: string; + error?: string; +} + +const MIN_KEEP = 1; + +/** + * Compute what a prune with the given retention would do. Never mutates. + */ +export async function previewPrune(projectRoot: string, keep: number): Promise { + const retention = normalizeKeep(keep); + const entries = await manifest.readManifest(projectRoot); + + const pruneCount = Math.max(0, entries.length - retention); + const toPrune = entries.slice(0, pruneCount).map((e) => ({ + shortHash: e.shortHash, + type: e.type, + reason: e.reason, + timestamp: e.timestamp, + })); + + return { + totalSnapshots: entries.length, + keep: retention, + pruneCount, + toPrune, + }; +} + +/** + * Prune old snapshots. + * + * When `confirm` is false this is a pure preview. When true it: + * 1. writes a safety `git bundle` of the entire shadow repository, + * 2. rewrites the shadow history so it keeps only the newest `keep` commits, + * 3. atomically moves the branch (compare-and-swap) and rebuilds the manifest + * so every remaining entry references a real commit. + * + * The newest `keep` snapshots are always protected. Nothing is deleted until + * the safety bundle has been written. The whole operation runs under the + * project lock so it cannot race a snapshot or another prune. + */ +export async function pruneSnapshots( + projectRoot: string, + keep: number, + confirm: boolean +): Promise { + const plan = await previewPrune(projectRoot, keep); + + if (!confirm || plan.pruneCount === 0) { + return { ...plan, performed: false }; + } + + try { + return await lock.withLock(projectRoot, "prune", `prune to ${plan.keep}`, () => + doPrune(projectRoot, plan) + ); + } catch (error) { + if (error instanceof LockError) { + return { ...plan, performed: false, error: error.message }; + } + return { ...plan, performed: false, error: (error as Error).message }; + } +} + +async function doPrune(projectRoot: string, plan: PrunePlan): Promise { + const shadowPath = getShadowRepoPath(projectRoot); + + const commits = await git.revListReverse(shadowPath); + if (commits.length <= plan.keep) { + return { ...plan, performed: false }; + } + + const dropCount = commits.length - plan.keep; + const keptCommits = commits.slice(dropCount); + const oldHead = commits[commits.length - 1]; + const refPath = `refs/heads/${await git.getHeadRef(shadowPath)}`; + + // 1. Safety bundle before any rewrite. + const backupDir = getProjectStateDir(projectRoot); + ensurePrivateDir(backupDir); + const backupPath = path.join( + backupDir, + `prune-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.bundle` + ); + await git.createBundle(shadowPath, backupPath); + hardenFile(backupPath); + + // 2. Rewrite history: replay the kept commits as a fresh linear chain. + const newHashes: string[] = []; + let parent: string | null = null; + + for (const commit of keptCommits) { + const meta = await git.getCommitMeta(shadowPath, commit); + if (!meta) { + return { ...plan, performed: false, error: `Could not read commit ${commit}` }; + } + const newHash = await git.commitTree(shadowPath, meta.tree, parent, meta); + newHashes.push(newHash); + parent = newHash; + } + + const newHead = newHashes[newHashes.length - 1]; + + // 3. Atomic branch move. If another process changed HEAD, abort. + const moved = await git.updateRef(shadowPath, refPath, newHead, oldHead); + if (!moved) { + return { + ...plan, + performed: false, + backupPath, + error: "Shadow repository changed during prune; aborted to preserve history", + }; + } + + await git.resetHard(shadowPath, newHead); + + // 4. Update the manifest so every entry references a real commit. + const entries = await manifest.readManifest(projectRoot); + if (entries.length === commits.length) { + const keptEntries = entries.slice(dropCount).map((entry, i) => ({ + ...entry, + hash: newHashes[i], + shortHash: newHashes[i].slice(0, 7), + })); + await manifest.writeManifest(projectRoot, keptEntries); + } else { + // Manifest and git history disagree; rebuild purely from git. + const rebuilt = await manifest.rebuildManifestFromGit(projectRoot); + await manifest.writeManifest(projectRoot, rebuilt); + } + + return { ...plan, performed: true, backupPath }; +} + +function normalizeKeep(keep: number): number { + if (!Number.isFinite(keep) || keep < MIN_KEEP) { + return MIN_KEEP; + } + return Math.floor(keep); +} diff --git a/src/engine/redactor.ts b/src/engine/redactor.ts index 295e050..ab41299 100644 --- a/src/engine/redactor.ts +++ b/src/engine/redactor.ts @@ -3,11 +3,46 @@ import * as path from "path"; import { Tracker } from "./tracker.js"; export interface RedactionResult { + /** True when at least one secret value was replaced. */ redacted: boolean; content: string; keysRedacted: string[]; + /** + * False when the content could not be processed safely (for example, a JSON + * file that does not parse). Callers MUST fail closed and refuse to persist + * content when this is false. + */ + safe: boolean; } +const JSON_EXTENSIONS = new Set([".json"]); + +const TEXT_EXTENSIONS = new Set([ + ".jsonc", + ".json5", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".conf", + ".properties", + ".env", + ".envrc", + ".jsonl", + ".ndjson", + ".txt", + ".md", + ".mdx", + ".pem", + ".key", + ".npmrc", + ".yarnrc", + ".sh", + ".bash", + ".zsh", +]); + export class Redactor { private tracker: Tracker; @@ -16,107 +51,209 @@ export class Redactor { } async redactFile(filePath: string): Promise { - try { - const content = await fs.readFile(filePath, "utf-8"); - const ext = path.extname(filePath).toLowerCase(); + const content = await readTextFile(filePath); + if (content === null) { + return { redacted: false, content: "", keysRedacted: [], safe: false }; + } + return this.redactContent(filePath, content); + } - if (ext === ".json") { - return this.redactJson(content); - } + redactContent(filePath: string, content: string): RedactionResult { + const ext = path.extname(filePath).toLowerCase(); - return { - redacted: false, - content, - keysRedacted: [], - }; - } catch (error) { - return { - redacted: false, - content: "", - keysRedacted: [], - }; + if (JSON_EXTENSIONS.has(ext)) { + return this.redactJson(content); } + + return this.redactText(content); } + /** + * Structured redaction for JSON. If the document is not valid JSON we cannot + * reason about its structure, so we fail closed (`safe: false`). + */ redactJson(content: string): RedactionResult { + let parsed: unknown; try { - const parsed = JSON.parse(content); - const keysRedacted: string[] = []; + parsed = JSON.parse(content); + } catch { + return { redacted: false, content, keysRedacted: [], safe: false }; + } - this.redactObject(parsed, "", keysRedacted); + const keysRedacted: string[] = []; + const seen = new Set(); - if (keysRedacted.length > 0) { - return { - redacted: true, - content: JSON.stringify(parsed, null, 2), - keysRedacted, - }; - } + this.redactObject(parsed, "", keysRedacted, seen); - return { - redacted: false, - content, - keysRedacted: [], - }; - } catch { - return { - redacted: false, - content, - keysRedacted: [], - }; - } + // Defence in depth: run a text pass over the serialized result so inline + // secrets under non-obvious keys are still caught. + const serialized = JSON.stringify(parsed, null, 2); + const textResult = this.redactText(serialized); + + return { + redacted: keysRedacted.length > 0 || textResult.redacted, + content: textResult.content, + keysRedacted: [...new Set([...keysRedacted, ...textResult.keysRedacted])], + safe: true, + }; } - private redactObject(obj: unknown, prefix: string, keysRedacted: string[]): void { - if (obj === null || obj === undefined) { + private redactObject( + obj: unknown, + prefix: string, + keysRedacted: string[], + seen: Set + ): void { + if (obj === null || obj === undefined || typeof obj !== "object") { + return; + } + if (seen.has(obj)) { return; } + seen.add(obj); if (Array.isArray(obj)) { for (let i = 0; i < obj.length; i++) { - this.redactObject(obj[i], `${prefix}[${i}]`, keysRedacted); + this.redactObject(obj[i], `${prefix}[${i}]`, keysRedacted, seen); } return; } - if (typeof obj === "object") { - for (const [key, value] of Object.entries(obj as Record)) { - const fullKey = prefix ? `${prefix}.${key}` : key; + for (const [key, value] of Object.entries(obj as Record)) { + const fullKey = prefix ? `${prefix}.${key}` : key; - if (this.tracker.isSecretKey(key)) { - if (typeof value === "string" && value.length > 0) { - (obj as Record)[key] = "[REDACTED]"; - keysRedacted.push(fullKey); - } - } else { - this.redactObject(value, fullKey, keysRedacted); + if (this.tracker.isSecretKey(key)) { + if (typeof value === "string" && value.length > 0) { + (obj as Record)[key] = "[REDACTED]"; + keysRedacted.push(fullKey); + } else if (value !== null && typeof value === "object") { + // A structured value under a secret key (e.g. { token: { value } }). + this.redactAllStrings(value, fullKey, keysRedacted, seen); } + } else { + this.redactObject(value, fullKey, keysRedacted, seen); } } } - redactString(content: string): RedactionResult { + private redactAllStrings( + obj: unknown, + prefix: string, + keysRedacted: string[], + seen: Set + ): void { + if (obj === null || typeof obj !== "object") { + return; + } + if (seen.has(obj)) { + return; + } + seen.add(obj); + + if (Array.isArray(obj)) { + obj.forEach((item, i) => + this.redactAllStrings(item, `${prefix}[${i}]`, keysRedacted, seen) + ); + return; + } + + const record = obj as Record; + let touched = false; + for (const [key, value] of Object.entries(record)) { + if (value !== null && typeof value === "object") { + this.redactAllStrings(value, `${prefix}.${key}`, keysRedacted, seen); + touched = true; + } else if (typeof value === "string" && value.length > 0) { + record[key] = "[REDACTED]"; + touched = true; + } + } + if (touched) { + keysRedacted.push(prefix); + } + } + + /** + * Best-effort redaction of textual configuration. Handles `key: value` and + * `key=value` assignments, `Bearer`/`Basic` authorization headers, PEM + * private-key blocks and inline secrets. + */ + redactText(content: string): RedactionResult { const keysRedacted: string[] = []; - let redacted = false; + let output = content; + + // PEM blocks (multiline). + output = output.replace( + /-----BEGIN[^-]*?(PRIVATE KEY|CERTIFICATE)-----[\s\S]*?-----END[^-]*?\1-----/g, + (_match, kind: string) => { + keysRedacted.push("pem-block"); + return `[REDACTED ${kind}]`; + } + ); - for (const key of this.tracker["secretPatterns"]) { - const matches = content.match(new RegExp(`(${key.source})\\s*[:=]\\s*["']?([^"'\\s]+)["']?`, "gi")); - if (matches) { - redacted = true; - keysRedacted.push(key.source); + // Authorization headers. + output = output.replace( + /\b(authorization\s*[:=]\s*)((?:bearer|basic|token)\s+)?([^\s"',;]+)/gi, + (_match, prefix: string, scheme: string | undefined) => { + keysRedacted.push("authorization"); + return `${prefix}${scheme ?? ""}[REDACTED]`; } + ); + + for (const key of this.tracker.getSecretKeys()) { + const keyPat = `[\\w.-]*${escapeRegExp(key)}[\\w.-]*`; + + // Quoted value: token: "abc" or token='abc' + output = output.replace( + new RegExp(`(${keyPat}\\s*[:=]\\s*)(["'])([^"']*)\\2`, "gi"), + (_match, prefix: string, quote: string) => { + keysRedacted.push(key); + return `${prefix}${quote}[REDACTED]${quote}`; + } + ); + + // Unquoted value: token=abc123, apiKey: sk-live-... + output = output.replace( + new RegExp(`(${keyPat}\\s*[:=]\\s*)([^\\s"'\\n][^\\n]*)`, "gi"), + (_match, prefix: string, value: string) => { + if (value.includes("[REDACTED]")) { + return `${prefix}${value}`; + } + keysRedacted.push(key); + return `${prefix}[REDACTED]`; + } + ); } + const unique = [...new Set(keysRedacted)]; return { - redacted, - content, - keysRedacted, + redacted: unique.length > 0, + content: output, + keysRedacted: unique, + safe: true, }; } shouldRedact(filePath: string): boolean { const ext = path.extname(filePath).toLowerCase(); - return ext === ".json"; + return JSON_EXTENSIONS.has(ext) || TEXT_EXTENSIONS.has(ext); + } + + /** Backwards-compatible alias for {@link redactText}. */ + redactString(content: string): RedactionResult { + return this.redactText(content); + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +async function readTextFile(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf-8"); + } catch { + return null; } } @@ -124,25 +261,23 @@ export function createRedactor(tracker: Tracker): Redactor { return new Redactor(tracker); } +/** + * Redact a file's content before it is persisted to the shadow repository. + * + * The returned `safe` flag is authoritative: when it is false the caller must + * NOT write the file, because we could not prove it is free of secrets. + */ export async function redactFileContent( filePath: string, tracker: Tracker -): Promise<{ content: string; wasRedacted: boolean; keysRedacted: string[] }> { +): Promise<{ content: string; wasRedacted: boolean; keysRedacted: string[]; safe: boolean }> { const redactor = createRedactor(tracker); - - if (!redactor.shouldRedact(filePath)) { - try { - const content = await fs.readFile(filePath, "utf-8"); - return { content, wasRedacted: false, keysRedacted: [] }; - } catch { - return { content: "", wasRedacted: false, keysRedacted: [] }; - } - } - const result = await redactor.redactFile(filePath); + return { content: result.content, wasRedacted: result.redacted, keysRedacted: result.keysRedacted, + safe: result.safe, }; -} \ No newline at end of file +} diff --git a/src/engine/restore.ts b/src/engine/restore.ts index 758ae80..c2c0a44 100644 --- a/src/engine/restore.ts +++ b/src/engine/restore.ts @@ -6,19 +6,50 @@ import * as manifest from "./manifest.js"; import * as lock from "./lock.js"; import { snapshot } from "./snapshot.js"; import { loadConfig, DEFAULT_CONFIG } from "./config.js"; -import { getShadowRepoPath, ensureDir, copyFile, fileExists } from "../shared/paths.js"; +import { + sanitizeText, + formatSnapshotFailureWarning, + shouldBlockOperation, +} from "./safety.js"; +import { getShadowRepoPath, fileExists } from "../shared/paths.js"; import type { RestoreSnapshotInput, RestoreResult, RestoreMode } from "../shared/types.js"; +/** + * Marker written by the redactor. Any stored file containing it is a redacted + * copy whose real secret values are intentionally absent, so restoring it would + * replace live secrets with placeholders. + */ +const REDACTION_MARKER = "[REDACTED]"; + +/** + * Restore a snapshot. + * + * - `dry-run` reports what would happen and writes nothing. + * - `overwrite` writes files. Files whose current content differs from the + * snapshot are treated as conflicts and are only overwritten when `force` is + * set; otherwise they are reported and left untouched. + * - `to-temp` writes the snapshot into a temporary directory instead of the + * project. + * + * Redacted files are never restored, because their stored content contains + * `[REDACTED]` placeholders, not the real secret values. This holds even when + * `force` is set. + * + * The actual restore runs exactly once, inside a single exclusive lock, so it + * can never be applied twice or race another session. + */ export async function restoreSnapshot(input: RestoreSnapshotInput): Promise { - const { projectRoot, hash, mode, yes, force } = input; + const { projectRoot, hash, mode, force } = input; const config = await loadConfig(projectRoot).catch(() => DEFAULT_CONFIG); - const shadowPath = getShadowRepoPath(projectRoot); if (mode === "dry-run") { return await dryRunRestore(projectRoot, hash); } + const warnings: string[] = []; + let preRestoreHash: string | undefined; + if (config.restore.autoPreRestoreSnapshot && !force) { const preRestoreResult = await snapshot({ projectRoot, @@ -27,30 +58,66 @@ export async function restoreSnapshot(input: RestoreSnapshotInput): Promise { - return await realRestore(projectRoot, hash, mode); - }); + const result = await lock.withLock( + projectRoot, + "restore", + `restore to ${hash}`, + () => realRestore(projectRoot, hash, mode, force) + ); - return await realRestore(projectRoot, hash, mode); + return { + ...result, + preRestoreHash: preRestoreHash ?? result.preRestoreHash, + warnings: [...(result.warnings ?? []), ...warnings], + }; } catch (error) { return { mode, + preRestoreHash, restored: [], wouldRestore: [], overwritten: [], @@ -58,10 +125,10 @@ export async function restoreSnapshot(input: RestoreSnapshotInput): Promise { + try { + return await fs.realpath(dir); + } catch { + return path.resolve(dir); + } +} + +/** + * Reject any destination that could escape the project through a symlink. + * + * Path traversal (`..`) and absolute paths are already rejected lexically by + * {@link isWithin}. Lexical checks cannot see symlinks, so this resolves the + * nearest *existing* ancestor of the destination and requires its real path to + * stay within the real project root. It also refuses a destination that is + * itself a symlink, so a restore can never be tricked into writing through a + * pre-planted link. + */ +async function checkDestination( + realRoot: string, + destPath: string +): Promise<{ ok: boolean; reason?: string }> { + try { + const stat = await fs.lstat(destPath); + if (stat.isSymbolicLink()) { + return { ok: false, reason: "destination is a symlink (refused)" }; + } + } catch { + // Destination does not exist yet: fine. + } + + let ancestor = path.dirname(destPath); + for (;;) { + try { + const real = await fs.realpath(ancestor); + if (!isWithin(realRoot, real)) { + return { ok: false, reason: "destination resolves outside the project (symlink) (refused)" }; + } + return { ok: true }; + } catch { + const parent = path.dirname(ancestor); + if (parent === ancestor) { + return { ok: false, reason: "destination directory is not resolvable (refused)" }; + } + ancestor = parent; + } + } +} + +/** + * Write `content` to `destPath` atomically and without following symlinks. + * + * The content is written to a sibling temporary file opened with `O_EXCL` and + * `O_NOFOLLOW`, then `rename`d into place. `rename` replaces a symlink at the + * destination rather than writing through it, which closes the + * symlink-substitution window between validation and writing. + */ +export async function safeWriteFile( + realRoot: string, + destPath: string, + content: string +): Promise<{ ok: boolean; reason?: string }> { + const before = await checkDestination(realRoot, destPath); + if (!before.ok) { + return before; + } + + const dir = path.dirname(destPath); + await fs.mkdir(dir, { recursive: true }); + + // Re-validate immediately before writing: an ancestor symlink could have been + // swapped in while the directory was being created. + const after = await checkDestination(realRoot, destPath); + if (!after.ok) { + return after; + } + + const tmpPath = path.join( + dir, + `.gts-restore-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp` + ); + + const flags = + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + (typeof fs.constants.O_NOFOLLOW === "number" ? fs.constants.O_NOFOLLOW : 0); + + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(tmpPath, flags, 0o600); + await handle.writeFile(content, "utf-8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await fs.rename(tmpPath, destPath); + return { ok: true }; + } catch (error) { + if (handle) { + await handle.close().catch(() => {}); + } + await fs.unlink(tmpPath).catch(() => {}); + return { ok: false, reason: `write failed: ${sanitizeText((error as Error).message)}` }; + } +} + export async function dryRunRestore( projectRoot: string, hash: string ): Promise { const shadowPath = getShadowRepoPath(projectRoot); + const realRoot = await realRootOf(projectRoot); try { - const entries = await manifest.readManifest(projectRoot); - const found = entries.find((e) => e.hash === hash || e.shortHash === hash); + const found = await resolveManifestEntry(projectRoot, hash); if (!found) { - return { - mode: "dry-run", - restored: [], - wouldRestore: [], - overwritten: [], - created: [], - skipped: [], - missing: [], - conflicts: [], - error: `Snapshot hash ${hash} not found in manifest`, - }; + return errorResult("dry-run", `Snapshot hash ${hash} not found in manifest`); } - const snapshotFiles = await git.listFilesAtCommit(shadowPath, hash); + const snapshotFiles = await git.listFilesAtCommit(shadowPath, found.hash); const wouldRestore: string[] = []; const missing: string[] = []; + const conflicts: string[] = []; + const redacted: string[] = []; + const skipped: Array<{ path: string; reason: string }> = []; for (const file of snapshotFiles) { - if (isShadowInternalFile(file)) { + if (isShadowInternalFile(file) || isGlobalFile(file)) { + continue; + } + + const projectPath = path.join(projectRoot, file); + if (!isWithin(projectRoot, projectPath)) { + skipped.push({ path: file, reason: "unsafe path (refused)" }); + continue; + } + + const destCheck = await checkDestination(realRoot, projectPath); + if (!destCheck.ok) { + skipped.push({ path: file, reason: destCheck.reason ?? "unsafe destination (refused)" }); + continue; + } + + const snapshotContent = await git.getFileAtCommit(shadowPath, found.hash, file); + if (containsRedaction(snapshotContent)) { + redacted.push(file); continue; } wouldRestore.push(file); - const projectPath = path.join(projectRoot, file); if (!fileExists(projectPath)) { missing.push(file); + } else { + const existing = await readText(projectPath); + if (existing !== snapshotContent) { + conflicts.push(file); + } } } @@ -116,32 +317,30 @@ export async function dryRunRestore( wouldRestore, overwritten: [], created: [], - skipped: [], + skipped, missing, - conflicts: [], + conflicts, + redacted, }; } catch (error) { - return { - mode: "dry-run", - restored: [], - wouldRestore: [], - overwritten: [], - created: [], - skipped: [], - missing: [], - conflicts: [], - error: (error as Error).message, - }; + return errorResult("dry-run", sanitizeText((error as Error).message)); } } async function realRestore( projectRoot: string, hash: string, - mode: RestoreMode + mode: RestoreMode, + force?: boolean ): Promise { const shadowPath = getShadowRepoPath(projectRoot); - const tempDir = mode === "to-temp" ? await fs.mkdtemp(path.join(os.tmpdir(), "gts-restore-")) : undefined; + const realRoot = await realRootOf(projectRoot); + const found = await resolveManifestEntry(projectRoot, hash); + const targetHash = found?.hash ?? hash; + + const tempDir = + mode === "to-temp" ? await fs.mkdtemp(path.join(os.tmpdir(), "gts-restore-")) : undefined; + const tempRoot = tempDir ? await realRootOf(tempDir) : undefined; const restored: string[] = []; const overwritten: string[] = []; @@ -149,42 +348,103 @@ async function realRestore( const skipped: Array<{ path: string; reason: string }> = []; const missing: string[] = []; const conflicts: string[] = []; + const redacted: string[] = []; try { - const snapshotFiles = await git.listFilesAtCommit(shadowPath, hash); + const snapshotFiles = await git.listFilesAtCommit(shadowPath, targetHash); + + if (snapshotFiles.length === 0) { + return { + mode, + restored, + wouldRestore: [], + overwritten, + created, + skipped, + missing, + conflicts, + redacted, + tempDir, + error: `Snapshot ${hash} has no files or does not exist`, + }; + } for (const file of snapshotFiles) { - if (file.startsWith("global-opencode/")) { - skipped.push({ path: file, reason: "global config skipped in restore" }); + if (isGlobalFile(file)) { + skipped.push({ path: file, reason: "global config is never restored" }); + continue; + } + if (isShadowInternalFile(file)) { continue; } - const content = await git.getFileAtCommit(shadowPath, hash, file); + const content = await git.getFileAtCommit(shadowPath, targetHash, file); + + // Never substitute redaction placeholders for live secrets. Redacted + // files are always skipped, because the snapshot holds placeholders, not + // the real values, and writing them would destroy live credentials. + if (containsRedaction(content)) { + redacted.push(file); + skipped.push({ + path: file, + reason: "redacted snapshot content (not restored; re-enter secrets manually)", + }); + continue; + } - if (mode === "to-temp" && tempDir) { + if (mode === "to-temp" && tempDir && tempRoot) { const destPath = path.join(tempDir, file); - ensureDir(path.dirname(destPath)); - await fs.writeFile(destPath, content, "utf-8"); + if (!isWithin(tempDir, destPath)) { + skipped.push({ path: file, reason: "unsafe path (refused)" }); + continue; + } + const write = await safeWriteFile(tempRoot, destPath, content); + if (!write.ok) { + skipped.push({ path: file, reason: write.reason ?? "unsafe destination (refused)" }); + continue; + } restored.push(file); continue; } const projectPath = path.join(projectRoot, file); - const exists = fileExists(projectPath); + if (!isWithin(projectRoot, projectPath)) { + skipped.push({ path: file, reason: "unsafe path (refused)" }); + continue; + } - if (exists) { - const existingContent = await fs.readFile(projectPath, "utf-8"); - if (existingContent !== content) { - conflicts.push(file); + if (!fileExists(projectPath)) { + const write = await safeWriteFile(realRoot, projectPath, content); + if (!write.ok) { + skipped.push({ path: file, reason: write.reason ?? "unsafe destination (refused)" }); + continue; } - overwritten.push(file); - } else { created.push(file); + restored.push(file); + continue; + } + + const existing = await readText(projectPath); + + if (existing === content) { + skipped.push({ path: file, reason: "unchanged" }); + continue; } - ensureDir(path.dirname(projectPath)); - await fs.writeFile(projectPath, content, "utf-8"); - restored.push(file); + // The file exists and differs from the snapshot: a potential user change. + conflicts.push(file); + + if (force) { + const write = await safeWriteFile(realRoot, projectPath, content); + if (!write.ok) { + skipped.push({ path: file, reason: write.reason ?? "unsafe destination (refused)" }); + continue; + } + overwritten.push(file); + restored.push(file); + } else { + skipped.push({ path: file, reason: "conflict (use --force to overwrite)" }); + } } return { @@ -196,6 +456,7 @@ async function realRestore( skipped, missing, conflicts, + redacted, tempDir, }; } catch (error) { @@ -208,8 +469,9 @@ async function realRestore( skipped, missing, conflicts, + redacted, tempDir, - error: (error as Error).message, + error: sanitizeText((error as Error).message), }; } } @@ -225,14 +487,19 @@ export async function previewRestore( const shadowPath = getShadowRepoPath(projectRoot); try { - const snapshotFiles = await git.listFilesAtCommit(shadowPath, hash); + const found = await resolveManifestEntry(projectRoot, hash); + if (!found) { + return { files: [], missing: [], conflicts: [] }; + } + + const snapshotFiles = await git.listFilesAtCommit(shadowPath, found.hash); const files: string[] = []; const missing: string[] = []; const conflicts: string[] = []; for (const file of snapshotFiles) { - if (isShadowInternalFile(file)) { + if (isShadowInternalFile(file) || isGlobalFile(file)) { continue; } @@ -242,8 +509,8 @@ export async function previewRestore( if (!fileExists(projectPath)) { missing.push(file); } else { - const projectContent = await fs.readFile(projectPath, "utf-8"); - const snapshotContent = await git.getFileAtCommit(shadowPath, hash, file); + const projectContent = await readText(projectPath); + const snapshotContent = await git.getFileAtCommit(shadowPath, found.hash, file); if (projectContent !== snapshotContent) { conflicts.push(file); } @@ -251,29 +518,78 @@ export async function previewRestore( } return { files, missing, conflicts }; - } catch (error) { + } catch { return { files: [], missing: [], conflicts: [] }; } } -export async function getRestoreTarget( +/** + * Resolve a hash in the manifest, falling back to the shadow git log when the + * manifest is missing or incomplete. + */ +async function resolveManifestEntry( projectRoot: string, hashOrShort: string -): Promise { +): Promise<{ hash: string } | null> { const entries = await manifest.readManifest(projectRoot); + const inManifest = findEntry(entries, hashOrShort); + if (inManifest) { + return { hash: inManifest }; + } + + const rebuilt = await manifest.rebuildManifestFromGit(projectRoot); + const inGit = findEntry(rebuilt, hashOrShort); + if (inGit) { + return { hash: inGit }; + } - const exact = entries.find((e) => e.hash === hashOrShort || e.shortHash === hashOrShort); + return null; +} +function findEntry( + entries: Array<{ hash: string; shortHash: string }>, + needle: string +): string | null { + const exact = entries.find((e) => e.hash === needle || e.shortHash === needle); if (exact) { return exact.hash; } - - if (hashOrShort.length >= 7) { - const shortMatch = entries.find((e) => e.shortHash === hashOrShort); - if (shortMatch) { - return shortMatch.hash; + if (needle.length >= 7) { + const prefix = entries.find((e) => e.hash.startsWith(needle)); + if (prefix) { + return prefix.hash; } } - return null; -} \ No newline at end of file +} + +async function readText(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf-8"); + } catch { + return ""; + } +} + +function errorResult(mode: RestoreMode, message: string): RestoreResult { + return { + mode, + restored: [], + wouldRestore: [], + overwritten: [], + created: [], + skipped: [], + missing: [], + conflicts: [], + redacted: [], + error: message, + }; +} + +export async function getRestoreTarget( + projectRoot: string, + hashOrShort: string +): Promise { + const result = await resolveManifestEntry(projectRoot, hashOrShort); + return result ? result.hash : null; +} diff --git a/src/engine/safety.ts b/src/engine/safety.ts new file mode 100644 index 0000000..35417df --- /dev/null +++ b/src/engine/safety.ts @@ -0,0 +1,155 @@ +import type { DetectionLevel, SnapshotReason, SnapshotResult } from "../shared/types.js"; +import { createRedactor } from "./redactor.js"; +import { createTracker } from "./tracker.js"; +import { DEFAULT_CONFIG, normalizeFailureMode, type SnapshotFailureMode } from "./config.js"; + +export type { SnapshotFailureMode }; + +/** + * Detection levels that strict mode is permitted to block. + * + * Blocking is deliberately restricted to operations Git That Shit has + * *explicitly classified* as destructive. Risky package-manager operations and + * ordinary config writes are never blocked, and a harmless command is never + * blocked merely because the snapshot engine is unhealthy. + */ +export const STRICT_BLOCKABLE_LEVELS: readonly DetectionLevel[] = ["destructive"]; + +export function isBlockableLevel(level: DetectionLevel): boolean { + return STRICT_BLOCKABLE_LEVELS.includes(level); +} + +/** + * Decide whether a classified operation should be blocked because its required + * protective snapshot could not be created. + */ +export function shouldBlockOperation(level: DetectionLevel, mode: SnapshotFailureMode): boolean { + return normalizeFailureMode(mode) === "strict" && isBlockableLevel(level); +} + +/** + * A module-level redactor built from the *default* secret keys. Used to + * sanitize diagnostic strings (git errors, command previews, manual snapshot + * messages) that may incidentally contain a credential. Configuration-supplied + * keys are applied separately wherever a project tracker is available. + */ +const defaultRedactor = createRedactor(createTracker(DEFAULT_CONFIG)); + +/** + * Best-effort removal of secret values from a free-form diagnostic string. + * Fail-open by construction: redaction is regex-based and may miss a value, but + * it never throws and never returns the input unexamined. + */ +export function sanitizeText(text: string, secrets: string[] = []): string { + if (!text) { + return ""; + } + if (secrets.length === 0) { + return defaultRedactor.redactText(text).content; + } + const tracker = createTracker({ + ...DEFAULT_CONFIG, + secrets: { + ...DEFAULT_CONFIG.secrets, + redactKeys: [...DEFAULT_CONFIG.secrets.redactKeys, ...secrets.filter(Boolean)], + }, + }); + return createRedactor(tracker).redactText(text).content; +} + +/** Human-readable, credential-free explanation of a snapshot failure. */ +export function describeSnapshotFailure(result: Pick): string { + const suffix = result.error ? `: ${sanitizeText(result.error)}` : ""; + switch (result.reason) { + case "locked": + return `another Git That Shit operation holds the project lock${suffix}`; + case "low-disk": + return `insufficient disk space${suffix}`; + case "git-missing": + return `git is not available${suffix}`; + case "no-changes": + return `there were no changes to snapshot (the existing snapshot is the recovery point)`; + case "error": + default: + return `snapshot creation failed${suffix}`; + } +} + +/** A clear, actionable warning emitted when a protective snapshot failed. */ +export function formatSnapshotFailureWarning( + result: Pick, + mode: SnapshotFailureMode +): string { + const detail = describeSnapshotFailure(result); + const prefix = + normalizeFailureMode(mode) === "strict" + ? "[git-that-shit] STRICT: protective snapshot could not be created" + : "[git-that-shit] WARNING: protective snapshot could not be created"; + return `${prefix} — ${detail}.`; +} + +/** The message shown to a user (or model) when strict mode blocks an operation. */ +export function formatBlockMessage(failureReason: SnapshotReason, detail?: string): string { + const why = describeSnapshotFailure({ reason: failureReason, error: detail }); + return ( + `Blocked by Git That Shit strict mode: the protective snapshot required before this ` + + `destructive operation could not be created (${why}). Re-run once the snapshot ` + + `problem is resolved, or set snapshot.failureMode to "standard" to continue without ` + + `a recovery point.` + ); +} + +/** + * Session-scoped registry correlating a tool-call id with a block decision. + * + * `permission.evaluate` is the OpenCode V2 pre-execution gate; it exposes the + * originating tool call via `evaluation.source.id`. When `execute.before` + * cannot create a required protective snapshot in strict mode it records the + * tool-call id here, and the permission hook denies exactly that call. Entries + * are removed when the tool finishes (or on cleanup) and capped in size so a + * long session cannot grow unbounded. + */ +export class PendingDenials { + private readonly entries = new Map(); + private readonly maxEntries: number; + + constructor(maxEntries = 256) { + this.maxEntries = maxEntries; + } + + add(callId: string, message: string): void { + if (!callId) { + return; + } + if (this.entries.size >= this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest !== undefined) { + this.entries.delete(oldest); + } + } + this.entries.set(callId, message); + } + + /** Non-destructive lookup used by the permission hook (may be evaluated repeatedly). */ + get(callId: string): string | undefined { + return callId ? this.entries.get(callId) : undefined; + } + + has(callId: string): boolean { + return Boolean(callId) && this.entries.has(callId); + } + + forget(callId: string): void { + if (callId) { + this.entries.delete(callId); + } + } + + get size(): number { + return this.entries.size; + } + + clear(): void { + this.entries.clear(); + } +} diff --git a/src/engine/snapshot.ts b/src/engine/snapshot.ts index e5b3529..885047e 100644 --- a/src/engine/snapshot.ts +++ b/src/engine/snapshot.ts @@ -1,30 +1,88 @@ import * as path from "path"; import * as fs from "fs/promises"; -import { createTracker, findFiles } from "./tracker.js"; +import { createTracker, findFiles, Tracker } from "./tracker.js"; import { createRedactor, redactFileContent } from "./redactor.js"; import * as git from "./git.js"; import * as manifest from "./manifest.js"; import * as lock from "./lock.js"; -import { checkDiskSpace, checkFileSize, ensureDiskSpace } from "./disk.js"; +import { LockError } from "../shared/errors.js"; +import { checkDiskSpace, checkFileSize } from "./disk.js"; import { loadConfig, DEFAULT_CONFIG } from "./config.js"; +import { sanitizeText, formatSnapshotFailureWarning } from "./safety.js"; import { getShadowRepoPath, + getLegacyShadowRepoPath, + getProjectStateDir, getProjectConfigPaths, - getGlobalConfigPath, + getGlobalConfigPaths, ensureDir, copyFile, fileExists, } from "../shared/paths.js"; +import { + ensurePrivateDir, + hardenDir, + hardenFile, + hardenProjectState, +} from "../shared/permissions.js"; import type { SnapshotInput, SnapshotResult, + SnapshotReason, SnapshotType, } from "../shared/types.js"; import type { ManifestEntry } from "../shared/types.js"; +/** + * Move a shadow repository created by an older build (at + * `.git-that-shit/snapshots`) to the canonical location + * (`.git-that-shit/git-that-shit/snapshots`) so historical snapshots are never + * orphaned. + */ +export async function migrateLegacyShadowRepo(projectRoot: string): Promise { + const canonical = getShadowRepoPath(projectRoot); + const legacy = getLegacyShadowRepoPath(projectRoot); + + if (fileExists(path.join(canonical, ".git"))) { + return; + } + if (!fileExists(path.join(legacy, ".git"))) { + return; + } + + ensureDir(path.dirname(canonical)); + try { + await fs.rename(legacy, canonical); + } catch { + // If the rename fails (e.g. different devices) fall back to a copy. + try { + await fs.cp(legacy, canonical, { recursive: true }); + } catch { + return; + } + } + + // Migrate a legacy config if the canonical one is missing. + const legacyConfig = path.join(projectRoot, ".git-that-shit", "config.json"); + const canonicalConfig = path.join(getProjectStateDir(projectRoot), "config.json"); + if (fileExists(legacyConfig) && !fileExists(canonicalConfig)) { + try { + await fs.copyFile(legacyConfig, canonicalConfig); + } catch { + // Best effort. + } + } +} + export async function initShadowRepo(projectRoot: string): Promise { + await migrateLegacyShadowRepo(projectRoot); + const shadowPath = getShadowRepoPath(projectRoot); - ensureDir(shadowPath); + // Private storage: the shadow repository and its parent state directories + // are created (and tightened) to owner-only access before any snapshot + // content is written into them. + ensurePrivateDir(getProjectStateDir(projectRoot)); + ensurePrivateDir(shadowPath); const hasOwnGit = git.hasOwnGitDir(shadowPath); @@ -36,175 +94,260 @@ export async function initShadowRepo(projectRoot: string): Promise { await git.writeGitignore(shadowPath, ".gts.lock\n.git-that-shit/manifest.jsonl\n"); } - ensureDir(path.join(shadowPath, ".git-that-shit")); + ensurePrivateDir(path.join(shadowPath, ".git-that-shit")); + + // Tighten every storage directory we own. Only the `.git-that-shit` tree is + // touched; user project files and unrelated directories are never chmod'ed. + hardenProjectState(projectRoot); +} + +function failure(reason: SnapshotReason, input: SnapshotInput, error?: string): SnapshotResult { + return { + committed: false, + reason, + verified: false, + failureCode: reason, + filesCopied: [], + filesSkipped: [], + filesRedacted: [], + timestamp: new Date().toISOString(), + error: error ? sanitizeText(error) : undefined, + }; } +/** + * Create a snapshot. The entire operation — repository initialisation, file + * collection, secret filtering, copying, staging, committing and manifest + * updates — runs under a single exclusive project lock so concurrent sessions + * can never corrupt the shadow repository. + */ export async function snapshot(input: SnapshotInput): Promise { - const { projectRoot, reason, type, trigger, operation, sessionId } = input; + const { projectRoot, reason } = input; try { - await lock.withLock(projectRoot, "snapshot", reason, async () => { - await ensureDiskSpace(projectRoot); - }); + return await lock.withLock(projectRoot, "snapshot", reason, () => doSnapshot(input)); } catch (error) { + if (error instanceof LockError) { + return failure("locked", input, error.message); + } + return failure("error", input, (error as Error).message); + } +} + +async function doSnapshot(input: SnapshotInput): Promise { + const { projectRoot, reason, type, trigger, operation, sessionId } = input; + + const config = await loadConfig(projectRoot).catch(() => DEFAULT_CONFIG); + const tracker = createTracker(config); + const redactor = createRedactor(tracker); + const shadowPath = getShadowRepoPath(projectRoot); + const gitTimeout = config.snapshot.gitTimeoutMs; + + // Command previews and manual snapshot messages can legitimately contain a + // credential (for example `token=…` on a shell command). Sanitize before the + // value can reach the commit message, the manifest, the result or a log. + const secretKeys = config.secrets.redactKeys; + const safeReason = sanitizeText(reason, secretKeys); + const safeOperation = operation + ? { + tool: operation.tool, + preview: operation.preview ? sanitizeText(operation.preview, secretKeys) : undefined, + } + : undefined; + + await initShadowRepo(projectRoot); + + const diskCheck = await checkDiskSpace(shadowPath, config.snapshot.minDiskSpaceMb); + if (!diskCheck.sufficient) { return { - committed: false, - reason: "locked", - filesCopied: [], - filesSkipped: [], - filesRedacted: [], - timestamp: new Date().toISOString(), - error: (error as Error).message, + ...failure("low-disk", input, diskCheck.message), }; } - try { - const config = await loadConfig(projectRoot).catch(() => DEFAULT_CONFIG); - const tracker = createTracker(config); - const redactor = createRedactor(tracker); - const shadowPath = getShadowRepoPath(projectRoot); - - await initShadowRepo(projectRoot); - - const diskCheck = await checkDiskSpace(shadowPath, config.snapshot.minDiskSpaceMb); - if (!diskCheck.sufficient) { - return { - committed: false, - reason: "low-disk", - filesCopied: [], - filesSkipped: [], - filesRedacted: [], - timestamp: new Date().toISOString(), - error: diskCheck.message, - }; - } + const filesToTrack = collectFilesToTrack(projectRoot, tracker, config); - const filesToTrack = collectFilesToTrack(projectRoot, tracker, config); + const filesCopied: string[] = []; + const filesSkipped: Array<{ path: string; reason: string }> = []; + const filesRedacted: string[] = []; - const filesCopied: string[] = []; - const filesSkipped: Array<{ path: string; reason: string }> = []; - const filesRedacted: string[] = []; + for (const relPath of filesToTrack) { + const srcPath = path.join(projectRoot, relPath); + const destPath = path.join(shadowPath, relPath); - for (const relPath of filesToTrack) { - const srcPath = path.join(projectRoot, relPath); - const destPath = path.join(shadowPath, relPath); + if (!isWithin(projectRoot, srcPath) || !isWithin(shadowPath, destPath)) { + filesSkipped.push({ path: relPath, reason: "unsafe path" }); + continue; + } - if (!fileExists(srcPath)) { - filesSkipped.push({ path: relPath, reason: "file not found" }); - continue; - } + if (!fileExists(srcPath)) { + await removeShadowCopy(destPath); + filesSkipped.push({ path: relPath, reason: "file not found" }); + continue; + } - const sizeCheck = checkFileSize(srcPath, config.snapshot.maxFileSizeMb); - if (!sizeCheck.acceptable) { - filesSkipped.push({ path: relPath, reason: `exceeds max size (${sizeCheck.sizeMb}MB)` }); - continue; - } + const sizeCheck = checkFileSize(srcPath, config.snapshot.maxFileSizeMb); + if (!sizeCheck.acceptable) { + await removeShadowCopy(destPath); + filesSkipped.push({ path: relPath, reason: `exceeds max size (${sizeCheck.sizeMb}MB)` }); + continue; + } - if (tracker.isHardExcluded(relPath)) { - filesSkipped.push({ path: relPath, reason: "hard excluded" }); - continue; - } + const { content, wasRedacted, safe } = await redactFileContent(srcPath, tracker); + + if (!safe) { + // Fail closed: we could not prove the content is free of secrets. + await removeShadowCopy(destPath); + filesSkipped.push({ path: relPath, reason: "unredactable content (failed closed)" }); + continue; + } - if (tracker.isSensitive(relPath) && !config.tracking.allowSensitiveFiles) { - filesSkipped.push({ path: relPath, reason: "sensitive file excluded" }); + if (wasRedacted) { + filesRedacted.push(relPath); + ensurePrivateDir(path.dirname(destPath)); + await fs.writeFile(destPath, content, "utf-8"); + } else { + if (!copyFile(srcPath, destPath)) { + filesSkipped.push({ path: relPath, reason: "copy failed" }); continue; } + } - const { content, wasRedacted, keysRedacted } = await redactFileContent(srcPath, tracker); - - if (wasRedacted) { - filesRedacted.push(relPath); - await fs.writeFile(destPath, content, "utf-8"); - } else { - copyFile(srcPath, destPath); - } + // Snapshot copies are private storage. The enclosing shadow directory is + // already 0700; tighten each stored file to 0600 as defence in depth. + hardenFile(destPath); + filesCopied.push(relPath); + } - filesCopied.push(relPath); + // Global OpenCode configuration is always redacted before it is stored. + for (const globalConfigPath of getGlobalConfigPaths()) { + if (!fileExists(globalConfigPath)) { + continue; } - const globalConfigPath = getGlobalConfigPath(); - if (fileExists(globalConfigPath)) { - const globalDest = path.join(shadowPath, "global-opencode", "config.json"); - const sizeCheck = checkFileSize(globalConfigPath, config.snapshot.maxFileSizeMb); - if (sizeCheck.acceptable) { - copyFile(globalConfigPath, globalDest); - filesCopied.push("global-opencode/config.json"); - } - } + const relDest = `global-opencode/${path.basename(globalConfigPath)}`; + const destPath = path.join(shadowPath, relDest); - if (filesCopied.length === 0) { - return { - committed: false, - reason: "no-changes", - filesCopied: [], - filesSkipped, - filesRedacted: [], - timestamp: new Date().toISOString(), - }; + const sizeCheck = checkFileSize(globalConfigPath, config.snapshot.maxFileSizeMb); + if (!sizeCheck.acceptable) { + await removeShadowCopy(destPath); + filesSkipped.push({ path: relDest, reason: `exceeds max size (${sizeCheck.sizeMb}MB)` }); + continue; } - await git.addAll(shadowPath); - - const hasChanges = await git.hasStagedChanges(shadowPath); - if (!hasChanges) { - return { - committed: false, - reason: "no-changes", - filesCopied, - filesSkipped, - filesRedacted, - timestamp: new Date().toISOString(), - }; + const { content, wasRedacted, safe } = await redactFileContent(globalConfigPath, tracker); + + if (!safe) { + await removeShadowCopy(destPath); + filesSkipped.push({ path: relDest, reason: "global config unredactable (failed closed)" }); + continue; } - const commitMessage = formatCommitMessage(type, reason, trigger, operation, sessionId); - const hash = await git.commit(shadowPath, commitMessage); - const shortHash = await git.getShortHash(shadowPath, hash); + ensurePrivateDir(path.dirname(destPath)); + await fs.writeFile(destPath, content, "utf-8"); + hardenFile(destPath); + if (wasRedacted) { + filesRedacted.push(relDest); + } + filesCopied.push(relDest); + } - const manifestEntry: ManifestEntry = { - hash, - shortHash, - timestamp: new Date().toISOString(), - type, - trigger, - reason, - operation, - sessionId, - filesCopied, + if (filesCopied.length === 0) { + return { + committed: false, + reason: "no-changes", + verified: false, + failureCode: "no-changes", + filesCopied: [], filesSkipped, - filesRedacted, + filesRedacted: [], + timestamp: new Date().toISOString(), }; + } - await manifest.appendManifestEntry(projectRoot, manifestEntry); + await git.addAll(shadowPath, gitTimeout); + const hasChanges = await git.hasStagedChanges(shadowPath, gitTimeout); + if (!hasChanges) { return { - committed: true, - reason: "committed", - hash, - shortHash, + committed: false, + reason: "no-changes", + verified: false, + failureCode: "no-changes", filesCopied, filesSkipped, filesRedacted, timestamp: new Date().toISOString(), }; - } catch (error) { + } + + const commitMessage = formatCommitMessage(type, safeReason, trigger, safeOperation, sessionId); + const hash = await git.commit(shadowPath, commitMessage, gitTimeout); + + // Never claim a snapshot exists unless its creation is verified: confirm the + // hash resolves to a real commit object in the shadow repository. + const verified = await git.commitExists(shadowPath, hash, gitTimeout); + if (!verified || !hash) { return { committed: false, reason: "error", - filesCopied: [], - filesSkipped: [], - filesRedacted: [], + verified: false, + failureCode: "error", + filesCopied, + filesSkipped, + filesRedacted, timestamp: new Date().toISOString(), - error: (error as Error).message, + error: sanitizeText("commit could not be verified in the shadow repository", secretKeys), + warnings: [formatSnapshotFailureWarning({ reason: "error" }, config.snapshot.failureMode)], }; - } finally { - await lock.releaseLock(projectRoot); + } + + const shortHash = await git.getShortHash(shadowPath, hash); + + const manifestEntry: ManifestEntry = { + hash, + shortHash, + timestamp: new Date().toISOString(), + type, + trigger, + reason: safeReason, + operation: safeOperation, + sessionId, + filesCopied, + filesSkipped, + filesRedacted, + }; + + await manifest.appendManifestEntry(projectRoot, manifestEntry); + + return { + committed: true, + reason: "committed", + verified: true, + hash, + shortHash, + filesCopied, + filesSkipped, + filesRedacted, + timestamp: new Date().toISOString(), + }; +} + +async function removeShadowCopy(destPath: string): Promise { + try { + await fs.unlink(destPath); + } catch { + // Not present; nothing to do. } } +function isWithin(root: string, candidate: string): boolean { + const rel = path.relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + function collectFilesToTrack( projectRoot: string, - tracker: ReturnType, + tracker: Tracker, config: typeof DEFAULT_CONFIG ): string[] { const files: string[] = []; @@ -259,13 +402,14 @@ function formatCommitMessage( const typePrefix = `${type}: `; let message = typePrefix + reason.slice(0, 80 - typePrefix.length); - if (operation?.preview) { - message += `\n\nTriggered by: ${trigger}`; - message += `\nOperation type: ${type.split("-")[0]}`; - message += `\nTimestamp: ${new Date().toISOString()}`; - if (sessionId) { - message += `\nSession: ${sessionId}`; - } + message += `\n\nTriggered by: ${trigger}`; + message += `\nSnapshot type: ${type}`; + message += `\nTimestamp: ${new Date().toISOString()}`; + if (operation?.tool) { + message += `\nTool: ${operation.tool}`; + } + if (sessionId) { + message += `\nSession: ${sessionId}`; } return message; @@ -301,4 +445,4 @@ export async function getSnapshotContent( ): Promise { const shadowPath = getShadowRepoPath(projectRoot); return git.getFileAtCommit(shadowPath, hash, filePath); -} \ No newline at end of file +} diff --git a/src/engine/tracker.ts b/src/engine/tracker.ts index c78cdc8..33dc86e 100644 --- a/src/engine/tracker.ts +++ b/src/engine/tracker.ts @@ -15,15 +15,22 @@ export class Tracker { private includePatterns: RegExp[]; private excludePatterns: RegExp[]; private secretPatterns: RegExp[]; + private secretKeys: string[]; constructor(config: GitThatShitConfig) { this.config = config; this.hardExcludes = getHardExcludes().map((p) => this.globToRegex(p)); this.includePatterns = config.tracking.include.map((p) => this.globToRegex(p)); this.excludePatterns = config.tracking.exclude.map((p) => this.globToRegex(p)); + this.secretKeys = config.secrets.redactKeys; this.secretPatterns = config.secrets.redactKeys.map((k) => new RegExp(`(^|[^a-zA-Z])${k}([^a-zA-Z]|$)`, "i")); } + /** The configured secret key names, for text-based redaction. */ + getSecretKeys(): string[] { + return this.secretKeys; + } + private globToRegex(glob: string): RegExp { let pattern = glob .replace(/[.+^${}()|[\]\\]/g, "\\$&") @@ -82,10 +89,28 @@ export class Tracker { return false; } + /** + * A relative path is safe only when it stays inside the project root: it must + * be relative and must not contain a `..` segment. This blocks path traversal + * attempts such as `../../../etc/passwd.json`. + */ + isSafeRelativePath(filePath: string): boolean { + if (!filePath || path.isAbsolute(filePath)) { + return false; + } + const normalized = filePath.replace(/\\/g, "/"); + const segments = normalized.split("/"); + return !segments.includes(".."); + } + shouldTrack(filePath: string): { track: boolean; reason?: string } { const normalizedPath = filePath.replace(/\\/g, "/"); const filename = path.basename(normalizedPath); + if (!this.isSafeRelativePath(filePath)) { + return { track: false, reason: "unsafe path (traversal or absolute)" }; + } + if (this.isHardExcluded(normalizedPath)) { return { track: false, reason: "hard excluded" }; } diff --git a/src/plugin/index.ts b/src/plugin/index.ts index d7a6de7..79ffcfd 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -1,198 +1,541 @@ -import type { Plugin, ToolContext } from "@opencode-ai/plugin"; -import { tool } from "@opencode-ai/plugin"; -import { resolveProjectRoot, getShadowRepoPath } from "../shared/paths.js"; -import * as snapshot from "../engine/snapshot.js"; +import { Plugin } from "@opencode/plugin"; +import { resolveProjectRoot, getShadowRepoPath, fileExists } from "../shared/paths.js"; +import { getPackageVersion } from "../shared/version.js"; +import * as snapshotEngine from "../engine/snapshot.js"; import * as manifest from "../engine/manifest.js"; -import * as restore from "../engine/restore.js"; -import * as doctor from "../engine/doctor.js"; -import * as config from "../engine/config.js"; +import * as restoreEngine from "../engine/restore.js"; +import * as doctorEngine from "../engine/doctor.js"; +import * as configEngine from "../engine/config.js"; import * as detector from "../engine/detector.js"; +import * as pruneEngine from "../engine/prune.js"; +import { + PendingDenials, + formatBlockMessage, + formatSnapshotFailureWarning, + sanitizeText, + shouldBlockOperation, +} from "../engine/safety.js"; +import { createTracker } from "../engine/tracker.js"; +import type { DetectionLevel, GitThatShitConfig, SnapshotResult, SnapshotType } from "../shared/types.js"; -export const GitThatShitPlugin: Plugin = async ({ project, directory, client }) => { - const projectRoot = directory || resolveProjectRoot(); +/** Stable plugin id used by OpenCode for storage, status and diagnostics. */ +export const PLUGIN_ID = "gitdamnit.git-that-shit"; - const getConfig = async () => { - try { - return await config.loadConfig(projectRoot); - } catch { - return config.DEFAULT_CONFIG; +const EDIT_TOOLS = new Set([ + "write", + "edit", + "str_replace_editor", + "str_replace", + "apply_patch", + "patch", + "multiedit", +]); + +const SHELL_TOOLS = new Set(["bash", "shell", "cmd", "powershell"]); + +const JSON_SCHEMA = { + type: "object" as const, + additionalProperties: false, +}; + +/** Extract a shell command string from a tool input of unknown shape. */ +function extractCommandArgs(input: unknown): string[] { + if (input == null) { + return []; + } + if (typeof input === "string") { + return [input]; + } + if (typeof input === "object") { + const record = input as Record; + const command = record.command ?? record.cmd ?? record.script; + if (typeof command === "string") { + return [command]; + } + if (Array.isArray(command)) { + return command.map((part) => String(part)); } - }; + } + return []; +} - const createSnapshot = async (type: string, reason: string, trigger: string) => { - try { - const result = await snapshot.snapshot({ - projectRoot, - reason, - type: type as any, - trigger, - }); +export const GitThatShitPlugin = Plugin.define({ + id: PLUGIN_ID, + async setup(ctx) { + const projectRoot = ctx.location?.directory || resolveProjectRoot(); + const controller = new AbortController(); + const denials = new PendingDenials(); - if (result.committed && client?.app?.log) { - await client.app.log({ - body: { - service: "git-that-shit", - level: "info", - message: `Snapshot created: ${result.shortHash}`, - extra: { type, reason, filesCopied: result.filesCopied.length }, - }, + let lastSessionId: string | undefined; + let editTimer: ReturnType | undefined; + let permissionHookAvailable = false; + + console.log(`[git-that-shit] loaded v${getPackageVersion()} for ${projectRoot}`); + + const getConfig = async (): Promise => { + try { + return await configEngine.loadConfig(projectRoot); + } catch { + return configEngine.DEFAULT_CONFIG; + } + }; + + const createSnapshot = async ( + type: SnapshotType, + reason: string, + trigger: string, + sessionId?: string, + operation?: { tool?: string; preview?: string } + ): Promise => { + try { + const result = await snapshotEngine.snapshot({ + projectRoot, + reason, + type, + trigger, + operation, + sessionId: sessionId ?? lastSessionId, }); + + if (result.committed) { + console.log(`[git-that-shit] snapshot ${result.shortHash} (${type})`); + } + return result; + } catch (error) { + // Never surface raw content in logs. + console.error( + `[git-that-shit] snapshot failed (${type}): ${sanitizeText((error as Error).message)}` + ); + return undefined; } + }; - return result; - } catch (error) { - if (client?.app?.log) { - await client.app.log({ - body: { - service: "git-that-shit", - level: "error", - message: `Snapshot failed: ${(error as Error).message}`, - extra: { type, reason }, - }, + // --- Optional pre-execution blocking (strict mode) -------------------- + // The OpenCode V2 SDK's `execute.before` tool hook returns void and cannot + // veto an operation. The only pre-execution gate the SDK exposes is + // `permission.evaluate`, whose evaluation carries a mutable `effect` + // ("allow" | "deny" | "ask") and the originating tool call in + // `evaluation.source.id`. Strict mode uses that gate and denies exactly the + // tool call whose protective snapshot failed. + // + // Ordering between `permission.evaluate` and `execute.before` is not + // specified by the SDK, so blocking is best-effort. When the hook is + // unavailable strict mode fails open and says so explicitly rather than + // claiming a guarantee it cannot provide. See docs/INTEROP.md. + try { + const permissionDomain = (ctx as { permission?: { hook?: (name: string, cb: (evaluation: any) => void) => Promise } }).permission; + if (permissionDomain && typeof permissionDomain.hook === "function") { + await permissionDomain.hook("evaluate", (evaluation: any) => { + try { + const source = evaluation?.source; + const callId = + source && source.type === "tool" && source.id != null ? String(source.id) : undefined; + if (!callId) { + return; + } + const message = denials.get(callId); + if (message) { + evaluation.effect = "deny"; + evaluation.message = message; + } + } catch (error) { + console.error( + `[git-that-shit] permission.evaluate failed: ${sanitizeText((error as Error).message)}` + ); + } }); + permissionHookAvailable = true; + } + } catch (error) { + console.error( + `[git-that-shit] could not register permission.evaluate: ${sanitizeText((error as Error).message)}` + ); + } + + // Make the strict-mode contract explicit at startup rather than implying a + // guarantee the SDK does not provide. + if ((await getConfig()).snapshot.failureMode === "strict") { + if (permissionHookAvailable) { + console.warn( + "[git-that-shit] strict mode: destructive operations will be denied when a required " + + "protective snapshot fails. Blocking is best-effort (the SDK does not specify hook order)." + ); + } else { + console.warn( + "[git-that-shit] strict mode requested, but this SDK build exposes no pre-execution " + + "permission gate; destructive operations will NOT be blocked. See docs/INTEROP.md." + ); } } - }; - return { - "tool.execute.before": async (input: any, output: any) => { + /** + * Take a protective snapshot before a classified operation and act on the + * result according to the configured failure mode: + * - standard: continue, warn clearly, never claim a snapshot exists; + * - strict: request a deny for this exact tool call; if the SDK cannot + * block, warn and continue (fail open) instead of pretending. + */ + const createProtectiveSnapshot = async ( + type: SnapshotType, + reason: string, + trigger: string, + level: DetectionLevel, + callId: string | undefined, + operation?: { tool?: string; preview?: string } + ): Promise<{ verified: boolean; blocked: boolean }> => { const cfg = await getConfig(); + const result = await createSnapshot(type, reason, trigger, undefined, operation); + const verified = Boolean(result?.committed && result?.verified); - if (input.tool === "bash" || input.tool === "shell") { - const cmdArgs = output?.args?.command; - const args = Array.isArray(cmdArgs) ? cmdArgs : [cmdArgs || ""].filter(Boolean); + // A "no-changes" result means the working tree already matches the latest + // snapshot, so a recovery point exists even though no new commit was made. + // Treat it exactly like a verified snapshot, matching the restore path. + if (verified || result?.reason === "no-changes") { + return { verified, blocked: false }; + } - const detection = detector.detectDestructiveCommand(input.tool, args, cfg); + const failure = result ?? { reason: "error" as const, error: "snapshot engine threw" }; + const warning = formatSnapshotFailureWarning(failure, cfg.snapshot.failureMode); - if (detection.shouldSnapshot) { - const snapshotType = detection.level === "risky" ? "pre-risky-op" : "pre-op"; - await createSnapshot(snapshotType, detection.reason, "tool.execute.before"); + if (shouldBlockOperation(level, cfg.snapshot.failureMode)) { + if (callId && permissionHookAvailable) { + denials.add(callId, formatBlockMessage(failure.reason, failure.error)); + console.error( + `${warning} Blocking this ${level} operation (strict mode): tool call ${callId}.` + ); + return { verified: false, blocked: true }; } + console.error( + `${warning} Strict mode requested blocking, but this SDK build exposes no pre-execution ` + + `gate; the operation was NOT blocked (documented limitation).` + ); + return { verified: false, blocked: false }; + } - if (cfg.destructiveOps.configWrites && detection.level === "config-write") { - await createSnapshot("pre-op", detection.reason, "tool.execute.before"); - } + console.warn(`${warning} Continuing (standard mode).`); + return { verified: false, blocked: false }; + }; + + const schedulePostEdit = async (file: string) => { + const cfg = await getConfig(); + if (cfg.fileEdits.mode === "disabled") { + return; } - if (input.tool === "write" || input.tool === "edit" || input.tool === "str_replace_editor") { - const cfg2 = await getConfig(); - if (cfg2.fileEdits.mode !== "disabled") { - await createSnapshot( - cfg2.fileEdits.mode === "pre-if-available" ? "pre-edit" : "post-edit", - "config file modification", - "tool.execute.before" - ); - } + const tracker = createTracker(cfg); + if (!tracker.shouldTrack(file).track) { + return; } - }, - "file.edited": async (input: any) => { - const cfg = await getConfig(); - if (cfg.fileEdits.mode !== "disabled") { - await createSnapshot( - cfg.fileEdits.mode === "pre-if-available" ? "pre-edit" : "post-edit", - `file edited: ${input.filePath || "unknown"}`, - "file.edited" - ); + const debounce = Math.max(0, cfg.fileEdits.debounceMs || 0); + if (editTimer) { + clearTimeout(editTimer); + } + editTimer = setTimeout(() => { + void createSnapshot("post-edit", `file edited: ${file}`, "filesystem.changed", undefined, { + tool: "filesystem", + preview: file, + }); + }, debounce); + }; + + // Failure isolation: a snapshot failure, a malformed project file, or a + // missing companion plugin must never surface as a tool failure or break + // another plugin. Hooks are strictly additive and read-only with respect to + // the event: they never mutate `event` or `event.input`. + const safe = async (label: string, fn: () => Promise): Promise => { + try { + await fn(); + } catch (error) { + console.error(`[git-that-shit] ${label} failed: ${(error as Error).message}`); } - }, + }; + + // --- Tool hooks ------------------------------------------------------- + // `execute.before` runs before a tool executes: this is the only correct + // place for a *pre*-edit / pre-operation snapshot. + await ctx.tool.hook("execute.before", async (event: any) => { + await safe("execute.before", async () => { + if (event.sessionID) { + lastSessionId = String(event.sessionID); + } + + const tool = String(event.tool); + const callId = event.id != null ? String(event.id) : undefined; + const cfg = await getConfig(); + + if (SHELL_TOOLS.has(tool)) { + const args = extractCommandArgs(event.input); + const detection = detector.detectDestructiveCommand(tool, args, cfg); + + if (detection.shouldSnapshot) { + const snapshotType: SnapshotType = + detection.level === "risky" ? "pre-risky-op" : "pre-op"; + await createProtectiveSnapshot( + snapshotType, + detection.reason, + "tool.execute.before", + detection.level, + callId, + { tool, preview: detection.commandPreview } + ); + } + } + + if (EDIT_TOOLS.has(tool) && cfg.fileEdits.mode === "pre-if-available") { + await createSnapshot("pre-edit", `pre-edit for ${tool}`, "tool.execute.before"); + } + }); + }); + + // `execute.after` runs after a tool completes. This is the reliable V2 + // signal for a completed edit: the public `filesystem.changed` event is + // only emitted for out-of-band filesystem changes, not for agent tool + // writes, so relying on it alone would miss post-edit snapshots. + await ctx.tool.hook("execute.after", async (event: any) => { + await safe("execute.after", async () => { + // A pending strict-mode denial is scoped to a single tool call. + if (event.id != null) { + denials.forget(String(event.id)); + } + if (event.status !== "completed") { + return; + } + const tool = String(event.tool); + if (!EDIT_TOOLS.has(tool)) { + return; + } + const cfg = await getConfig(); + if (cfg.fileEdits.mode === "disabled") { + return; + } + await createSnapshot("post-edit", `post-edit for ${tool}`, "tool.execute.after"); + }); + }); + + // --- Event subscriptions --------------------------------------------- + // V1 hooks -> V2 events: + // session.created -> session.created + // session.compacted -> session.compaction.ended + // file.edited -> filesystem.changed + void (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + const data = ((event as any).data ?? {}) as Record; - "session.created": async () => { - await createSnapshot("session-start", "session created", "session.created"); - }, + // Each event is handled in isolation so one malformed event cannot + // tear down the subscription. + await safe(`event ${event.type}`, async () => { + switch (event.type) { + case "session.created": + if (data.sessionID) lastSessionId = String(data.sessionID); + await createSnapshot("session-start", "session created", "session.created"); + break; - "session.compacted": async () => { - await createSnapshot("post-compact", "context window compacted", "session.compacted"); - }, + case "session.compaction.ended": + case "session.compacted" as never: + await createSnapshot( + "post-compact", + "context window compacted", + "session.compaction.ended" + ); + break; - tool: { - gts_status: tool({ - description: "Show Git That Shit status", - args: {}, - execute: async function(_args: any, _context: ToolContext) { + case "filesystem.changed": + case "file.edited" as never: { + const file = typeof data.file === "string" ? data.file : ""; + if (file) { + await schedulePostEdit(file); + } + break; + } + + default: + break; + } + }); + } + } catch (error) { + if (!controller.signal.aborted) { + console.error(`[git-that-shit] event stream ended: ${(error as Error).message}`); + } + } + })(); + + // --- Tools ------------------------------------------------------------ + await ctx.tool.transform((editor) => { + const addTool = ( + name: string, + description: string, + properties: Record, + required: string[], + handler: (input: any, toolCtx: any) => Promise + ) => { + editor.add({ + name, + description, + input: { ...JSON_SCHEMA, properties, ...(required.length ? { required } : {}) }, + execute: async (input: unknown, toolCtx: any) => { + try { + return { content: await handler(input, toolCtx) }; + } catch (error) { + return { + content: JSON.stringify({ error: (error as Error).message }, null, 2), + metadata: { error: true }, + }; + } + }, + }); + }; + + addTool( + "gts_status", + "Show Git That Shit status: shadow repo path, snapshot count and last snapshot.", + {}, + [], + async () => { const shadowPath = getShadowRepoPath(projectRoot); const snapshots = await manifest.readManifest(projectRoot); const last = snapshots.length > 0 ? snapshots[snapshots.length - 1] : null; - return JSON.stringify({ repoPath: shadowPath, snapshotCount: snapshots.length, lastSnapshot: last ? { hash: last.shortHash, type: last.type, reason: last.reason } : null }, null, 2); - }, - }), - - gts_list: tool({ - description: "List recent snapshots", - args: { count: tool.schema.number().optional() }, - execute: async function(args: any, _context: ToolContext) { - const snapshots = await manifest.readManifest(projectRoot, args.count || 20); - return JSON.stringify({ snapshots: snapshots.reverse() }, null, 2); - }, - }), + return JSON.stringify( + { + version: getPackageVersion(), + repoPath: shadowPath, + repoInitialized: fileExists(`${shadowPath}/.git`), + snapshotCount: snapshots.length, + lastSnapshot: last + ? { hash: last.shortHash, type: last.type, reason: last.reason } + : null, + }, + null, + 2 + ); + } + ); + + addTool( + "gts_list", + "List recent Git That Shit snapshots.", + { count: { type: "integer", minimum: 1, maximum: 1000 } }, + [], + async (input) => { + const count = Number(input?.count) || 20; + const snapshots = await manifest.readManifest(projectRoot, count); + return JSON.stringify({ snapshots: [...snapshots].reverse() }, null, 2); + } + ); - gts_diff: tool({ - description: "Show diff for a snapshot", - args: { hash: tool.schema.string() }, - execute: async function(args: any, _context: ToolContext) { - const diff = await snapshot.getSnapshotDiff(projectRoot, args.hash); + addTool( + "gts_diff", + "Show the diff introduced by a snapshot.", + { hash: { type: "string", minLength: 4 } }, + ["hash"], + async (input) => { + const diff = await snapshotEngine.getSnapshotDiff(projectRoot, String(input.hash)); return diff || "No changes"; + } + ); + + addTool( + "gts_restore", + "Restore files from a snapshot. Defaults to a dry run; pass dry_run:false and yes:true to restore. Conflicting files require force:true.", + { + hash: { type: "string", minLength: 4 }, + dry_run: { type: "boolean" }, + yes: { type: "boolean" }, + force: { type: "boolean" }, + to_temp: { type: "boolean" }, }, - }), - - gts_restore: tool({ - description: "Restore from a snapshot", - args: { - hash: tool.schema.string(), - dry_run: tool.schema.boolean().optional(), - yes: tool.schema.boolean().optional(), - }, - execute: async function(args: any, _context: ToolContext) { - const result = await restore.restoreSnapshot({ + ["hash"], + async (input) => { + const mode = input?.to_temp + ? "to-temp" + : input?.dry_run === false + ? "overwrite" + : "dry-run"; + + if (mode === "overwrite" && input?.yes !== true) { + return JSON.stringify( + { error: "Confirmation required. Pass yes:true to restore." }, + null, + 2 + ); + } + + const result = await restoreEngine.restoreSnapshot({ projectRoot, - hash: args.hash, - mode: args.yes ? "overwrite" : "dry-run", + hash: String(input.hash), + mode, + force: input?.force === true, }); return JSON.stringify(result, null, 2); - }, - }), + } + ); - gts_snapshot: tool({ - description: "Manually trigger a snapshot", - args: { message: tool.schema.string().optional() }, - execute: async function(args: any, _context: ToolContext) { - const result = await snapshot.snapshot({ + addTool( + "gts_snapshot", + "Manually create a Git That Shit snapshot.", + { message: { type: "string", maxLength: 200 } }, + [], + async (input) => { + const result = await snapshotEngine.snapshot({ projectRoot, - reason: args.message || "manual snapshot", + reason: typeof input?.message === "string" ? input.message : "manual snapshot", type: "manual", trigger: "gts_snapshot tool", + sessionId: lastSessionId, }); return JSON.stringify(result, null, 2); - }, - }), + } + ); - gts_prune: tool({ - description: "Preview or prune old snapshots", - args: { keep: tool.schema.number().optional() }, - execute: async function(args: any, _context: ToolContext) { - return JSON.stringify({ message: "Pruning not implemented in v0.1", keep: args.keep || 50 }, null, 2); + addTool( + "gts_prune", + "Preview or prune old snapshots. Preview by default; pass yes:true to prune, keeping the newest `keep` snapshots.", + { + keep: { type: "integer", minimum: 1 }, + yes: { type: "boolean" }, }, - }), + [], + async (input) => { + const keep = Number(input?.keep) || 50; + const result = await pruneEngine.pruneSnapshots(projectRoot, keep, input?.yes === true); + return JSON.stringify(result, null, 2); + } + ); - gts_config: tool({ - description: "Show current configuration", - args: {}, - execute: async function(_args: any, _context: ToolContext) { + addTool( + "gts_config", + "Show the effective Git That Shit configuration.", + {}, + [], + async () => { const cfg = await getConfig(); return JSON.stringify({ config: cfg }, null, 2); - }, - }), + } + ); - gts_doctor: tool({ - description: "Run health diagnostics", - args: {}, - execute: async function(_args: any, _context: ToolContext) { - const report = await doctor.runDoctor(projectRoot); + addTool( + "gts_doctor", + "Run Git That Shit health diagnostics.", + {}, + [], + async () => { + const report = await doctorEngine.runDoctor(projectRoot); return JSON.stringify(report, null, 2); - }, - }), - }, - }; -}; + } + ); + }); + + // Cleanup: abort the event subscription and cancel pending work. + return () => { + controller.abort(); + denials.clear(); + if (editTimer) { + clearTimeout(editTimer); + } + }; + }, +}); -export default GitThatShitPlugin; \ No newline at end of file +export default GitThatShitPlugin; diff --git a/src/shared/index.ts b/src/shared/index.ts index f55893a..cf6ad05 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1,3 +1,5 @@ export * from "./types.js"; export * from "./paths.js"; -export * from "./errors.js"; \ No newline at end of file +export * from "./errors.js"; +export * from "./version.js"; +export * from "./permissions.js"; \ No newline at end of file diff --git a/src/shared/paths.ts b/src/shared/paths.ts index 8fa1548..712dad6 100644 --- a/src/shared/paths.ts +++ b/src/shared/paths.ts @@ -7,8 +7,9 @@ export function resolveProjectRoot(cwd?: string): string { const markers = [ "opencode.json", + "opencode.jsonc", "package.json", -".git-that-shit", + ".git-that-shit", ".git", ]; @@ -30,12 +31,34 @@ export function resolveProjectRoot(cwd?: string): string { return searchDir; } +/** + * Canonical Git That Shit layout (v0.3.0+): + * + * .git-that-shit/git-that-shit/snapshots/ shadow git repository + * .git-that-shit/git-that-shit/config.json configuration + * .git-that-shit/git-that-shit/snapshots/.git-that-shit/manifest.jsonl + * + * The nested `git-that-shit/git-that-shit` path is historical but is the layout + * every existing snapshot repository on disk already uses, so it remains + * canonical. It is never changed to avoid orphaning existing snapshots. + */ + +/** Directory that holds all Git That Shit state for a project. */ +export function getProjectStateDir(projectRoot: string): string { + return path.join(projectRoot, ".git-that-shit", "git-that-shit"); +} + export function getShadowRepoPath(projectRoot: string): string { - return path.join(projectRoot, ".git-that-shit", "git-that-shit", "snapshots"); + return path.join(getProjectStateDir(projectRoot), "snapshots"); +} + +/** Legacy shadow-repo location documented by older builds. */ +export function getLegacyShadowRepoPath(projectRoot: string): string { + return path.join(projectRoot, ".git-that-shit", "snapshots"); } export function getConfigPath(projectRoot: string): string { - return path.join(projectRoot, ".git-that-shit", "git-that-shit", "config.json"); + return path.join(getProjectStateDir(projectRoot), "config.json"); } export function getLockPath(projectRoot: string): string { @@ -46,14 +69,35 @@ export function getManifestPath(projectRoot: string): string { return path.join(getShadowRepoPath(projectRoot), ".git-that-shit", "manifest.jsonl"); } +export function getGlobalConfigDir(): string { + const override = process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; + if (override) { + return override; + } + const xdg = process.env.XDG_CONFIG_HOME; + if (xdg) { + return path.join(xdg, "opencode"); + } + return path.join(os.homedir(), ".config", "opencode"); +} + +export function getGlobalConfigPaths(): string[] { + const configDir = getGlobalConfigDir(); + return [ + path.join(configDir, "config.json"), + path.join(configDir, "opencode.json"), + path.join(configDir, "opencode.jsonc"), + ]; +} + export function getGlobalConfigPath(): string { - const homedir = os.homedir(); - return path.join(homedir, ".config", "opencode", "config.json"); + return getGlobalConfigPaths()[0]; } export function getProjectConfigPaths(projectRoot: string): string[] { return [ path.join(projectRoot, "opencode.json"), + path.join(projectRoot, "opencode.jsonc"), path.join(projectRoot, ".git-that-shit", "config.json"), path.join(projectRoot, ".opencode", "state", "checkpoint.json"), path.join(projectRoot, ".opencode", "state", "handoff.md"), diff --git a/src/shared/permissions.ts b/src/shared/permissions.ts new file mode 100644 index 0000000..ada2d4d --- /dev/null +++ b/src/shared/permissions.ts @@ -0,0 +1,127 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** + * Restrictive permissions for private snapshot storage. + * + * Directory: `0700` (owner read/write/execute only). + * File: `0600` (owner read/write only). + * + * POSIX permission bits are not meaningful on Windows and Node ignores them + * there, so every helper degrades to a no-op on `win32`. Filesystems that do + * not support POSIX modes (some network/FAT mounts, container overlay setups + * without `chmod`) are also tolerated: a failed `chmod` never fails the + * operation. The limitation is documented in `docs/SECURITY.md`. + */ + +export const PRIVATE_DIR_MODE = 0o700; +export const PRIVATE_FILE_MODE = 0o600; + +/** POSIX mode bits only take effect on non-Windows platforms. */ +export const POSIX_SUPPORTED = process.platform !== "win32"; + +/** + * Apply `mode` to an existing directory. Best effort: returns whether the + * permission was actually applied. Never throws. + */ +export function hardenDir(dirPath: string, mode: number = PRIVATE_DIR_MODE): boolean { + if (!POSIX_SUPPORTED) { + return false; + } + try { + fs.chmodSync(dirPath, mode); + return true; + } catch { + return false; + } +} + +/** Apply `mode` to an existing file. Best effort: never throws. */ +export function hardenFile(filePath: string, mode: number = PRIVATE_FILE_MODE): boolean { + if (!POSIX_SUPPORTED) { + return false; + } + try { + fs.chmodSync(filePath, mode); + return true; + } catch { + return false; + } +} + +/** + * Create a directory (and parents) with owner-only access. The explicit + * `chmod` after `mkdir` defeats the process umask, which would otherwise widen + * the mode of newly created directories. + */ +export function ensurePrivateDir(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true, mode: PRIVATE_DIR_MODE }); + hardenDir(dirPath); +} + +/** + * Write a file with owner-only permissions. When the file already exists its + * permissions are tightened rather than widened. + */ +export function writePrivateFileSync( + filePath: string, + content: string, + mode: number = PRIVATE_FILE_MODE +): boolean { + try { + ensurePrivateDir(path.dirname(filePath)); + fs.writeFileSync(filePath, content, { encoding: "utf-8", mode }); + hardenFile(filePath, mode); + return true; + } catch { + return false; + } +} + +/** Async counterpart of {@link writePrivateFileSync}. */ +export async function writePrivateFile( + filePath: string, + content: string, + mode: number = PRIVATE_FILE_MODE +): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: PRIVATE_DIR_MODE }); + hardenDir(path.dirname(filePath)); + await fs.promises.writeFile(filePath, content, { encoding: "utf-8", mode }); + hardenFile(filePath, mode); +} + +/** + * The Git That Shit state directories that must never be world-readable. + * Only directories that Git That Shit itself owns are touched; user project + * files and unrelated directories are never chmod'ed. + */ +export function getPrivateStateDirs(projectRoot: string): string[] { + const stateDir = path.join(projectRoot, ".git-that-shit"); + const projectStateDir = path.join(stateDir, "git-that-shit"); + const shadowRepo = path.join(projectStateDir, "snapshots"); + return [ + stateDir, + projectStateDir, + shadowRepo, + path.join(shadowRepo, ".git-that-shit"), + path.join(shadowRepo, "global-opencode"), + ]; +} + +/** + * Tighten permissions on every Git That Shit storage directory for a project. + * Existing snapshots are protected without altering the user's working files. + * Returns the directories it successfully restricted (for diagnostics). + */ +export function hardenProjectState(projectRoot: string): string[] { + if (!POSIX_SUPPORTED) { + return []; + } + const hardened: string[] = []; + for (const dir of getPrivateStateDirs(projectRoot)) { + if (fs.existsSync(dir) && hardenDir(dir)) { + hardened.push(dir); + } + } + return hardened; +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 79ae45c..a2b09df 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -38,6 +38,16 @@ export interface SnapshotResult { filesSkipped: Array<{ path: string; reason: string }>; filesRedacted: string[]; timestamp: string; + /** + * True only when a commit was created *and* verified to resolve to a real + * commit object in the shadow repository. Callers must key protection off + * this flag, never off `committed` alone. + */ + verified?: boolean; + /** Sanitized, human-readable warnings. Never contain secret values. */ + warnings?: string[]; + /** Structured, credential-free failure code (the `SnapshotReason`). */ + failureCode?: SnapshotReason; error?: string; } @@ -71,7 +81,10 @@ export interface RestoreResult { skipped: Array<{ path: string; reason: string }>; missing: string[]; conflicts: string[]; + redacted: string[]; tempDir?: string; + /** Sanitized, human-readable warnings. Never contain secret values. */ + warnings?: string[]; error?: string; } @@ -97,6 +110,14 @@ export interface GitThatShitConfig { snapshot: { maxFileSizeMb: number; minDiskSpaceMb: number; + /** Subprocess timeout for shadow-repository git operations, in ms. */ + gitTimeoutMs: number; + /** + * What to do when a protective pre-operation snapshot cannot be created: + * - "standard" (default): let the requested operation continue, warn. + * - "strict": attempt to block classified destructive operations. + */ + failureMode: "standard" | "strict"; }; restore: { autoPreRestoreSnapshot: boolean; diff --git a/src/shared/version.ts b/src/shared/version.ts new file mode 100644 index 0000000..1574eb8 --- /dev/null +++ b/src/shared/version.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +let cached: string | null = null; + +/** + * Read the version from the package manifest so the CLI and plugin always + * report the version that was actually published. Falls back to "0.0.0" if the + * manifest cannot be located (for example when bundled in an unusual layout). + */ +export function getPackageVersion(): string { + if (cached !== null) { + return cached; + } + + try { + const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url)); + const parsed = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { version?: string }; + cached = typeof parsed.version === "string" && parsed.version ? parsed.version : "0.0.0"; + } catch { + cached = "0.0.0"; + } + + return cached; +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..390f0d3 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { existsSync, rmSync, writeFileSync, mkdirSync, readFileSync } from "fs"; +import { join } from "path"; +import { spawnSync } from "child_process"; + +const CLI_PATH = join(process.cwd(), "dist/cli/index.js"); + +function runGts(args: string[], cwd: string, input?: string): { stdout: string; stderr: string; code: number } { + const result = spawnSync("node", [CLI_PATH, ...args], { + cwd, + encoding: "utf-8", + input, + }); + return { + stdout: result.stdout || "", + stderr: result.stderr || "", + code: result.status ?? 0, + }; +} + +describe("Git That Shit CLI", () => { + const testDir = join("/tmp", "gts-cli-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + spawnSync("git", ["init"], { cwd: testDir }); + writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "test" })); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("init creates shadow repo", () => { + const result = runGts(["init"], testDir); + expect(result.code).toBe(0); + expect(result.stdout).toContain("Git That Shit is ready"); + }); + + it("init is idempotent", () => { + runGts(["init"], testDir); + const result = runGts(["init"], testDir); + expect(result.code).toBe(0); + expect(result.stdout).toContain("already initialized"); + }); + + it("status shows shadow repo", () => { + runGts(["init"], testDir); + const result = runGts(["status"], testDir); + expect(result.code).toBe(0); + expect(result.stdout).toContain("Shadow repo"); + }); + + it("status works without init", () => { + const result = runGts(["status"], testDir); + expect(result.code).toBe(0); + expect(result.stdout).toContain("Shadow repo"); + }); + + it("list shows snapshots", () => { + runGts(["init"], testDir); + runGts(["snapshot"], testDir); + const result = runGts(["list"], testDir); + expect(result.code).toBe(0); + expect(result.stdout).toContain("snapshot"); + }); + + it("list with count shows limited snapshots", () => { + runGts(["init"], testDir); + runGts(["snapshot"], testDir); + runGts(["snapshot"], testDir); + const result = runGts(["list", "--count", "1"], testDir); + expect(result.code).toBe(0); + expect(result.stdout).toContain("Showing 1 snapshot"); + }); + + it("diff works after second snapshot", () => { + runGts(["init"], testDir); + runGts(["snapshot"], testDir); + writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "test", version: "1.0.0" })); + runGts(["snapshot"], testDir); + const result = runGts(["list"], testDir); + expect(result.stdout).toMatch(/\d+ snapshot/); + }); + + it("diff with hash shows diff", () => { + runGts(["init"], testDir); + runGts(["snapshot"], testDir); + writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "test", version: "2.0.0" })); + runGts(["snapshot"], testDir); + const listResult = runGts(["list"], testDir); + const hashMatch = listResult.stdout.match(/([a-f0-9]{7,})/g); + if (hashMatch) { + const lastHash = hashMatch[hashMatch.length - 1]; + const result = runGts(["diff", lastHash], testDir); + expect(result.code).toBe(0); + } + }); + + it("restore dry-run shows files to restore", () => { + runGts(["init"], testDir); + runGts(["snapshot"], testDir); + const listResult = runGts(["list"], testDir); + const hashMatch = listResult.stdout.match(/^([a-f0-9]+)/m); + if (hashMatch) { + const result = runGts(["restore", hashMatch[1]], testDir); + expect(result.stdout).toContain("Dry-run mode"); + } + }); + + it("config shows configuration", () => { + const result = runGts(["config"], testDir); + expect(result.code).toBe(0); + expect(result.stdout).toContain("version"); + expect(result.stdout).toContain("tracking"); + }); + + it("doctor runs health checks", () => { + runGts(["init"], testDir); + const result = runGts(["doctor"], testDir); + expect(result.stdout).toContain("Running Git That Shit health checks"); + expect(result.stdout).toContain("Overall:"); + }); + + it("snapshot command works", () => { + runGts(["init"], testDir); + writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "test", version: "1.0.0" })); + const result = runGts(["snapshot"], testDir); + expect(result.code).toBe(0); + expect(result.stdout).toContain("snapshot"); + }); + + it("snapshot with --message flag", () => { + runGts(["init"], testDir); + writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "test", version: "2.0.0" })); + const result = runGts(["snapshot", "--message", "custom reason"], testDir); + expect(result.code).toBe(0); + }); + + it("version shows version number", () => { + const result = runGts(["version"], testDir); + expect(result.code).toBe(0); + expect(result.stdout).toContain("0"); + }); + + it("help shows commands", () => { + const result = runGts(["--help"], testDir); + expect(result.code).toBe(0); + expect(result.stdout).toContain("gts status"); + expect(result.stdout).toContain("gts list"); + expect(result.stdout).toContain("gts diff"); + expect(result.stdout).toContain("gts restore"); + expect(result.stdout).toContain("gts snapshot"); + expect(result.stdout).toContain("gts config"); + expect(result.stdout).toContain("gts doctor"); + expect(result.stdout).toContain("gts prune"); + }); + + it("unknown command fails", () => { + const result = runGts(["foobar"], testDir); + expect(result.code).toBe(1); + expect(result.stderr).toContain("Unknown command"); + }); + + it("prune shows message", () => { + runGts(["init"], testDir); + const result = runGts(["prune"], testDir); + expect(result.code).toBe(0); + }); + + it("prune with keep flag", () => { + runGts(["init"], testDir); + const result = runGts(["prune", "--keep", "10"], testDir); + expect(result.code).toBe(0); + }); + + it("multiple operations work in sequence: init -> snapshot -> list -> snapshot -> list", () => { + runGts(["init"], testDir); + runGts(["snapshot"], testDir); + const list1 = runGts(["list"], testDir); + expect(list1.stdout).toContain("snapshot"); + writeFileSync(join(testDir, "package.json"), JSON.stringify({ name: "test", version: "2.0.0" })); + runGts(["snapshot"], testDir); + const list2 = runGts(["list"], testDir); + expect(list2.stdout).toContain("snapshot"); + }); + + it("works in git repo with multiple config files", () => { + writeFileSync(join(testDir, "tsconfig.json"), JSON.stringify({ compilerOptions: {} })); + writeFileSync(join(testDir, "vite.config.ts"), "export default {}"); + runGts(["init"], testDir); + runGts(["snapshot"], testDir); + const result = runGts(["status"], testDir); + expect(result.code).toBe(0); + }); +}); diff --git a/tests/concurrency.test.ts b/tests/concurrency.test.ts new file mode 100644 index 0000000..16ee5ad --- /dev/null +++ b/tests/concurrency.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; +import { spawn, spawnSync } from "child_process"; + +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = path.join("/tmp", "gts-no-global-concurrency"); + +const CLI_PATH = path.join(import.meta.dir, "..", "dist", "cli", "index.js"); + +async function assertManifestConsistent(projectRoot: string): Promise { + const { getManifestPath, getShadowRepoPath } = await import("../src/shared/paths.js"); + const manifestPath = getManifestPath(projectRoot); + + if (!fs.existsSync(manifestPath)) { + return; + } + + const lines = fs + .readFileSync(manifestPath, "utf-8") + .split("\n") + .filter((l) => l.trim()); + + for (const line of lines) { + const entry = JSON.parse(line); // must be valid JSON: no torn writes + const check = spawnSync("git", ["cat-file", "-e", entry.hash], { + cwd: getShadowRepoPath(projectRoot), + }); + expect(check.status).toBe(0); + } +} + +function spawnAsync(args: string[], cwd: string): Promise { + return new Promise((resolve) => { + const child = spawn("node", [CLI_PATH, ...args], { cwd, stdio: "ignore" }); + child.on("close", (code) => resolve(code ?? 1)); + }); +} + +describe("concurrency", () => { + const root = path.join("/tmp", "gts-conc-" + Date.now()); + + const makeProject = (name: string): string => { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + spawnSync("git", ["init"], { cwd: dir }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version: "1.0.0" })); + return dir; + }; + + beforeEach(() => { + fs.mkdirSync(root, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("two concurrent snapshots do not corrupt the shadow repo", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const project = makeProject("two"); + + const results = await Promise.all([ + snapshot({ projectRoot: project, reason: "a", type: "manual", trigger: "test" }), + snapshot({ projectRoot: project, reason: "b", type: "manual", trigger: "test" }), + ]); + + expect(results.some((r) => r.committed)).toBe(true); + await assertManifestConsistent(project); + + const { isGitRepo } = await import("../src/engine/git.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + expect(await isGitRepo(getShadowRepoPath(project))).toBe(true); + }); + + it("five concurrent snapshots serialize safely", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const project = makeProject("five"); + + const results = await Promise.allSettled( + Array.from({ length: 5 }, (_, i) => + snapshot({ projectRoot: project, reason: `run-${i}`, type: "manual", trigger: "test" }) + ) + ); + + const committed = results.filter( + (r) => r.status === "fulfilled" && r.value.committed + ).length; + expect(committed).toBeGreaterThanOrEqual(1); + await assertManifestConsistent(project); + }); + + it("concurrent CLI processes do not corrupt snapshots", async () => { + const project = makeProject("cli"); + + // Seed an initial snapshot so the shadow repo exists. + await spawnAsync(["init"], project); + + fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "cli", version: "2.0.0" })); + + const codes = await Promise.all([ + spawnAsync(["snapshot"], project), + spawnAsync(["snapshot"], project), + spawnAsync(["snapshot"], project), + ]); + + for (const code of codes) { + expect(code).toBe(0); + } + await assertManifestConsistent(project); + }); + + it("different projects snapshot independently", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const a = makeProject("proj-a"); + const b = makeProject("proj-b"); + + const [ra, rb] = await Promise.all([ + snapshot({ projectRoot: a, reason: "a", type: "manual", trigger: "test" }), + snapshot({ projectRoot: b, reason: "b", type: "manual", trigger: "test" }), + ]); + + expect(ra.committed).toBe(true); + expect(rb.committed).toBe(true); + + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + expect(getShadowRepoPath(a)).not.toBe(getShadowRepoPath(b)); + await assertManifestConsistent(a); + await assertManifestConsistent(b); + }); + + it("lock is exclusive within a project", async () => { + const { acquireLock, releaseLock } = await import("../src/engine/lock.js"); + const project = makeProject("lock"); + + expect(await acquireLock(project, "first", "test")).toBe(true); + await expect(acquireLock(project, "second", "test")).rejects.toThrow("Lock held by PID"); + await releaseLock(project); + expect(await acquireLock(project, "third", "test")).toBe(true); + await releaseLock(project); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..f581c2d --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs"; + +describe("config", () => { + const testDir = path.join("/tmp", "gts-config-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("loadConfig returns defaults when no file exists", async () => { + const { loadConfig, DEFAULT_CONFIG } = await import("../src/engine/config.js"); + const result = await loadConfig(testDir); + expect(result.version).toBe("0.1"); + expect(result.tracking.include).toEqual(DEFAULT_CONFIG.tracking.include); + }); + + it("loadConfig returns parsed config", async () => { + const configDir = path.join(testDir, ".git-that-shit", "git-that-shit"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, "config.json"), JSON.stringify({ + version: "0.1", + tracking: { include: ["*.ts"], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: false, configWrites: false }, + fileEdits: { mode: "disabled", debounceMs: 5000 }, + secrets: { redactKeys: ["key"], warnOnSensitive: false }, + snapshot: { maxFileSizeMb: 5, minDiskSpaceMb: 50 }, + restore: { autoPreRestoreSnapshot: false }, + })); + const { loadConfig } = await import("../src/engine/config.js"); + const result = await loadConfig(testDir); + expect(result.tracking.include).toEqual(["*.ts"]); + expect(result.destructiveOps.riskyPackageManagerOps).toBe(false); + expect(result.fileEdits.mode).toBe("disabled"); + expect(result.snapshot.maxFileSizeMb).toBe(5); + }); + + it("loadConfig throws on wrong version", async () => { + const configDir = path.join(testDir, ".git-that-shit", "git-that-shit"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, "config.json"), JSON.stringify({ version: "0.2" })); + const { loadConfig } = await import("../src/engine/config.js"); + await expect(loadConfig(testDir)).rejects.toThrow("Invalid or missing config version"); + }); + + it("loadConfig handles malformed JSON by backing up and returning defaults", async () => { + const configDir = path.join(testDir, ".git-that-shit", "git-that-shit"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, "config.json"), "{invalid}"); + const { loadConfig } = await import("../src/engine/config.js"); + const result = await loadConfig(testDir); + expect(result.version).toBe("0.1"); + expect(existsSync(path.join(configDir, "config.json.bak"))).toBe(true); + }); + + it("saveConfig writes config file", async () => { + const { saveConfig, DEFAULT_CONFIG } = await import("../src/engine/config.js"); + await saveConfig(testDir, DEFAULT_CONFIG); + const configPath = path.join(testDir, ".git-that-shit", "git-that-shit", "config.json"); + expect(existsSync(configPath)).toBe(true); + const content = JSON.parse(readFileSync(configPath, "utf-8")); + expect(content.version).toBe("0.1"); + }); + + it("resetConfig resets to defaults", async () => { + const configDir = path.join(testDir, ".git-that-shit", "git-that-shit"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, "config.json"), JSON.stringify({ + version: "0.1", + tracking: { include: [], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: false, configWrites: false }, + fileEdits: { mode: "disabled", debounceMs: 5000 }, + secrets: { redactKeys: ["key"], warnOnSensitive: false }, + snapshot: { maxFileSizeMb: 5, minDiskSpaceMb: 50 }, + restore: { autoPreRestoreSnapshot: false }, + })); + const { resetConfig, DEFAULT_CONFIG } = await import("../src/engine/config.js"); + await resetConfig(testDir); + const { loadConfig } = await import("../src/engine/config.js"); + const result = await loadConfig(testDir); + expect(result.destructiveOps.riskyPackageManagerOps).toBe(DEFAULT_CONFIG.destructiveOps.riskyPackageManagerOps); + }); + + it("configExists returns false for missing config", async () => { + const { configExists } = await import("../src/engine/config.js"); + expect(await configExists(testDir)).toBe(false); + }); + + it("configExists returns true for existing config", async () => { + const { saveConfig, DEFAULT_CONFIG, configExists } = await import("../src/engine/config.js"); + await saveConfig(testDir, DEFAULT_CONFIG); + expect(await configExists(testDir)).toBe(true); + }); + + it("mergeConfig merges partial config", async () => { + const { DEFAULT_CONFIG } = await import("../src/engine/config.js"); + const { loadConfig } = await import("../src/engine/config.js"); + const configDir = path.join(testDir, ".git-that-shit", "git-that-shit"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, "config.json"), JSON.stringify({ + version: "0.1", + tracking: { include: ["*.ts"], exclude: [], allowSensitiveFiles: false }, + })); + const result = await loadConfig(testDir); + expect(result.tracking.include).toEqual(["*.ts"]); + expect(result.destructiveOps.riskyPackageManagerOps).toBe(DEFAULT_CONFIG.destructiveOps.riskyPackageManagerOps); + expect(result.snapshot.maxFileSizeMb).toBe(DEFAULT_CONFIG.snapshot.maxFileSizeMb); + }); + + it("getHardExcludes returns all patterns", async () => { + const { getHardExcludes } = await import("../src/engine/config.js"); + const excludes = getHardExcludes(); + expect(excludes).toContain("**/node_modules/**"); + expect(excludes).toContain(".git/**"); + expect(excludes).toContain("*.log"); + expect(excludes).toContain("*.gguf"); + expect(excludes).toContain(".env"); + expect(excludes).toContain("**/id_rsa"); + expect(excludes).toContain("**/credentials.json"); + }); + + it("getAllowedEnvPatterns returns safe patterns", async () => { + const { getAllowedEnvPatterns } = await import("../src/engine/config.js"); + const patterns = getAllowedEnvPatterns(); + expect(patterns).toContain(".env.example"); + expect(patterns).toContain(".env.sample"); + expect(patterns).toContain(".env.template"); + }); + + it("loadConfig handles ENOENT gracefully", async () => { + const { loadConfig } = await import("../src/engine/config.js"); + const result = await loadConfig("/nonexistent-path-12345"); + expect(result.version).toBe("0.1"); + }); +}); diff --git a/tests/detector.test.ts b/tests/detector.test.ts new file mode 100644 index 0000000..56195bf --- /dev/null +++ b/tests/detector.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect } from "bun:test"; + +const MODULE = "../src/engine/detector.js"; + +describe("detector", () => { + it("detects git reset --hard", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git reset --hard"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + expect(result.level).toBe("destructive"); + }); + + it("detects git clean -fd", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git clean -fd"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + expect(result.level).toBe("destructive"); + }); + + it("detects git push --force", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git push --force"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects git rebase", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git rebase main"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects git stash drop", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git stash drop"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects git stash clear", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git stash clear"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects git branch -D", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git branch -D feature"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects git restore", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git restore file.ts"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("does NOT snapshot safe git checkout branch", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git checkout main"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("does NOT snapshot git status", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git status"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("does NOT snapshot git diff", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git diff"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("does NOT snapshot git log", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git log"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("does NOT snapshot git fetch", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git fetch"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("does NOT snapshot git pull", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git pull"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("does NOT snapshot git stash push", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git stash push"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("does NOT snapshot git stash save", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git stash save wip"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("detects risky package manager ops", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["npm install"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + expect(result.level).toBe("risky"); + }); + + it("detects pnpm install as risky", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["pnpm install"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects bun add as risky", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["bun add lodash"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects yarn remove as risky", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["yarn remove express"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("respects riskyPackageManagerOps config", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["npm install"], { destructiveOps: { riskyPackageManagerOps: false } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("detects rm -rf", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["rm -rf node_modules"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + expect(result.level).toBe("destructive"); + }); + + it("detects rm -f", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["rm -f file.txt"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects shred", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["shred secret.txt"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects config file writes", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("write", ["package.json"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + expect(result.level).toBe("config-write"); + }); + + it("detects opencode.json writes", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("edit", ["opencode.json"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects .git-that-shit writes", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("write", [".git-that-shit/config.json"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects .opencode writes", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("edit", [".opencode/state/checkpoint.json"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects Dockerfile writes", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("write", ["Dockerfile"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects vite.config writes", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("write", ["vite.config.ts"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("does NOT snapshot for str_replace_editor on non-config file", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("str_replace_editor", ["src/component.tsx"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("is safe for non-config write targets", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("write", ["src/component.tsx"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("handles empty args", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", [], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("handles case-insensitive matching", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["GIT RESET --HARD"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("handles edge case: rm with no flags (just rm by itself is safe)", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["rm file.txt"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("returns commandPreview truncated to 80 chars", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const longCommand = "rm -rf " + "x".repeat(100); + const result = detectDestructiveCommand("bash", [longCommand], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.commandPreview.length).toBeLessThanOrEqual(80); + }); + + it("detects npx init commands as risky", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["npx shadcn@latest init"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("does not snapshot for cmd tool with non-destructive command", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("cmd", ["dir"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("does not snapshot for powershell tool with non-destructive command", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("powershell", ["Get-ChildItem"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(false); + }); + + it("getDetectionLevel returns correct levels", async () => { + const { getDetectionLevel } = await import(MODULE); + expect(getDetectionLevel({ level: "destructive", shouldSnapshot: true, reason: "", commandPreview: "" })).toBe("destructive"); + expect(getDetectionLevel({ level: "risky", shouldSnapshot: true, reason: "", commandPreview: "" })).toBe("risky"); + expect(getDetectionLevel({ level: "safe", shouldSnapshot: false, reason: "", commandPreview: "" })).toBe("safe"); + }); + + it("helper functions work correctly", async () => { + const { isDestructive, isRisky, isConfigWrite, isSafe } = await import(MODULE); + expect(isDestructive("destructive")).toBe(true); + expect(isDestructive("risky")).toBe(false); + expect(isRisky("risky")).toBe(true); + expect(isConfigWrite("config-write")).toBe(true); + expect(isSafe("safe")).toBe(true); + expect(isSafe("destructive")).toBe(false); + }); + + it("detects git checkout with -- as destructive", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git checkout -- file.ts"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects git reset (plain) as destructive", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git reset"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + it("detects git worktree remove as destructive", async () => { + const { detectDestructiveCommand } = await import(MODULE); + const result = detectDestructiveCommand("bash", ["git worktree remove old-feature"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); +}); diff --git a/tests/disk.test.ts b/tests/disk.test.ts new file mode 100644 index 0000000..4caa6cc --- /dev/null +++ b/tests/disk.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; + +describe("disk", () => { + const testDir = path.join("/tmp", "gts-disk-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("checkFileSize returns acceptable for small file", async () => { + const { checkFileSize } = await import("../src/engine/disk.js"); + const f = path.join(testDir, "small.txt"); + writeFileSync(f, "hello"); + const result = checkFileSize(f, 10); + expect(result.acceptable).toBe(true); + expect(result.sizeMb).toBe(0); + }); + + it("checkFileSize returns unacceptable for large file", async () => { + const { checkFileSize } = await import("../src/engine/disk.js"); + const result = checkFileSize("/dev/null", 0); + expect(result.acceptable).toBe(false); + }); + + it("checkFileSize handles missing file", async () => { + const { checkFileSize } = await import("../src/engine/disk.js"); + const result = checkFileSize("/nonexistent", 10); + expect(result.acceptable).toBe(true); + expect(result.sizeMb).toBe(0); + }); + + it("formatBytes formats correctly", async () => { + const { formatBytes } = await import("../src/engine/disk.js"); + expect(formatBytes(0)).toContain("0.00 B"); + expect(formatBytes(1024)).toContain("1.00 KB"); + expect(formatBytes(1048576)).toContain("1.00 MB"); + expect(formatBytes(1073741824)).toContain("1.00 GB"); + }); + + it("formatBytes handles TB", async () => { + const { formatBytes } = await import("../src/engine/disk.js"); + expect(formatBytes(1099511627776)).toContain("1.00 TB"); + }); + + it("getDirSize returns total size", async () => { + const { getDirSize } = await import("../src/engine/disk.js"); + writeFileSync(path.join(testDir, "a.txt"), "12345"); + mkdirSync(path.join(testDir, "sub")); + writeFileSync(path.join(testDir, "sub", "b.txt"), "123"); + const size = await getDirSize(testDir); + expect(size).toBe(8); + }); + + it("getDirSize returns 0 for missing dir", async () => { + const { getDirSize } = await import("../src/engine/disk.js"); + expect(await getDirSize("/nonexistent-dir-xyz")).toBe(0); + }); + + it("getFreeSpaceMb returns a number", async () => { + const { getFreeSpaceMb } = await import("../src/engine/disk.js"); + const free = await getFreeSpaceMb(); + expect(free).toBeGreaterThanOrEqual(0); + }); + + it("ensureDiskSpace returns ok with sufficient space", async () => { + const { ensureDiskSpace } = await import("../src/engine/disk.js"); + const result = await ensureDiskSpace(testDir, 1); + expect(result.ok).toBe(true); + }); + + it("ensureDiskSpace returns ok for non-existent dir (statfs fallback)", async () => { + const { ensureDiskSpace } = await import("../src/engine/disk.js"); + const result = await ensureDiskSpace("/nonexistent", 1); + expect(result.ok).toBe(true); + }); + + it("disk check gracefully handles statfs failure", async () => { + const { checkDiskSpace } = await import("../src/engine/disk.js"); + const result = await checkDiskSpace("/nonexistent12345", 100); + expect(result.sufficient).toBe(true); + expect(result.freeMb).toBe(0); + }); +}); diff --git a/tests/git.test.ts b/tests/git.test.ts new file mode 100644 index 0000000..ce89e86 --- /dev/null +++ b/tests/git.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; +import { spawnSync } from "child_process"; + +describe("git operations", () => { + const testDir = path.join("/tmp", "gts-git-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + spawnSync("git", ["init"], { cwd: testDir }); + spawnSync("git", ["config", "user.email", "test@test.com"], { cwd: testDir }); + spawnSync("git", ["config", "user.name", "Test"], { cwd: testDir }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("checkGitAvailable returns true", async () => { + const { checkGitAvailable } = await import("../src/engine/git.js"); + expect(await checkGitAvailable()).toBe(true); + }); + + it("getGitVersion returns version string", async () => { + const { getGitVersion } = await import("../src/engine/git.js"); + const version = await getGitVersion(); + expect(version).not.toBeNull(); + expect(version!).toContain("git version"); + }); + + it("isGitRepo returns true for git repo", async () => { + const { isGitRepo } = await import("../src/engine/git.js"); + expect(await isGitRepo(testDir)).toBe(true); + }); + + it("isGitRepo returns false for non-git dir", async () => { + const { isGitRepo } = await import("../src/engine/git.js"); + expect(await isGitRepo("/tmp")).toBe(false); + }); + + it("initRepo initializes a repo", async () => { + const { initRepo, isGitRepo } = await import("../src/engine/git.js"); + const newDir = path.join(testDir, "new-repo"); + mkdirSync(newDir); + await initRepo(newDir); + expect(await isGitRepo(newDir)).toBe(true); + }); + + it("setLocalConfig and getLocalConfig work", async () => { + const { setLocalConfig, getLocalConfig } = await import("../src/engine/git.js"); + await setLocalConfig(testDir, "user.email", "custom@test.com"); + expect(await getLocalConfig(testDir, "user.email")).toBe("custom@test.com"); + }); + + it("hasStagedChanges returns false initially", async () => { + const { hasStagedChanges } = await import("../src/engine/git.js"); + expect(await hasStagedChanges(testDir)).toBe(false); + }); + + it("hasStagedChanges returns true after staging", async () => { + const { hasStagedChanges, addAll } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "file.txt"), "content"); + await addAll(testDir); + expect(await hasStagedChanges(testDir)).toBe(true); + }); + + it("hasUnstagedChanges returns false after commit", async () => { + const { hasUnstagedChanges, addAll, commit } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "file.txt"), "content"); + await addAll(testDir); + await commit(testDir, "initial"); + expect(await hasUnstagedChanges(testDir)).toBe(false); + }); + + it("commit returns hash", async () => { + const { addAll, commit } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "file.txt"), "content"); + await addAll(testDir); + const hash = await commit(testDir, "test commit"); + expect(hash.length).toBeGreaterThanOrEqual(7); + }); + + it("log returns commit entries", async () => { + const { addAll, commit, log } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "a.txt"), "a"); + await addAll(testDir); + await commit(testDir, "first"); + writeFileSync(path.join(testDir, "b.txt"), "b"); + await addAll(testDir); + await commit(testDir, "second"); + const entries = await log(testDir); + expect(entries.length).toBe(2); + expect(entries[0].message).toContain("second"); + expect(entries[1].message).toContain("first"); + }); + + it("log returns empty for no commits", async () => { + const { log } = await import("../src/engine/git.js"); + expect(await log(testDir)).toEqual([]); + }); + + it("show returns content at commit", async () => { + const { addAll, commit, show } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "show.txt"), "show content"); + await addAll(testDir); + const hash = await commit(testDir, "show test"); + const content = await show(testDir, hash); + expect(content).toContain("show content"); + }); + + it("diff returns differences", async () => { + const { addAll, commit, diff } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "diff.txt"), "v1"); + await addAll(testDir); + await commit(testDir, "v1"); + writeFileSync(path.join(testDir, "diff.txt"), "v2"); + await addAll(testDir); + const hash = await commit(testDir, "v2"); + const d = await diff(testDir, `${hash}^`, hash); + expect(d).toContain("v1"); + expect(d).toContain("v2"); + }); + + it("status returns short status", async () => { + const { status } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "new.txt"), "new"); + const s = await status(testDir); + expect(s).toContain("new.txt"); + }); + + it("getCurrentBranch returns branch name", async () => { + const { getCurrentBranch } = await import("../src/engine/git.js"); + const branch = await getCurrentBranch(testDir); + expect(branch).toBe("master"); + }); + + it("getFileAtCommit returns content", async () => { + const { addAll, commit, getFileAtCommit } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "get.txt"), "get content"); + await addAll(testDir); + const hash = await commit(testDir, "get test"); + expect(await getFileAtCommit(testDir, hash, "get.txt")).toBe("get content"); + }); + + it("getFileAtCommit returns empty for missing file", async () => { + const { addAll, commit, getFileAtCommit } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "a.txt"), "a"); + await addAll(testDir); + const hash = await commit(testDir, "test"); + expect(await getFileAtCommit(testDir, hash, "nonexistent.txt")).toBe(""); + }); + + it("listFilesAtCommit returns file list", async () => { + const { addAll, commit, listFilesAtCommit } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "file1.txt"), "1"); + writeFileSync(path.join(testDir, "file2.txt"), "2"); + await addAll(testDir); + const hash = await commit(testDir, "multi"); + const files = await listFilesAtCommit(testDir, hash); + expect(files).toContain("file1.txt"); + expect(files).toContain("file2.txt"); + }); + + it("getShortHash returns short hash", async () => { + const { addAll, commit, getShortHash } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "short.txt"), "s"); + await addAll(testDir); + const hash = await commit(testDir, "short test"); + const short = await getShortHash(testDir, hash); + expect(short.length).toBeLessThanOrEqual(7); + }); + + it("getCommitDate returns date string", async () => { + const { addAll, commit, getCommitDate } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "date.txt"), "d"); + await addAll(testDir); + const hash = await commit(testDir, "date test"); + const date = await getCommitDate(testDir, hash); + expect(date).not.toBeNull(); + }); + + it("getParentHash returns parent hash", async () => { + const { addAll, commit, getParentHash } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "p.txt"), "p"); + await addAll(testDir); + const hash1 = await commit(testDir, "parent"); + writeFileSync(path.join(testDir, "c.txt"), "c"); + await addAll(testDir); + const hash2 = await commit(testDir, "child"); + const parent = await getParentHash(testDir, hash2); + expect(parent).toBe(hash1); + }); + + it("writeGitignore creates gitignore", async () => { + const { writeGitignore } = await import("../src/engine/git.js"); + await writeGitignore(testDir, "*.log\n"); + const content = require("fs").readFileSync(path.join(testDir, ".gitignore"), "utf-8"); + expect(content).toContain("*.log"); + }); + + it("getTrackingFiles returns tracked files", async () => { + const { addAll, commit, getTrackingFiles } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "tracked.txt"), "tracked"); + await addAll(testDir); + await commit(testDir, "track test"); + const files = await getTrackingFiles(testDir); + expect(files).toContain("tracked.txt"); + }); + + it("createBundle creates bundle file", async () => { + const { addAll, commit, createBundle } = await import("../src/engine/git.js"); + writeFileSync(path.join(testDir, "bundle.txt"), "bundle"); + await addAll(testDir); + await commit(testDir, "bundle test"); + const bundlePath = path.join(testDir, "out.bundle"); + await createBundle(testDir, bundlePath); + expect(existsSync(bundlePath)).toBe(true); + }); +}); diff --git a/tests/independence.test.ts b/tests/independence.test.ts new file mode 100644 index 0000000..2b4639d --- /dev/null +++ b/tests/independence.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; + +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = path.join("/tmp", "gts-no-global-independence"); + +const DIST_INDEX = path.join(import.meta.dir, "..", "dist", "index.js"); +const SRC_DIR = path.join(import.meta.dir, "..", "src"); + +function makeEventStream() { + const queue: unknown[] = []; + let resolveNext: ((r: IteratorResult) => void) | null = null; + let closed = false; + return { + push(event: unknown) { + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r({ done: false, value: event }); + } else { + queue.push(event); + } + }, + close() { + closed = true; + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r({ done: true, value: undefined }); + } + }, + iterable(signal?: AbortSignal) { + return { + [Symbol.asyncIterator]() { + return { + next(): Promise> { + if (signal?.aborted) return Promise.resolve({ done: true, value: undefined }); + if (queue.length) return Promise.resolve({ done: false, value: queue.shift() }); + if (closed) return Promise.resolve({ done: true, value: undefined }); + return new Promise((res) => { + resolveNext = res; + }); + }, + }; + }, + }; + }, + }; +} + +function makeHarness(projectDir: string) { + const tools = new Map(); + const hooks = new Map any>(); + const stream = makeEventStream(); + + const editor = { + namespace() {}, + add(tool: any) { + tools.set(tool.name, tool); + }, + list: () => [...tools.values()], + get: (id: string) => tools.get(id), + update() {}, + remove() {}, + }; + + const ctx: any = { + app: { name: "opencode", version: "2.0.21", channel: "test" }, + location: { + directory: projectDir, + project: { id: "proj", directory: projectDir, canonical: projectDir }, + }, + options: {}, + tool: { + async hook(name: string, cb: any) { + hooks.set(name, cb); + return { dispose: async () => {} }; + }, + async transform(cb: (editor: any) => void) { + cb(editor); + return { dispose: async () => {} }; + }, + async list() { + return [...tools.values()]; + }, + async reload() {}, + }, + event: { + subscribe(options?: { signal?: AbortSignal }) { + return stream.iterable(options?.signal); + }, + }, + storage: { async get() {}, async set() {}, async remove() {} }, + }; + + return { ctx, tools, hooks, stream }; +} + +const tick = (ms = 60) => new Promise((r) => setTimeout(r, ms)); + +describe("independence and interoperability", () => { + const root = path.join("/tmp", "gts-indep-" + Date.now()); + + const makeProject = (name: string): string => { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version: "1.0.0" })); + return dir; + }; + + beforeEach(() => { + fs.mkdirSync(root, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("does not import any companion plugin or shared framework", () => { + const files: string[] = []; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith(".ts")) files.push(full); + } + }; + walk(SRC_DIR); + + const forbidden = /(stranger|checkpoint|cheapcode|orchestrator|@gitdamnit\/)/i; + const offenders: string[] = []; + + for (const file of files) { + const content = fs.readFileSync(file, "utf-8"); + const importLines = content + .split("\n") + .filter((line) => /\bfrom\s+["']/.test(line) || /\bimport\s*\(/.test(line)); + for (const line of importLines) { + if (forbidden.test(line)) { + offenders.push(`${path.relative(SRC_DIR, file)}: ${line.trim()}`); + } + } + } + + expect(offenders).toEqual([]); + }); + + it("only depends on @opencode/plugin and node builtins", () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(import.meta.dir, "..", "package.json"), "utf-8") + ); + const deps = Object.keys(pkg.dependencies || {}); + expect(deps).toEqual(["@opencode/plugin"]); + }); + + it("works with no companion plugin present", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("alone"); + const { ctx, tools, hooks } = makeHarness(project); + + const cleanup = await mod.default.setup(ctx); + try { + expect(tools.size).toBe(8); + expect(hooks.has("execute.before")).toBe(true); + expect(hooks.has("execute.after")).toBe(true); + + // No .opencode/state or other plugin artifacts exist; still works. + await hooks.get("execute.before")!({ + tool: "shell", + input: { command: "rm -rf build" }, + sessionID: "s-alone", + }); + + const { readManifest } = await import("../src/engine/manifest.js"); + const entries = await readManifest(project); + expect(entries.length).toBeGreaterThanOrEqual(1); + } finally { + await cleanup?.(); + } + }); + + it("never mutates the tool event (read-only hook)", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("readonly"); + const { ctx, hooks } = makeHarness(project); + + const cleanup = await mod.default.setup(ctx); + try { + const before = { tool: "shell", input: { command: "git reset --hard" }, sessionID: "s1" }; + const beforeSnapshot = JSON.stringify(before); + await hooks.get("execute.before")!(before); + expect(JSON.stringify(before)).toBe(beforeSnapshot); + + const after = { tool: "write", status: "completed", input: { path: "x" }, sessionID: "s1" }; + const afterSnapshot = JSON.stringify(after); + await hooks.get("execute.after")!(after); + expect(JSON.stringify(after)).toBe(afterSnapshot); + } finally { + await cleanup?.(); + } + }); + + it("survives malformed companion state files", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("malformed"); + const stateDir = path.join(project, ".opencode", "state"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "strangerdanger-audit.jsonl"), "{ not json at all"); + fs.writeFileSync(path.join(stateDir, "checkpoint.json"), "}{ broken json"); + fs.writeFileSync(path.join(stateDir, "handoff.md"), "# handoff\nsecret: keep"); + + const { ctx, hooks } = makeHarness(project); + const cleanup = await mod.default.setup(ctx); + try { + let threw = false; + try { + await hooks.get("execute.before")!({ + tool: "shell", + input: { command: "git clean -fd" }, + sessionID: "s1", + }); + } catch { + threw = true; + } + expect(threw).toBe(false); + + const { readManifest } = await import("../src/engine/manifest.js"); + const entries = await readManifest(project); + expect(entries.length).toBeGreaterThanOrEqual(1); + } finally { + await cleanup?.(); + } + }); + + it("does not write into companion plugin storage", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("boundaries"); + const stateDir = path.join(project, ".opencode", "state"); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "checkpoint.json"), JSON.stringify({ task: "x" })); + const before = fs.readdirSync(stateDir).sort(); + + const { ctx, hooks } = makeHarness(project); + const cleanup = await mod.default.setup(ctx); + try { + await hooks.get("execute.before")!({ + tool: "shell", + input: { command: "git reset --hard" }, + sessionID: "s1", + }); + // Our writes live under .git-that-shit only. + const after = fs.readdirSync(stateDir).sort(); + expect(after).toEqual(before); + expect(fs.existsSync(path.join(project, ".git-that-shit"))).toBe(true); + } finally { + await cleanup?.(); + } + }); + + it("supports reload: multiple independent setups do not share state", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("reload"); + + const a = makeHarness(project); + const b = makeHarness(project); + const cleanupA = await mod.default.setup(a.ctx); + const cleanupB = await mod.default.setup(b.ctx); + + try { + // A missing/cleaned-up sibling must not break the other instance. + await cleanupA?.(); + + await b.hooks.get("execute.before")!({ + tool: "shell", + input: { command: "git reset --hard" }, + sessionID: "s-b", + }); + + const { readManifest } = await import("../src/engine/manifest.js"); + const entries = await readManifest(project); + expect(entries.length).toBeGreaterThanOrEqual(1); + expect(a.tools.size).toBe(8); + expect(b.tools.size).toBe(8); + } finally { + await cleanupB?.(); + } + }); + + it("keeps the event stream alive across a malformed event", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("stream"); + const { ctx, stream } = makeHarness(project); + + const cleanup = await mod.default.setup(ctx); + try { + // Malformed: file is not a string. + stream.push({ type: "filesystem.changed", data: { file: 12345 } }); + await tick(); + // Valid event after the malformed one must still be processed. + stream.push({ type: "session.created", data: { sessionID: "s-after" } }); + + let entries: any[] = []; + for (let i = 0; i < 100; i++) { + await tick(50); + const { readManifest } = await import("../src/engine/manifest.js"); + entries = await readManifest(project); + if (entries.length > 0) break; + } + expect(entries.length).toBeGreaterThanOrEqual(1); + expect(entries[entries.length - 1].type).toBe("session-start"); + } finally { + await cleanup?.(); + } + }); + + it("namespaces its configuration and logging", async () => { + const { getConfigPath, getShadowRepoPath } = await import("../src/shared/paths.js"); + const project = "/tmp/example-project"; + expect(getConfigPath(project)).toContain(".git-that-shit/git-that-shit/config.json"); + expect(getShadowRepoPath(project)).toContain(".git-that-shit/git-that-shit/snapshots"); + }); +}); diff --git a/tests/lock.test.ts b/tests/lock.test.ts new file mode 100644 index 0000000..9b745b9 --- /dev/null +++ b/tests/lock.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; + +describe("lock", () => { + const testDir = path.join("/tmp", "gts-lock-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("acquireLock succeeds", async () => { + const { acquireLock, releaseLock } = await import("../src/engine/lock.js"); + const acquired = await acquireLock(testDir, "test", "testing"); + expect(acquired).toBe(true); + await releaseLock(testDir); + }); + + it("releaseLock removes lock file", async () => { + const { acquireLock, releaseLock } = await import("../src/engine/lock.js"); + const { getLockPath } = await import("../src/shared/paths.js"); + await acquireLock(testDir, "test", "testing"); + await releaseLock(testDir); + expect(existsSync(getLockPath(testDir))).toBe(false); + }); + + it("readLock returns null when no lock", async () => { + const { readLock } = await import("../src/engine/lock.js"); + expect(await readLock(testDir)).toBeNull(); + }); + + it("readLock returns lock file content", async () => { + const { acquireLock, readLock, releaseLock } = await import("../src/engine/lock.js"); + await acquireLock(testDir, "test-op", "testing reason"); + const lock = await readLock(testDir); + expect(lock).not.toBeNull(); + expect(lock!.operation).toBe("test-op"); + expect(lock!.reason).toBe("testing reason"); + expect(lock!.pid).toBeGreaterThan(0); + await releaseLock(testDir); + }); + + it("isLocked returns false when no lock", async () => { + const { isLocked } = await import("../src/engine/lock.js"); + expect(await isLocked(testDir)).toBe(false); + }); + + it("isLocked returns true when locked", async () => { + const { acquireLock, isLocked, releaseLock } = await import("../src/engine/lock.js"); + await acquireLock(testDir, "test", "testing"); + expect(await isLocked(testDir)).toBe(true); + await releaseLock(testDir); + }); + + it("withLock runs function under lock", async () => { + const { withLock } = await import("../src/engine/lock.js"); + const result = await withLock(testDir, "test", "testing", async () => { + return "done"; + }); + expect(result).toBe("done"); + }); + + it("withLock releases lock after function", async () => { + const { withLock, isLocked } = await import("../src/engine/lock.js"); + await withLock(testDir, "test", "testing", async () => { + return "done"; + }); + expect(await isLocked(testDir)).toBe(false); + }); + + it("lock contention throws after retries", async () => { + const { acquireLock } = await import("../src/engine/lock.js"); + await acquireLock(testDir, "first", "first"); + try { + await acquireLock(testDir, "second", "second"); + expect(true).toBe(false); // should not reach here + } catch (error) { + expect((error as Error).message).toContain("Lock held by PID"); + } + }); + + it("cleanupStaleLocks returns 0 when no lock", async () => { + const { cleanupStaleLocks } = await import("../src/engine/lock.js"); + expect(await cleanupStaleLocks(testDir)).toBe(0); + }); + + it("cleanupStaleLocks removes stale lock", async () => { + const { getLockPath } = await import("../src/shared/paths.js"); + const lockPath = getLockPath(testDir); + const { ensureDir } = await import("../src/shared/paths.js"); + ensureDir(path.dirname(lockPath)); + writeFileSync(lockPath, JSON.stringify({ + pid: 99999999, + timestamp: new Date(Date.now() - 600000).toISOString(), + operation: "stale", + reason: "dead process", + })); + const { cleanupStaleLocks } = await import("../src/engine/lock.js"); + const cleaned = await cleanupStaleLocks(testDir); + expect(cleaned).toBe(1); + expect(existsSync(lockPath)).toBe(false); + }); + + it("isLockStale returns true for old lock", async () => { + const { isLockStale } = await import("../src/engine/lock.js"); + const stale = await isLockStale({ + pid: 99999999, + timestamp: new Date(Date.now() - 600000).toISOString(), + operation: "old", + reason: "old", + }); + expect(stale).toBe(true); + }); + + it("releaseLock does not throw when no lock", async () => { + const { releaseLock } = await import("../src/engine/lock.js"); + await releaseLock(testDir); + // Should not throw + }); + + it("isLocked cleans up stale locks", async () => { + const { getLockPath } = await import("../src/shared/paths.js"); + const lockPath = getLockPath(testDir); + const { ensureDir } = await import("../src/shared/paths.js"); + ensureDir(path.dirname(lockPath)); + writeFileSync(lockPath, JSON.stringify({ + pid: 99999999, + timestamp: new Date(Date.now() - 600000).toISOString(), + operation: "stale", + reason: "dead", + })); + const { isLocked } = await import("../src/engine/lock.js"); + expect(await isLocked(testDir)).toBe(false); + expect(existsSync(lockPath)).toBe(false); + }); +}); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts new file mode 100644 index 0000000..6e8efc6 --- /dev/null +++ b/tests/manifest.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs"; + +describe("manifest", () => { + const testDir = path.join("/tmp", "gts-manifest-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("readManifest returns empty array when no file", async () => { + const { readManifest } = await import("../src/engine/manifest.js"); + const entries = await readManifest(testDir); + expect(entries).toEqual([]); + }); + + it("appendManifestEntry adds entry", async () => { + const { appendManifestEntry, readManifest } = await import("../src/engine/manifest.js"); + const entry = { + hash: "abc123", shortHash: "abc1234", timestamp: new Date().toISOString(), + type: "manual" as const, trigger: "test", reason: "test entry", + filesCopied: ["package.json"], filesSkipped: [], filesRedacted: [], + }; + await appendManifestEntry(testDir, entry); + const entries = await readManifest(testDir); + expect(entries.length).toBe(1); + expect(entries[0].hash).toBe("abc123"); + }); + + it("readManifest returns limited entries", async () => { + const { appendManifestEntry, readManifest } = await import("../src/engine/manifest.js"); + for (let i = 0; i < 5; i++) { + await appendManifestEntry(testDir, { + hash: `hash${i}`, shortHash: `short${i}`, timestamp: new Date().toISOString(), + type: "manual", trigger: "test", reason: `entry ${i}`, + filesCopied: [], filesSkipped: [], filesRedacted: [], + }); + } + const entries = await readManifest(testDir, 2); + expect(entries.length).toBe(2); + }); + + it("readManifest skips invalid JSON lines", async () => { + const { getManifestPath } = await import("../src/shared/paths.js"); + const manifestPath = getManifestPath(testDir); + mkdirSync(path.dirname(manifestPath), { recursive: true }); + writeFileSync(manifestPath, '{"hash":"valid"}\ninvalid json\n{"hash":"also-valid"}\n'); + const { readManifest } = await import("../src/engine/manifest.js"); + const entries = await readManifest(testDir); + expect(entries.length).toBe(2); + }); + + it("validateManifest validates correctly", async () => { + const { appendManifestEntry, validateManifest } = await import("../src/engine/manifest.js"); + await appendManifestEntry(testDir, { + hash: "abc", shortHash: "abc1234", timestamp: new Date().toISOString(), + type: "manual", trigger: "test", reason: "valid", + filesCopied: [], filesSkipped: [], filesRedacted: [], + }); + const result = await validateManifest(testDir); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("getLastSnapshot returns null for empty manifest", async () => { + const { getLastSnapshot } = await import("../src/engine/manifest.js"); + expect(await getLastSnapshot(testDir)).toBeNull(); + }); + + it("getLastSnapshot returns latest entry", async () => { + const { appendManifestEntry, getLastSnapshot } = await import("../src/engine/manifest.js"); + await appendManifestEntry(testDir, { + hash: "first", shortHash: "first1", timestamp: new Date().toISOString(), + type: "manual", trigger: "test", reason: "first", + filesCopied: [], filesSkipped: [], filesRedacted: [], + }); + await appendManifestEntry(testDir, { + hash: "second", shortHash: "secon1", timestamp: new Date().toISOString(), + type: "manual", trigger: "test", reason: "second", + filesCopied: [], filesSkipped: [], filesRedacted: [], + }); + const last = await getLastSnapshot(testDir); + expect(last!.hash).toBe("second"); + }); + + it("getSnapshotByHash finds by full hash", async () => { + const { appendManifestEntry, getSnapshotByHash } = await import("../src/engine/manifest.js"); + await appendManifestEntry(testDir, { + hash: "abcdef123456", shortHash: "abc1234", timestamp: new Date().toISOString(), + type: "session-start", trigger: "test", reason: "testing", + filesCopied: [], filesSkipped: [], filesRedacted: [], + }); + expect((await getSnapshotByHash(testDir, "abcdef123456"))!.type).toBe("session-start"); + }); + + it("getSnapshotByHash finds by short hash", async () => { + const { appendManifestEntry, getSnapshotByHash } = await import("../src/engine/manifest.js"); + await appendManifestEntry(testDir, { + hash: "abcdef123456", shortHash: "abc1234", timestamp: new Date().toISOString(), + type: "pre-op", trigger: "test", reason: "testing", + filesCopied: [], filesSkipped: [], filesRedacted: [], + }); + expect((await getSnapshotByHash(testDir, "abc1234"))!.type).toBe("pre-op"); + }); + + it("getSnapshotByHash returns null for missing", async () => { + const { getSnapshotByHash } = await import("../src/engine/manifest.js"); + expect(await getSnapshotByHash(testDir, "nonexistent")).toBeNull(); + }); + + it("clearManifest empties the file", async () => { + const { appendManifestEntry, clearManifest, readManifest } = await import("../src/engine/manifest.js"); + await appendManifestEntry(testDir, { + hash: "abc", shortHash: "abc1234", timestamp: new Date().toISOString(), + type: "manual", trigger: "test", reason: "test", + filesCopied: [], filesSkipped: [], filesRedacted: [], + }); + await clearManifest(testDir); + expect(await readManifest(testDir)).toEqual([]); + }); + + it("rebuildManifestFromGit returns empty for missing repo", async () => { + const { rebuildManifestFromGit } = await import("../src/engine/manifest.js"); + const entries = await rebuildManifestFromGit(testDir); + expect(entries).toEqual([]); + }); +}); diff --git a/tests/packaging.test.ts b/tests/packaging.test.ts new file mode 100644 index 0000000..5947884 --- /dev/null +++ b/tests/packaging.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; +import { spawnSync } from "child_process"; + +const ROOT = path.join(import.meta.dir, ".."); +const CLI_PATH = path.join(ROOT, "dist", "cli", "index.js"); + +describe("packaging and version", () => { + it("reports the version from package.json", async () => { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf-8")); + const { getPackageVersion } = await import("../src/shared/version.js"); + expect(getPackageVersion()).toBe(pkg.version); + expect(pkg.version).toBe("0.3.0"); + }); + + it("the built package entrypoint exports a plugin definition", async () => { + const mod = await import("../dist/index.js"); + expect(mod.default.id).toBe("gitdamnit.git-that-shit"); + }); + + it("the CLI runs without OpenCode and prints its version", () => { + const result = spawnSync("node", [CLI_PATH, "version"], { encoding: "utf-8" }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("0.3.0"); + }); + + it("the CLI exposes every documented command in help", () => { + const result = spawnSync("node", [CLI_PATH, "--help"], { encoding: "utf-8" }); + for (const cmd of [ + "gts status", + "gts list", + "gts diff", + "gts restore", + "gts snapshot", + "gts prune", + "gts config", + "gts init", + "gts doctor", + "gts version", + ]) { + expect(result.stdout).toContain(cmd); + } + }); + + it( + "npm pack includes dist and excludes tests", + () => { + const result = spawnSync("npm", ["pack", "--dry-run", "--json"], { + cwd: ROOT, + encoding: "utf-8", + }); + + if (result.status !== 0) { + throw new Error(`npm pack failed: ${result.stderr}`); + } + + const parsed = JSON.parse(result.stdout); + const files: Array<{ path: string }> = parsed[0]?.files ?? []; + const paths = files.map((f) => f.path); + + expect(paths).toContain("dist/index.js"); + expect(paths.some((p) => p.startsWith("dist/cli/"))).toBe(true); + expect(paths.some((p) => p.startsWith("tests/"))).toBe(false); + expect(paths.some((p) => p.startsWith("src/"))).toBe(false); + }, + 60000 + ); +}); diff --git a/tests/paths.test.ts b/tests/paths.test.ts new file mode 100644 index 0000000..0c83744 --- /dev/null +++ b/tests/paths.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; + +const MODULE_PATH = "../src/shared/paths.js"; + +describe("paths", () => { + const testDir = path.join("/tmp", "gts-paths-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("resolveProjectRoot returns cwd when no markers found", async () => { + const { resolveProjectRoot } = await import(MODULE_PATH); + const result = resolveProjectRoot("/nonexistent"); + expect(result).toBe("/nonexistent"); + }); + + it("resolveProjectRoot finds .git-that-shit marker", async () => { + mkdirSync(path.join(testDir, "sub", "nested"), { recursive: true }); + mkdirSync(path.join(testDir, ".git-that-shit")); + const { resolveProjectRoot } = await import(MODULE_PATH); + const result = resolveProjectRoot(path.join(testDir, "sub", "nested")); + expect(result).toBe(testDir); + }); + + it("resolveProjectRoot finds .git marker", async () => { + mkdirSync(path.join(testDir, "sub"), { recursive: true }); + mkdirSync(path.join(testDir, ".git")); + const { resolveProjectRoot } = await import(MODULE_PATH); + const result = resolveProjectRoot(path.join(testDir, "sub")); + expect(result).toBe(testDir); + }); + + it("resolveProjectRoot finds package.json marker", async () => { + writeFileSync(path.join(testDir, "package.json"), "{}"); + const { resolveProjectRoot } = await import(MODULE_PATH); + expect(resolveProjectRoot(testDir)).toBe(testDir); + }); + + it("getShadowRepoPath returns correct path", async () => { + const { getShadowRepoPath } = await import(MODULE_PATH); + const result = getShadowRepoPath("/project"); + expect(result).toBe("/project/.git-that-shit/git-that-shit/snapshots"); + }); + + it("getConfigPath returns correct path", async () => { + const { getConfigPath } = await import(MODULE_PATH); + expect(getConfigPath("/project")).toBe("/project/.git-that-shit/git-that-shit/config.json"); + }); + + it("getLockPath returns correct path", async () => { + const { getLockPath } = await import(MODULE_PATH); + expect(getLockPath("/project")).toBe("/project/.git-that-shit/git-that-shit/snapshots/.gts.lock"); + }); + + it("getManifestPath returns correct path", async () => { + const { getManifestPath } = await import(MODULE_PATH); + expect(getManifestPath("/project")).toBe("/project/.git-that-shit/git-that-shit/snapshots/.git-that-shit/manifest.jsonl"); + }); + + it("getGlobalConfigPath returns path in home dir", async () => { + const { getGlobalConfigPath } = await import(MODULE_PATH); + // Be robust to another test file having set an isolation override: this + // assertion is specifically about the *default* path. + const savedOverride = process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; + const savedXdg = process.env.XDG_CONFIG_HOME; + delete process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; + delete process.env.XDG_CONFIG_HOME; + try { + const home = require("os").homedir(); + expect(getGlobalConfigPath()).toBe(path.join(home, ".config", "opencode", "config.json")); + } finally { + if (savedOverride === undefined) delete process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; + else process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = savedOverride; + if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = savedXdg; + } + }); + + it("ensureDir creates directory", async () => { + const dir = path.join(testDir, "a", "b", "c"); + const { ensureDir } = await import(MODULE_PATH); + ensureDir(dir); + expect(existsSync(dir)).toBe(true); + }); + + it("ensureDir does not throw on existing dir", async () => { + const { ensureDir } = await import(MODULE_PATH); + ensureDir(testDir); + expect(existsSync(testDir)).toBe(true); + }); + + it("fileExists returns true for existing file", async () => { + const f = path.join(testDir, "exists.txt"); + writeFileSync(f, "hello"); + const { fileExists } = await import(MODULE_PATH); + expect(fileExists(f)).toBe(true); + }); + + it("fileExists returns false for missing file", async () => { + const { fileExists } = await import(MODULE_PATH); + expect(fileExists("/nonexistent")).toBe(false); + }); + + it("readFile returns null for missing file", async () => { + const { readFile } = await import(MODULE_PATH); + expect(readFile("/nonexistent")).toBeNull(); + }); + + it("readFile returns content for existing file", async () => { + const f = path.join(testDir, "content.txt"); + writeFileSync(f, "hello world"); + const { readFile } = await import(MODULE_PATH); + expect(readFile(f)).toBe("hello world"); + }); + + it("writeFile creates file with content", async () => { + const f = path.join(testDir, "newfile.txt"); + const { writeFile } = await import(MODULE_PATH); + const result = writeFile(f, "test content"); + expect(result).toBe(true); + expect(fs.readFileSync(f, "utf-8")).toBe("test content"); + }); + + it("copyFile copies content", async () => { + const src = path.join(testDir, "src.txt"); + const dst = path.join(testDir, "dst.txt"); + writeFileSync(src, "copy me"); + const { copyFile } = await import(MODULE_PATH); + expect(copyFile(src, dst)).toBe(true); + expect(fs.readFileSync(dst, "utf-8")).toBe("copy me"); + }); + + it("copyFile returns false for missing source", async () => { + const { copyFile } = await import(MODULE_PATH); + expect(copyFile("/nonexistent", "/tmp/nope")).toBe(false); + }); + + it("deleteFile removes file", async () => { + const f = path.join(testDir, "todelete.txt"); + writeFileSync(f, "bye"); + const { deleteFile } = await import(MODULE_PATH); + expect(deleteFile(f)).toBe(true); + expect(existsSync(f)).toBe(false); + }); + + it("deleteFile returns false for missing file", async () => { + const { deleteFile } = await import(MODULE_PATH); + expect(deleteFile("/nonexistent")).toBe(false); + }); + + it("getProjectConfigPaths returns all paths", async () => { + const { getProjectConfigPaths } = await import(MODULE_PATH); + const paths = getProjectConfigPaths(testDir); + expect(paths.length).toBeGreaterThan(5); + expect(paths.some((p: string) => p.includes("package.json"))).toBe(true); + expect(paths.some((p: string) => p.includes(".git-that-shit"))).toBe(true); + expect(paths.some((p: string) => p.includes(".opencode/state"))).toBe(true); + }); + + it("normalizePath normalizes slashes", async () => { + const { normalizePath } = await import(MODULE_PATH); + expect(normalizePath("/a/b/../c")).toBe(path.normalize("/a/b/../c")); + }); + + it("getFileSize returns 0 for missing file", async () => { + const { getFileSize } = await import(MODULE_PATH); + expect(getFileSize("/nonexistent")).toBe(0); + }); + + it("getFileSize returns correct size", async () => { + const f = path.join(testDir, "sized.txt"); + writeFileSync(f, "12345"); + const { getFileSize } = await import(MODULE_PATH); + expect(getFileSize(f)).toBe(5); + }); + + it("isFile returns true for files", async () => { + const f = path.join(testDir, "afile.txt"); + writeFileSync(f, ""); + const { isFile } = await import(MODULE_PATH); + expect(isFile(f)).toBe(true); + expect(isFile(testDir)).toBe(false); + }); + + it("listFiles returns directory contents", async () => { + writeFileSync(path.join(testDir, "a.txt"), ""); + writeFileSync(path.join(testDir, "b.txt"), ""); + const { listFiles } = await import(MODULE_PATH); + const files = listFiles(testDir); + expect(files).toContain("a.txt"); + expect(files).toContain("b.txt"); + }); + + it("listFiles returns empty for missing dir", async () => { + const { listFiles } = await import(MODULE_PATH); + expect(listFiles("/nonexistent")).toEqual([]); + }); +}); diff --git a/tests/permissions.test.ts b/tests/permissions.test.ts new file mode 100644 index 0000000..540ad3c --- /dev/null +++ b/tests/permissions.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; +import { spawnSync } from "child_process"; + +// Hermetic: never read the developer's real global OpenCode config. +const GLOBAL_CONFIG_ENV_PREV = process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = path.join("/tmp", "gts-no-global-perms"); +// Restore the environment so this file never leaks isolation into another test file. +afterAll(() => { + if (GLOBAL_CONFIG_ENV_PREV === undefined) delete process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; + else process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = GLOBAL_CONFIG_ENV_PREV; +}); + +const posix = process.platform !== "win32"; +const mode = (p: string): number => fs.statSync(p).mode & 0o777; + +describe("filesystem permission hardening", () => { + const root = path.join("/tmp", "gts-perms-" + Date.now()); + + const makeProject = (name: string): string => { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + spawnSync("git", ["init"], { cwd: dir }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version: "1.0.0" })); + return dir; + }; + + beforeEach(() => { + fs.mkdirSync(root, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("restricts Git That Shit storage directories to 0700", async () => { + if (!posix) return; + const { snapshot } = await import("../src/engine/snapshot.js"); + const { getProjectStateDir, getShadowRepoPath } = await import("../src/shared/paths.js"); + const project = makeProject("dirs"); + + await snapshot({ projectRoot: project, reason: "perm", type: "manual", trigger: "test" }); + + expect(mode(getProjectStateDir(project))).toBe(0o700); + expect(mode(getShadowRepoPath(project))).toBe(0o700); + expect(mode(path.join(getShadowRepoPath(project), ".git-that-shit"))).toBe(0o700); + }); + + it("writes the manifest, config and snapshot copies with 0600", async () => { + if (!posix) return; + const { snapshot } = await import("../src/engine/snapshot.js"); + const { getManifestPath, getShadowRepoPath, getConfigPath } = await import( + "../src/shared/paths.js" + ); + const { saveConfig } = await import("../src/engine/config.js"); + const { DEFAULT_CONFIG } = await import("../src/engine/config.js"); + const project = makeProject("files"); + + await saveConfig(project, DEFAULT_CONFIG); + await snapshot({ projectRoot: project, reason: "perm", type: "manual", trigger: "test" }); + + expect(mode(getConfigPath(project))).toBe(0o600); + expect(mode(getManifestPath(project))).toBe(0o600); + expect(mode(path.join(getShadowRepoPath(project), "package.json"))).toBe(0o600); + }); + + it("creates the lock file with 0600", async () => { + if (!posix) return; + const { acquireLock, releaseLock } = await import("../src/engine/lock.js"); + const { getLockPath } = await import("../src/shared/paths.js"); + const project = makeProject("lock"); + + await acquireLock(project, "perm", "test"); + try { + expect(mode(getLockPath(project))).toBe(0o600); + } finally { + await releaseLock(project); + } + }); + + it("writes pre-prune safety bundles with 0600", async () => { + if (!posix) return; + const { snapshot } = await import("../src/engine/snapshot.js"); + const { pruneSnapshots } = await import("../src/engine/prune.js"); + const project = makeProject("prune"); + + fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "p", v: 1 })); + await snapshot({ projectRoot: project, reason: "one", type: "manual", trigger: "test" }); + fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "p", v: 2 })); + await snapshot({ projectRoot: project, reason: "two", type: "manual", trigger: "test" }); + + const result = await pruneSnapshots(project, 1, true); + expect(result.performed).toBe(true); + expect(result.backupPath).toBeDefined(); + expect(mode(result.backupPath!)).toBe(0o600); + }); + + it("never chmods user project files or unrelated directories", async () => { + if (!posix) return; + const { snapshot } = await import("../src/engine/snapshot.js"); + const project = makeProject("untouched"); + + // A plausible user layout with default (world-readable) permissions. + const srcDir = path.join(project, "src"); + fs.mkdirSync(srcDir, { recursive: true }); + const userFile = path.join(srcDir, "app.ts"); + fs.writeFileSync(userFile, "export const x = 1;\n"); + fs.chmodSync(srcDir, 0o755); + fs.chmodSync(userFile, 0o644); + fs.chmodSync(path.join(project, "package.json"), 0o644); + + await snapshot({ projectRoot: project, reason: "perm", type: "manual", trigger: "test" }); + + expect(mode(srcDir)).toBe(0o755); + expect(mode(userFile)).toBe(0o644); + expect(mode(path.join(project, "package.json"))).toBe(0o644); + }); + + it("applies restrictive modes to newly created private files", async () => { + if (!posix) return; + const { writePrivateFileSync, writePrivateFile, ensurePrivateDir } = await import( + "../src/shared/permissions.js" + ); + const dir = path.join(root, "private"); + ensurePrivateDir(dir); + const syncFile = path.join(dir, "sync.json"); + const asyncFile = path.join(dir, "async.json"); + expect(writePrivateFileSync(syncFile, "{}")).toBe(true); + await writePrivateFile(asyncFile, "{}"); + expect(mode(dir)).toBe(0o700); + expect(mode(syncFile)).toBe(0o600); + expect(mode(asyncFile)).toBe(0o600); + }); + + it("doctor reports storage permissions and flags group/other access", async () => { + if (!posix) return; + const { snapshot } = await import("../src/engine/snapshot.js"); + const { runDoctor } = await import("../src/engine/doctor.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + const project = makeProject("doctor"); + + await snapshot({ projectRoot: project, reason: "perm", type: "manual", trigger: "test" }); + + let report = await runDoctor(project); + let check = report.checks.find((c) => c.name === "storage-permissions"); + expect(check?.status).toBe("ok"); + + // Widen the shadow repo and confirm the diagnostic notices. + fs.chmodSync(getShadowRepoPath(project), 0o755); + report = await runDoctor(project); + check = report.checks.find((c) => c.name === "storage-permissions"); + expect(check?.status).toBe("warn"); + }); +}); diff --git a/tests/plugin-strict-mode.test.ts b/tests/plugin-strict-mode.test.ts new file mode 100644 index 0000000..2a0be48 --- /dev/null +++ b/tests/plugin-strict-mode.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; + +const GLOBAL_CONFIG_ENV_PREV = process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = path.join("/tmp", "gts-no-global-strict"); +// Restore the environment so this file never leaks isolation into another test file. +afterAll(() => { + if (GLOBAL_CONFIG_ENV_PREV === undefined) delete process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; + else process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = GLOBAL_CONFIG_ENV_PREV; +}); + +const DIST_INDEX = path.join(import.meta.dir, "..", "dist", "index.js"); + +function makeEventStream() { + const queue: unknown[] = []; + let resolveNext: ((r: IteratorResult) => void) | null = null; + let closed = false; + return { + push(event: unknown) { + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r({ done: false, value: event }); + } else { + queue.push(event); + } + }, + close() { + closed = true; + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r({ done: true, value: undefined }); + } + }, + iterable(signal?: AbortSignal) { + return { + [Symbol.asyncIterator]() { + return { + next(): Promise> { + if (signal?.aborted) return Promise.resolve({ done: true, value: undefined }); + if (queue.length) return Promise.resolve({ done: false, value: queue.shift() }); + if (closed) return Promise.resolve({ done: true, value: undefined }); + return new Promise((res) => { + resolveNext = res; + }); + }, + }; + }, + }; + }, + }; +} + +interface Harness { + ctx: any; + tools: Map; + hooks: Map Promise | void>; + permissionHooks: Map void>; +} + +function makeHarness(projectDir: string, withPermission: boolean): Harness { + const tools = new Map(); + const hooks = new Map Promise | void>(); + const permissionHooks = new Map void>(); + + const editor = { + namespace() {}, + add(tool: any) { + tools.set(tool.name, tool); + }, + list: () => [...tools.values()], + get: (id: string) => tools.get(id), + update() {}, + remove() {}, + }; + + const ctx: any = { + app: { name: "opencode", version: "2.0.21", channel: "test" }, + location: { + directory: projectDir, + project: { id: "proj", directory: projectDir, canonical: projectDir }, + }, + options: {}, + tool: { + async hook(name: string, cb: any) { + hooks.set(name, cb); + return { dispose: async () => {} }; + }, + async transform(cb: (editor: any) => void) { + cb(editor); + return { dispose: async () => {} }; + }, + async list() { + return [...tools.values()]; + }, + async reload() {}, + }, + event: { + subscribe(options?: { signal?: AbortSignal }) { + return makeEventStream().iterable(options?.signal); + }, + }, + storage: { async get() {}, async set() {}, async remove() {} }, + }; + + if (withPermission) { + ctx.permission = { + async hook(name: string, cb: (evaluation: any) => void) { + permissionHooks.set(name, cb); + return { dispose: async () => {} }; + }, + }; + } + + return { ctx, tools, hooks, permissionHooks }; +} + +async function configure(project: string, failureMode: "standard" | "strict", lowDisk: boolean) { + const { DEFAULT_CONFIG, saveConfig } = await import("../src/engine/config.js"); + await saveConfig(project, { + ...DEFAULT_CONFIG, + snapshot: { + ...DEFAULT_CONFIG.snapshot, + failureMode, + minDiskSpaceMb: lowDisk ? Number.MAX_SAFE_INTEGER : DEFAULT_CONFIG.snapshot.minDiskSpaceMb, + }, + }); +} + +const destructiveEvent = (id: string) => ({ + tool: "bash", + input: { command: "git reset --hard HEAD~1" }, + id, + sessionID: "s1", +}); + +describe("plugin strict mode", () => { + const root = path.join("/tmp", "gts-strict-" + Date.now()); + + const makeProject = (name: string): string => { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version: "1.0.0" })); + return dir; + }; + + beforeEach(() => { + fs.mkdirSync(root, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("denies exactly the tool call whose protective snapshot failed", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("strict-deny"); + await configure(project, "strict", true); + const { ctx, hooks, permissionHooks } = makeHarness(project, true); + + const cleanup = await mod.default.setup(ctx); + try { + await hooks.get("execute.before")!(destructiveEvent("call-1")); + + const evaluation: any = { source: { type: "tool", id: "call-1" }, effect: "allow" }; + permissionHooks.get("evaluate")!(evaluation); + + expect(evaluation.effect).toBe("deny"); + expect(String(evaluation.message)).toContain("strict mode"); + } finally { + await cleanup?.(); + } + }, 20000); + + it("does not block harmless commands in strict mode", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("strict-safe"); + await configure(project, "strict", true); + const { ctx, hooks, permissionHooks } = makeHarness(project, true); + + const cleanup = await mod.default.setup(ctx); + try { + await hooks.get("execute.before")!({ + tool: "bash", + input: { command: "ls -la" }, + id: "call-safe", + sessionID: "s1", + }); + + const evaluation: any = { source: { type: "tool", id: "call-safe" }, effect: "allow" }; + permissionHooks.get("evaluate")!(evaluation); + expect(evaluation.effect).toBe("allow"); + } finally { + await cleanup?.(); + } + }, 20000); + + it("does not block when the protective snapshot succeeds", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("strict-ok"); + await configure(project, "strict", false); + const { ctx, hooks, permissionHooks } = makeHarness(project, true); + + const cleanup = await mod.default.setup(ctx); + try { + await hooks.get("execute.before")!(destructiveEvent("call-ok")); + + const evaluation: any = { source: { type: "tool", id: "call-ok" }, effect: "allow" }; + permissionHooks.get("evaluate")!(evaluation); + expect(evaluation.effect).toBe("allow"); + } finally { + await cleanup?.(); + } + }, 20000); + + it("never blocks in standard mode even when the snapshot fails", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("standard"); + await configure(project, "standard", true); + const { ctx, hooks, permissionHooks } = makeHarness(project, true); + + const cleanup = await mod.default.setup(ctx); + try { + await hooks.get("execute.before")!(destructiveEvent("call-std")); + + const evaluation: any = { source: { type: "tool", id: "call-std" }, effect: "allow" }; + permissionHooks.get("evaluate")!(evaluation); + expect(evaluation.effect).toBe("allow"); + } finally { + await cleanup?.(); + } + }, 20000); + + it("fails open and warns when the SDK exposes no pre-execution gate", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("no-permission"); + await configure(project, "strict", true); + const { ctx, hooks } = makeHarness(project, false); + + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }; + + const cleanup = await mod.default.setup(ctx); + try { + // Must not throw even though blocking is impossible. + await hooks.get("execute.before")!(destructiveEvent("call-np")); + expect(warnings.join("\n")).toContain("NOT be blocked"); + } finally { + console.warn = originalWarn; + await cleanup?.(); + } + }, 20000); + + it("forgets a denial once the tool call finishes", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("forget"); + await configure(project, "strict", true); + const { ctx, hooks, permissionHooks } = makeHarness(project, true); + + const cleanup = await mod.default.setup(ctx); + try { + await hooks.get("execute.before")!(destructiveEvent("call-2")); + const denial: any = { source: { type: "tool", id: "call-2" }, effect: "allow" }; + permissionHooks.get("evaluate")!(denial); + expect(denial.effect).toBe("deny"); + + await hooks.get("execute.after")!({ + tool: "bash", + status: "completed", + id: "call-2", + sessionID: "s1", + }); + + const later: any = { source: { type: "tool", id: "call-2" }, effect: "allow" }; + permissionHooks.get("evaluate")!(later); + expect(later.effect).toBe("allow"); + } finally { + await cleanup?.(); + } + }, 20000); + + it("treats an unchanged tree as an existing recovery point, not a failure", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("no-changes"); + await configure(project, "strict", false); + const { ctx, hooks, permissionHooks } = makeHarness(project, true); + + const cleanup = await mod.default.setup(ctx); + try { + // First call creates the snapshot; the second sees an unchanged tree. + await hooks.get("execute.before")!(destructiveEvent("call-first")); + await hooks.get("execute.before")!(destructiveEvent("call-second")); + + const evaluation: any = { source: { type: "tool", id: "call-second" }, effect: "allow" }; + permissionHooks.get("evaluate")!(evaluation); + expect(evaluation.effect).toBe("allow"); + } finally { + await cleanup?.(); + } + }, 20000); +}); diff --git a/tests/plugin-v2.test.ts b/tests/plugin-v2.test.ts new file mode 100644 index 0000000..094393b --- /dev/null +++ b/tests/plugin-v2.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; +import { spawnSync } from "child_process"; + +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = path.join("/tmp", "gts-no-global-plugin"); + +const DIST_INDEX = path.join(import.meta.dir, "..", "dist", "index.js"); + +interface MockStream { + push: (event: unknown) => void; + close: () => void; + iterable: (signal?: AbortSignal) => AsyncIterable; +} + +function makeEventStream(): MockStream { + const queue: unknown[] = []; + let resolveNext: ((r: IteratorResult) => void) | null = null; + let closed = false; + + return { + push(event: unknown) { + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r({ done: false, value: event }); + } else { + queue.push(event); + } + }, + close() { + closed = true; + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r({ done: true, value: undefined }); + } + }, + iterable(signal?: AbortSignal) { + return { + [Symbol.asyncIterator]() { + return { + next(): Promise> { + if (signal?.aborted) return Promise.resolve({ done: true, value: undefined }); + if (queue.length) return Promise.resolve({ done: false, value: queue.shift() }); + if (closed) return Promise.resolve({ done: true, value: undefined }); + return new Promise((res) => { + resolveNext = res; + }); + }, + }; + }, + }; + }, + }; +} + +interface MockHarness { + ctx: any; + tools: Map; + hooks: Map Promise | void>; + stream: MockStream; +} + +function makeHarness(projectDir: string): MockHarness { + const tools = new Map(); + const hooks = new Map Promise | void>(); + const stream = makeEventStream(); + + const editor = { + namespace() {}, + add(tool: any) { + tools.set(tool.name, tool); + }, + list: () => [...tools.values()], + get: (id: string) => tools.get(id), + update() {}, + remove() {}, + }; + + const ctx = { + app: { name: "opencode", version: "2.0.21", channel: "test" }, + location: { + directory: projectDir, + project: { id: "proj", directory: projectDir, canonical: projectDir }, + }, + options: {}, + tool: { + async hook(name: string, cb: any) { + hooks.set(name, cb); + return { dispose: async () => {} }; + }, + async transform(cb: (editor: any) => void) { + cb(editor); + return { dispose: async () => {} }; + }, + async list() { + return [...tools.values()]; + }, + async reload() {}, + }, + event: { + subscribe(options?: { signal?: AbortSignal }) { + return stream.iterable(options?.signal); + }, + }, + storage: { + async get() { + return undefined; + }, + async set() {}, + async remove() {}, + }, + }; + + return { ctx, tools, hooks, stream }; +} + +const tick = (ms = 50) => new Promise((r) => setTimeout(r, ms)); + +describe("OpenCode V2 plugin", () => { + const root = path.join("/tmp", "gts-plugin-" + Date.now()); + + const makeProject = (name: string): string => { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version: "1.0.0" })); + return dir; + }; + + beforeEach(() => { + fs.mkdirSync(root, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("exposes a Plugin.define definition with a stable id", async () => { + const mod = await import(DIST_INDEX); + const plugin = mod.default; + expect(plugin.id).toBe("gitdamnit.git-that-shit"); + expect(typeof plugin.setup).toBe("function"); + }); + + it("registers all eight tools with their public names", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("tools"); + const { ctx, tools } = makeHarness(project); + + const cleanup = await mod.default.setup(ctx); + try { + const names = [...tools.keys()].sort(); + expect(names).toEqual( + [ + "gts_config", + "gts_diff", + "gts_doctor", + "gts_list", + "gts_prune", + "gts_restore", + "gts_snapshot", + "gts_status", + ].sort() + ); + + // Every tool declares a JSON-schema object input. + for (const tool of tools.values()) { + expect(tool.input.type).toBe("object"); + expect(typeof tool.execute).toBe("function"); + } + } finally { + await cleanup?.(); + } + }); + + it("gts_status and gts_snapshot tools execute against the project", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("tool-exec"); + const { ctx, tools } = makeHarness(project); + + const cleanup = await mod.default.setup(ctx); + try { + const snap = await tools.get("gts_snapshot").execute({ message: "tool snapshot" }, {}); + const snapResult = JSON.parse(snap.content); + expect(snapResult.committed).toBe(true); + + const status = await tools.get("gts_status").execute({}, {}); + const statusResult = JSON.parse(status.content); + expect(statusResult.snapshotCount).toBeGreaterThanOrEqual(1); + expect(statusResult.version).toMatch(/^\d+\.\d+\.\d+/); + } finally { + await cleanup?.(); + } + }); + + it("registers execute.before and execute.after tool hooks", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("hook-reg"); + const { ctx, hooks } = makeHarness(project); + + const cleanup = await mod.default.setup(ctx); + try { + expect(hooks.has("execute.before")).toBe(true); + expect(hooks.has("execute.after")).toBe(true); + } finally { + await cleanup?.(); + } + }); + + it("records a post-edit snapshot from execute.after", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("postedit"); + const { ctx, hooks } = makeHarness(project); + const { readManifest } = await import("../src/engine/manifest.js"); + + const cleanup = await mod.default.setup(ctx); + try { + const after = hooks.get("execute.after")!; + await after({ + tool: "write", + status: "completed", + input: { path: "opencode.json" }, + sessionID: "s1", + }); + + const entries = await readManifest(project); + expect(entries.length).toBeGreaterThanOrEqual(1); + expect(entries[entries.length - 1].type).toBe("post-edit"); + } finally { + await cleanup?.(); + } + }); + + it("does not snapshot after a failed edit tool", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("postedit-fail"); + const { ctx, hooks } = makeHarness(project); + const { readManifest } = await import("../src/engine/manifest.js"); + + const cleanup = await mod.default.setup(ctx); + try { + await hooks.get("execute.after")!({ + tool: "write", + status: "error", + input: { path: "opencode.json" }, + sessionID: "s1", + }); + const entries = await readManifest(project); + expect(entries.length).toBe(0); + } finally { + await cleanup?.(); + } + }); + + it("snapshots before a destructive shell command but not a safe one", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("destructive"); + const { ctx, hooks } = makeHarness(project); + const { readManifest } = await import("../src/engine/manifest.js"); + + const cleanup = await mod.default.setup(ctx); + try { + const before = hooks.get("execute.before")!; + + await before({ tool: "bash", input: { command: "git reset --hard HEAD~1" }, sessionID: "s1" }); + const afterDestructive = await readManifest(project); + expect(afterDestructive.length).toBeGreaterThanOrEqual(1); + expect(afterDestructive[afterDestructive.length - 1].type).toBe("pre-op"); + + await before({ tool: "bash", input: { command: "ls -la" }, sessionID: "s1" }); + const afterSafe = await readManifest(project); + expect(afterSafe.length).toBe(afterDestructive.length); + } finally { + await cleanup?.(); + } + }); + + it("records a session-start snapshot from the event stream", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("events"); + const { ctx, stream } = makeHarness(project); + const { readManifest } = await import("../src/engine/manifest.js"); + + const cleanup = await mod.default.setup(ctx); + try { + stream.push({ type: "session.created", data: { sessionID: "sess-1" } }); + + // Poll until the detached event loop has processed the event. + let entries: any[] = []; + for (let i = 0; i < 100; i++) { + await tick(50); + entries = await readManifest(project); + if (entries.length > 0) break; + } + + expect(entries.length).toBeGreaterThanOrEqual(1); + expect(entries[entries.length - 1].type).toBe("session-start"); + } finally { + await cleanup?.(); + } + }); + + it("stops consuming events after cleanup", async () => { + const mod = await import(DIST_INDEX); + const project = makeProject("cleanup"); + const { ctx, hooks } = makeHarness(project); + + const cleanup = await mod.default.setup(ctx); + expect(typeof cleanup).toBe("function"); + await cleanup?.(); + + // The tool hook registration remains valid, but the stream was aborted; no + // unhandled errors should surface. + expect(hooks.has("execute.before")).toBe(true); + }); +}); diff --git a/tests/prune.test.ts b/tests/prune.test.ts new file mode 100644 index 0000000..4e9bc99 --- /dev/null +++ b/tests/prune.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; +import { spawnSync } from "child_process"; + +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = path.join("/tmp", "gts-no-global-prune"); + +describe("prune", () => { + const testDir = path.join("/tmp", "gts-prune-" + Date.now()); + + const snapshotN = async (version: string) => { + const { snapshot } = await import("../src/engine/snapshot.js"); + fs.writeFileSync( + path.join(testDir, "package.json"), + JSON.stringify({ name: "test", version }) + ); + return snapshot({ projectRoot: testDir, reason: `v${version}`, type: "manual", trigger: "test" }); + }; + + beforeEach(() => { + fs.mkdirSync(testDir, { recursive: true }); + spawnSync("git", ["init"], { cwd: testDir }); + }); + + afterEach(() => { + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it("preview reports what would be pruned without mutating", async () => { + for (let i = 1; i <= 5; i++) await snapshotN(`${i}.0.0`); + const { previewPrune } = await import("../src/engine/prune.js"); + + const plan = await previewPrune(testDir, 2); + expect(plan.totalSnapshots).toBe(5); + expect(plan.pruneCount).toBe(3); + expect(plan.toPrune.length).toBe(3); + + // Nothing changed. + const again = await previewPrune(testDir, 2); + expect(again.totalSnapshots).toBe(5); + }); + + it("does not prune without confirmation", async () => { + for (let i = 1; i <= 4; i++) await snapshotN(`${i}.0.0`); + const { pruneSnapshots } = await import("../src/engine/prune.js"); + + const result = await pruneSnapshots(testDir, 2, false); + expect(result.performed).toBe(false); + + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + const count = spawnSync("git", ["rev-list", "--count", "HEAD"], { + cwd: getShadowRepoPath(testDir), + encoding: "utf-8", + }); + expect(Number(count.stdout.trim())).toBe(4); + }); + + it("prunes old snapshots and keeps the newest N", async () => { + const hashes: string[] = []; + for (let i = 1; i <= 5; i++) { + const r = await snapshotN(`${i}.0.0`); + hashes.push(r.hash!); + } + + const { pruneSnapshots } = await import("../src/engine/prune.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + const { readManifest } = await import("../src/engine/manifest.js"); + + const result = await pruneSnapshots(testDir, 2, true); + expect(result.performed).toBe(true); + expect(result.pruneCount).toBe(3); + expect(result.backupPath && fs.existsSync(result.backupPath)).toBe(true); + + const shadow = getShadowRepoPath(testDir); + const count = spawnSync("git", ["rev-list", "--count", "HEAD"], { + cwd: shadow, + encoding: "utf-8", + }); + expect(Number(count.stdout.trim())).toBe(2); + + const manifest = await readManifest(testDir); + expect(manifest.length).toBe(2); + + // Every remaining entry references a real commit... + for (const entry of manifest) { + const check = spawnSync("git", ["cat-file", "-e", entry.hash], { cwd: shadow }); + expect(check.status).toBe(0); + } + + // ...and the newest original snapshot's content is preserved at the tip. + const tip = spawnSync("git", ["show", "HEAD:package.json"], { cwd: shadow, encoding: "utf-8" }); + expect(JSON.parse(tip.stdout).version).toBe("5.0.0"); + }); +}); diff --git a/tests/redactor.test.ts b/tests/redactor.test.ts new file mode 100644 index 0000000..77d2082 --- /dev/null +++ b/tests/redactor.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; + +const MODULE = "../src/engine/redactor.js"; + +describe("Redactor", () => { + const testDir = path.join("/tmp", "gts-redact-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("does not redact non-JSON files", async () => { + const { createRedactor } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const { DEFAULT_CONFIG } = await import("../src/engine/config.js"); + const tracker = createTracker(DEFAULT_CONFIG); + const redactor = createRedactor(tracker); + const f = path.join(testDir, "test.txt"); + writeFileSync(f, "hello world"); + const result = await redactor.redactFile(f); + expect(result.redacted).toBe(false); + expect(result.content).toBe("hello world"); + }); + + it("redacts secret keys in JSON", async () => { + const { createRedactor } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: [], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: ["apiKey", "token", "password"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + const redactor = createRedactor(tracker); + const f = path.join(testDir, "config.json"); + writeFileSync(f, JSON.stringify({ apiKey: "sk-1234567890", name: "test" })); + const result = await redactor.redactFile(f); + expect(result.redacted).toBe(true); + const parsed = JSON.parse(result.content); + expect(parsed.apiKey).toBe("[REDACTED]"); + expect(parsed.name).toBe("test"); + }); + + it("redacts deeply nested secret keys", async () => { + const { createRedactor } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: [], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: ["token", "secret"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + const redactor = createRedactor(tracker); + const obj = { auth: { token: "abc123", nested: { clientSecret: "shh" } }, public: "ok" }; + const result = redactor.redactJson(JSON.stringify(obj)); + expect(result.redacted).toBe(true); + const parsed = JSON.parse(result.content); + expect(parsed.auth.token).toBe("[REDACTED]"); + expect(parsed.public).toBe("ok"); + }); + + it("redacts secrets in arrays", async () => { + const { createRedactor } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: [], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: ["token"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + const redactor = createRedactor(tracker); + const obj = { items: [{ token: "secret1" }, { token: "secret2" }, { name: "ok" }] }; + const result = redactor.redactJson(JSON.stringify(obj)); + const parsed = JSON.parse(result.content); + expect(parsed.items[0].token).toBe("[REDACTED]"); + expect(parsed.items[1].token).toBe("[REDACTED]"); + expect(parsed.items[2].name).toBe("ok"); + }); + + it("handles invalid JSON gracefully", async () => { + const { createRedactor } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: [], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: ["token"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + const redactor = createRedactor(tracker); + const result = redactor.redactJson("not valid json{{{"); + expect(result.redacted).toBe(false); + }); + + it("handles null/undefined in objects", async () => { + const { createRedactor } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: [], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: ["token"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + const redactor = createRedactor(tracker); + const result = redactor.redactJson(JSON.stringify({ token: null, nested: null })); + expect(result.redacted).toBe(false); + }); + + it("shouldRedact returns true for JSON and text configs", async () => { + const { createRedactor } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const { DEFAULT_CONFIG } = await import("../src/engine/config.js"); + const redactor = createRedactor(createTracker(DEFAULT_CONFIG)); + expect(redactor.shouldRedact("config.json")).toBe(true); + expect(redactor.shouldRedact("file.txt")).toBe(true); + expect(redactor.shouldRedact("file.yaml")).toBe(true); + expect(redactor.shouldRedact("image.png")).toBe(false); + }); + + it("redactString finds key patterns", async () => { + const { createRedactor } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: [], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: ["token"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + const redactor = createRedactor(tracker); + const result = redactor.redactString('token = "abc123"'); + expect(result.redacted).toBe(true); + }); + + it("redactFile returns empty for unreadable file", async () => { + const { createRedactor } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const { DEFAULT_CONFIG } = await import("../src/engine/config.js"); + const redactor = createRedactor(createTracker(DEFAULT_CONFIG)); + const result = await redactor.redactFile("/nonexistent/file.json"); + expect(result.redacted).toBe(false); + expect(result.content).toBe(""); + }); + + it("does not redact empty string values for secret keys", async () => { + const { createRedactor } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: [], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: ["token"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + const redactor = createRedactor(tracker); + const result = redactor.redactJson(JSON.stringify({ token: "" })); + expect(result.redacted).toBe(false); + }); + + it("redactFileContent handles non-JSON files", async () => { + const { redactFileContent } = await import(MODULE); + const { createTracker } = await import("../src/engine/tracker.js"); + const { DEFAULT_CONFIG } = await import("../src/engine/config.js"); + const tracker = createTracker(DEFAULT_CONFIG); + const f = path.join(testDir, "readme.md"); + writeFileSync(f, "# Hello"); + const result = await redactFileContent(f, tracker); + expect(result.wasRedacted).toBe(false); + expect(result.content).toBe("# Hello"); + }); +}); diff --git a/tests/restore-behavior.test.ts b/tests/restore-behavior.test.ts new file mode 100644 index 0000000..6824a69 --- /dev/null +++ b/tests/restore-behavior.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; +import { spawnSync } from "child_process"; + +// Hermetic: do not read the developer's real global OpenCode config. +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = path.join("/tmp", "gts-no-global-restore"); + +const engine = async () => { + const snapshot = (await import("../src/engine/snapshot.js")).snapshot; + const restore = await import("../src/engine/restore.js"); + const manifest = await import("../src/engine/manifest.js"); + return { snapshot, restore, manifest }; +}; + +describe("restore behavior", () => { + const testDir = path.join("/tmp", "gts-restore-beh-" + Date.now()); + const pkgPath = () => path.join(testDir, "package.json"); + const readPkg = () => JSON.parse(fs.readFileSync(pkgPath(), "utf-8")); + + let v1Hash = ""; + + beforeEach(async () => { + fs.mkdirSync(testDir, { recursive: true }); + spawnSync("git", ["init"], { cwd: testDir }); + fs.writeFileSync(pkgPath(), JSON.stringify({ name: "test", version: "1.0.0" })); + const { snapshot } = await engine(); + const result = await snapshot({ + projectRoot: testDir, + reason: "v1", + type: "manual", + trigger: "test", + }); + v1Hash = result.hash!; + }); + + afterEach(() => { + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it("overwrites conflicting files when forced", async () => { + const { restore } = await engine(); + fs.writeFileSync(pkgPath(), JSON.stringify({ name: "test", version: "2.0.0" })); + + const result = await restore.restoreSnapshot({ + projectRoot: testDir, + hash: v1Hash, + mode: "overwrite", + force: true, + }); + + expect(result.error).toBeUndefined(); + expect(readPkg().version).toBe("1.0.0"); + expect(result.restored).toContain("package.json"); + expect(result.overwritten).toContain("package.json"); + }); + + it("skips conflicting files without force and reports them", async () => { + const { restore } = await engine(); + fs.writeFileSync(pkgPath(), JSON.stringify({ name: "test", version: "2.0.0" })); + + const result = await restore.restoreSnapshot({ + projectRoot: testDir, + hash: v1Hash, + mode: "overwrite", + force: false, + }); + + expect(readPkg().version).toBe("2.0.0"); + expect(result.conflicts).toContain("package.json"); + expect(result.restored).not.toContain("package.json"); + expect(result.skipped.some((s) => s.path === "package.json")).toBe(true); + }); + + it("recreates deleted files and reports them as created", async () => { + const { restore } = await engine(); + fs.rmSync(pkgPath()); + + const result = await restore.restoreSnapshot({ + projectRoot: testDir, + hash: v1Hash, + mode: "overwrite", + force: true, + }); + + expect(fs.existsSync(pkgPath())).toBe(true); + expect(readPkg().version).toBe("1.0.0"); + expect(result.created).toContain("package.json"); + }); + + it("dry-run reports wouldRestore and conflicts without writing", async () => { + const { restore } = await engine(); + fs.writeFileSync(pkgPath(), JSON.stringify({ name: "test", version: "3.0.0" })); + + const result = await restore.restoreSnapshot({ + projectRoot: testDir, + hash: v1Hash, + mode: "dry-run", + }); + + expect(result.wouldRestore).toContain("package.json"); + expect(result.conflicts).toContain("package.json"); + expect(result.restored).toEqual([]); + expect(readPkg().version).toBe("3.0.0"); + }); + + it("to-temp restores into a temporary directory without touching the project", async () => { + const { restore } = await engine(); + fs.writeFileSync(pkgPath(), JSON.stringify({ name: "test", version: "4.0.0" })); + + const result = await restore.restoreSnapshot({ + projectRoot: testDir, + hash: v1Hash, + mode: "to-temp", + }); + + expect(result.tempDir).toBeDefined(); + expect(readPkg().version).toBe("4.0.0"); + const tempPkg = JSON.parse(fs.readFileSync(path.join(result.tempDir!, "package.json"), "utf-8")); + expect(tempPkg.version).toBe("1.0.0"); + }); + + it("records a pre-restore recovery snapshot", async () => { + const { restore, manifest } = await engine(); + fs.writeFileSync(pkgPath(), JSON.stringify({ name: "test", version: "5.0.0" })); + + const before = (await manifest.readManifest(testDir)).length; + + const result = await restore.restoreSnapshot({ + projectRoot: testDir, + hash: v1Hash, + mode: "overwrite", + force: false, + }); + + const entries = await manifest.readManifest(testDir); + expect(entries.length).toBe(before + 1); + expect(entries[entries.length - 1].type).toBe("pre-restore"); + expect(result.preRestoreHash).toBeDefined(); + }); + + it("returns an error for a missing snapshot", async () => { + const { restore } = await engine(); + const result = await restore.restoreSnapshot({ + projectRoot: testDir, + hash: "deadbeef", + mode: "overwrite", + force: true, + }); + expect(result.error).toBeDefined(); + }); + + it("dry-run returns an error for an unknown hash", async () => { + const { restore } = await engine(); + const result = await restore.restoreSnapshot({ + projectRoot: testDir, + hash: "deadbeef", + mode: "dry-run", + }); + expect(result.error).toBeDefined(); + }); + + it("does not restore redacted files without force", async () => { + const { snapshot, restore } = await engine(); + const ocPath = path.join(testDir, "opencode.json"); + fs.writeFileSync(ocPath, JSON.stringify({ apiKey: "sk-LIVE-SECRET", name: "x" })); + + const snap = await snapshot({ + projectRoot: testDir, + reason: "with secret", + type: "manual", + trigger: "test", + }); + + // Simulate a damaged file after the snapshot. + fs.writeFileSync(ocPath, "{ damaged"); + + const result = await restore.restoreSnapshot({ + projectRoot: testDir, + hash: snap.hash!, + mode: "overwrite", + force: false, + }); + + expect(result.redacted).toContain("opencode.json"); + // The live file must not be replaced with [REDACTED] placeholders. + expect(fs.readFileSync(ocPath, "utf-8")).toBe("{ damaged"); + }); + + it("never restores redacted files, even with force", async () => { + const { snapshot, restore } = await engine(); + const ocPath = path.join(testDir, "opencode.json"); + fs.writeFileSync(ocPath, JSON.stringify({ apiKey: "sk-LIVE-SECRET2", name: "x" })); + + const snap = await snapshot({ + projectRoot: testDir, + reason: "with secret 2", + type: "manual", + trigger: "test", + }); + + fs.writeFileSync(ocPath, "{ damaged"); + + const result = await restore.restoreSnapshot({ + projectRoot: testDir, + hash: snap.hash!, + mode: "overwrite", + force: true, + }); + + expect(result.redacted).toContain("opencode.json"); + expect(result.restored).not.toContain("opencode.json"); + // Live file is left untouched; no placeholders are written. + expect(fs.readFileSync(ocPath, "utf-8")).toBe("{ damaged"); + }); + + it("performs the restore exactly once (regression guard)", () => { + // The original implementation invoked realRestore both inside and outside + // the lock, executing the restore twice. Guard against that regression. + const source = fs.readFileSync( + path.join(import.meta.dir, "..", "src", "engine", "restore.ts"), + "utf-8" + ); + const calls = source.match(/realRestore\(projectRoot/g) ?? []; + expect(calls.length).toBe(1); + }); +}); diff --git a/tests/restore-security.test.ts b/tests/restore-security.test.ts new file mode 100644 index 0000000..5c3d247 --- /dev/null +++ b/tests/restore-security.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; +import { spawnSync } from "child_process"; + +const GLOBAL_CONFIG_ENV_PREV = process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = path.join("/tmp", "gts-no-global-restore-sec"); +// Restore the environment so this file never leaks isolation into another test file. +afterAll(() => { + if (GLOBAL_CONFIG_ENV_PREV === undefined) delete process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; + else process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = GLOBAL_CONFIG_ENV_PREV; +}); + +const configModule = async () => import("../src/engine/config.js"); + +async function seedProject(project: string): Promise { + const { snapshot } = await import("../src/engine/snapshot.js"); + fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "t", version: "1.0.0" })); + const result = await snapshot({ projectRoot: project, reason: "v1", type: "manual", trigger: "test" }); + return result.hash!; +} + +describe("restore security", () => { + const root = path.join("/tmp", "gts-restore-sec-" + Date.now()); + + const makeProject = (name: string): string => { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + spawnSync("git", ["init"], { cwd: dir }); + return dir; + }; + + beforeEach(() => { + fs.mkdirSync(root, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("refuses to write through a symlinked destination file", async () => { + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const project = makeProject("symlink-file"); + const hash = await seedProject(project); + + const outside = path.join(root, "outside-secret.json"); + fs.writeFileSync(outside, "ORIGINAL-OUTSIDE"); + + const pkg = path.join(project, "package.json"); + fs.rmSync(pkg); + fs.symlinkSync(outside, pkg); + + const result = await restoreSnapshot({ projectRoot: project, hash, mode: "overwrite", force: true }); + + expect(fs.readFileSync(outside, "utf-8")).toBe("ORIGINAL-OUTSIDE"); + expect(result.restored).not.toContain("package.json"); + expect( + result.skipped.some((s) => s.path === "package.json" && /symlink|outside/i.test(s.reason)) + ).toBe(true); + }); + + it("refuses to write through a symlinked ancestor directory", async () => { + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + const project = makeProject("symlink-dir"); + await seedProject(project); + + // Craft a real commit in the shadow repo containing `evil/payload.txt`. + const shadow = getShadowRepoPath(project); + fs.mkdirSync(path.join(shadow, "evil"), { recursive: true }); + fs.writeFileSync(path.join(shadow, "evil", "payload.txt"), "PAYLOAD"); + spawnSync("git", ["add", "-A"], { cwd: shadow }); + spawnSync("git", ["commit", "-m", "manual: crafted evil path"], { cwd: shadow }); + const hash = spawnSync("git", ["rev-parse", "HEAD"], { cwd: shadow, encoding: "utf-8" }).stdout.trim(); + + // Point `evil` in the project at an unrelated directory outside the project. + const outsideDir = path.join(root, "outside-dir"); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.writeFileSync(path.join(outsideDir, "payload.txt"), "ORIGINAL-OUTSIDE-DIR"); + fs.symlinkSync(outsideDir, path.join(project, "evil")); + + const result = await restoreSnapshot({ projectRoot: project, hash, mode: "overwrite", force: true }); + + expect(fs.readFileSync(path.join(outsideDir, "payload.txt"), "utf-8")).toBe("ORIGINAL-OUTSIDE-DIR"); + expect(result.restored).not.toContain("evil/payload.txt"); + expect(result.skipped.some((s) => s.path === "evil/payload.txt")).toBe(true); + }); + + it("safeWriteFile rejects traversal, absolute and symlinked destinations", async () => { + const { safeWriteFile } = await import("../src/engine/restore.js"); + const project = makeProject("safewrite"); + const realRoot = fs.realpathSync(project); + + const traversal = path.join(project, "..", "gts-escape.txt"); + expect((await safeWriteFile(realRoot, traversal, "x")).ok).toBe(false); + expect(fs.existsSync(path.join(root, "gts-escape.txt"))).toBe(false); + + const absolute = path.join(root, "gts-abs-escape.txt"); + expect((await safeWriteFile(realRoot, absolute, "x")).ok).toBe(false); + expect(fs.existsSync(absolute)).toBe(false); + + const linkTarget = path.join(root, "link-target.txt"); + fs.writeFileSync(linkTarget, "KEEP"); + const linkPath = path.join(project, "link.json"); + fs.symlinkSync(linkTarget, linkPath); + expect((await safeWriteFile(realRoot, linkPath, "x")).ok).toBe(false); + expect(fs.readFileSync(linkTarget, "utf-8")).toBe("KEEP"); + + const linkedDir = path.join(root, "linked-dir"); + fs.mkdirSync(linkedDir, { recursive: true }); + fs.symlinkSync(linkedDir, path.join(project, "linked")); + expect((await safeWriteFile(realRoot, path.join(project, "linked", "nested.txt"), "x")).ok).toBe( + false + ); + expect(fs.existsSync(path.join(linkedDir, "nested.txt"))).toBe(false); + + // A normal in-project destination still succeeds. + const okPath = path.join(project, "nested", "ok.json"); + const ok = await safeWriteFile(realRoot, okPath, "ok"); + expect(ok.ok).toBe(true); + expect(fs.readFileSync(okPath, "utf-8")).toBe("ok"); + }); + + it("reports a failed write and leaves no temporary files behind", async () => { + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const project = makeProject("write-fail"); + const hash = await seedProject(project); + + // Replace the tracked file with a non-empty directory: rename cannot replace it. + const pkg = path.join(project, "package.json"); + fs.rmSync(pkg); + fs.mkdirSync(pkg, { recursive: true }); + fs.writeFileSync(path.join(pkg, "blocker"), "x"); + + const result = await restoreSnapshot({ projectRoot: project, hash, mode: "overwrite", force: true }); + + expect(result.restored).not.toContain("package.json"); + expect(result.skipped.some((s) => s.path === "package.json")).toBe(true); + + const leftovers = fs + .readdirSync(project) + .filter((f) => f.startsWith(".gts-restore-") && f.endsWith(".tmp")); + expect(leftovers).toEqual([]); + }); + + it("never restores redacted content, even when force is set", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const project = makeProject("redacted"); + const ocPath = path.join(project, "opencode.json"); + fs.writeFileSync(ocPath, JSON.stringify({ apiKey: "sk-LIVE-RESTORE-SEC", name: "x" })); + const snap = await snapshot({ projectRoot: project, reason: "with secret", type: "manual", trigger: "test" }); + + fs.writeFileSync(ocPath, "{ damaged"); + + const result = await restoreSnapshot({ projectRoot: project, hash: snap.hash!, mode: "overwrite", force: true }); + + expect(result.redacted).toContain("opencode.json"); + expect(result.restored).not.toContain("opencode.json"); + expect(fs.readFileSync(ocPath, "utf-8")).toBe("{ damaged"); + }); + + it("requires confirmation (force) before overwriting a conflicting file", async () => { + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const project = makeProject("conflict"); + const hash = await seedProject(project); + + fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "t", version: "2.0.0" })); + + const result = await restoreSnapshot({ projectRoot: project, hash, mode: "overwrite", force: false }); + + expect(result.conflicts).toContain("package.json"); + expect(result.restored).not.toContain("package.json"); + expect(JSON.parse(fs.readFileSync(path.join(project, "package.json"), "utf-8")).version).toBe("2.0.0"); + }); + + it("dry-run is the default and writes nothing", async () => { + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const project = makeProject("dry"); + const hash = await seedProject(project); + fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "t", version: "9.9.9" })); + + const result = await restoreSnapshot({ projectRoot: project, hash, mode: "dry-run" }); + + expect(result.mode).toBe("dry-run"); + expect(result.restored).toEqual([]); + expect(JSON.parse(fs.readFileSync(path.join(project, "package.json"), "utf-8")).version).toBe("9.9.9"); + }); + + it("remains protected by the project lock", async () => { + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const { acquireLock, releaseLock } = await import("../src/engine/lock.js"); + const project = makeProject("locked"); + const hash = await seedProject(project); + + await acquireLock(project, "other", "held"); + try { + const result = await restoreSnapshot({ projectRoot: project, hash, mode: "overwrite", force: true }); + expect(result.error).toContain("Lock held"); + expect(result.restored).toEqual([]); + } finally { + await releaseLock(project); + } + }, 20000); + + it("strict mode blocks a restore whose pre-restore snapshot cannot be created", async () => { + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const { DEFAULT_CONFIG, saveConfig } = await configModule(); + const project = makeProject("strict-block"); + const hash = await seedProject(project); + + await saveConfig(project, { + ...DEFAULT_CONFIG, + snapshot: { ...DEFAULT_CONFIG.snapshot, failureMode: "strict", minDiskSpaceMb: Number.MAX_SAFE_INTEGER }, + }); + fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "t", version: "2.0.0" })); + + const result = await restoreSnapshot({ projectRoot: project, hash, mode: "overwrite" }); + + expect(result.error).toContain("strict"); + expect(result.warnings?.join(" ")).toContain("STRICT"); + expect(JSON.parse(fs.readFileSync(path.join(project, "package.json"), "utf-8")).version).toBe("2.0.0"); + }); + + it("standard mode continues with a warning when the pre-restore snapshot fails", async () => { + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const { DEFAULT_CONFIG, saveConfig } = await configModule(); + const project = makeProject("standard-continue"); + const hash = await seedProject(project); + + await saveConfig(project, { + ...DEFAULT_CONFIG, + snapshot: { ...DEFAULT_CONFIG.snapshot, failureMode: "standard", minDiskSpaceMb: Number.MAX_SAFE_INTEGER }, + }); + fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "t", version: "2.0.0" })); + + const result = await restoreSnapshot({ projectRoot: project, hash, mode: "overwrite" }); + + expect(result.error).toBeUndefined(); + expect(result.warnings?.join(" ")).toContain("WARNING"); + // No recovery point was claimed. + expect(result.preRestoreHash).toBeUndefined(); + // Conflicting file still requires force. + expect(result.conflicts).toContain("package.json"); + }); +}); diff --git a/tests/restore.test.ts b/tests/restore.test.ts new file mode 100644 index 0000000..1681dfb --- /dev/null +++ b/tests/restore.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; +import { spawnSync } from "child_process"; + +describe("restore", () => { + const testDir = path.join("/tmp", "gts-restore-" + Date.now()); + + beforeEach(async () => { + mkdirSync(testDir, { recursive: true }); + spawnSync("git", ["init"], { cwd: testDir }); + writeFileSync(path.join(testDir, "package.json"), JSON.stringify({ name: "test", version: "1.0.0" })); + const { snapshot } = await import("../src/engine/snapshot.js"); + await snapshot({ projectRoot: testDir, reason: "initial", type: "manual", trigger: "test" }); + writeFileSync(path.join(testDir, "package.json"), JSON.stringify({ name: "test", version: "2.0.0" })); + const result = await snapshot({ projectRoot: testDir, reason: "after change", type: "manual", trigger: "test" }); + (globalThis as any).__snapshotHash = result.hash; + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("dryRunRestore shows files to restore", async () => { + const hash = (globalThis as any).__snapshotHash; + const { dryRunRestore } = await import("../src/engine/restore.js"); + const result = await (dryRunRestore as any)(testDir, hash); + expect(result.wouldRestore.length).toBeGreaterThan(0); + expect(result.mode).toBe("dry-run"); + }); + + it("previewRestore returns preview info", async () => { + const hash = (globalThis as any).__snapshotHash; + const { previewRestore } = await import("../src/engine/restore.js"); + const result = await previewRestore(testDir, hash); + expect(result.files.length).toBeGreaterThan(0); + expect(result.missing).toEqual([]); + }); + + it("getRestoreTarget finds hash in manifest", async () => { + const hash = (globalThis as any).__snapshotHash; + const { getRestoreTarget } = await import("../src/engine/restore.js"); + const target = await getRestoreTarget(testDir, hash); + expect(target).toBe(hash); + }); + + it("getRestoreTarget returns null for unknown hash", async () => { + const { getRestoreTarget } = await import("../src/engine/restore.js"); + expect(await getRestoreTarget(testDir, "nonexistent")).toBeNull(); + }); + + it("restoreSnapshot dry-run returns dry-run mode", async () => { + const hash = (globalThis as any).__snapshotHash; + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const result = await restoreSnapshot({ projectRoot: testDir, hash, mode: "dry-run" }); + expect(result.mode).toBe("dry-run"); + expect(result.wouldRestore.length).toBeGreaterThan(0); + }); + + it("restoreSnapshot fails gracefully for bad hash", async () => { + const { restoreSnapshot } = await import("../src/engine/restore.js"); + const result = await restoreSnapshot({ projectRoot: testDir, hash: "deadbeef", mode: "dry-run" }); + expect(result.error).toBeDefined(); + }); +}); diff --git a/tests/security-extended.test.ts b/tests/security-extended.test.ts new file mode 100644 index 0000000..8942b7a --- /dev/null +++ b/tests/security-extended.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; +import { spawnSync } from "child_process"; + +// Default to a non-existent global config dir; the global-config test overrides +// it for the duration of a single test and restores it afterwards. +const NO_GLOBAL = path.join("/tmp", "gts-no-global-security"); +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = NO_GLOBAL; + +function trackerConfig(overrides: Record = {}) { + return { + version: "0.1" as const, + tracking: { include: ["*.json", "*.key", "*.pem"], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 0 }, + secrets: { redactKeys: ["token", "apiKey", "password", "secret"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + ...overrides, + }; +} + +describe("security hardening (extended)", () => { + const testDir = path.join("/tmp", "gts-sec-ext-" + Date.now()); + + beforeEach(() => { + fs.mkdirSync(testDir, { recursive: true }); + spawnSync("git", ["init"], { cwd: testDir }); + }); + + afterEach(() => { + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + it("redacts multiline private key blocks in text files", async () => { + const { createRedactor } = await import("../src/engine/redactor.js"); + const { createTracker } = await import("../src/engine/tracker.js"); + const redactor = createRedactor(createTracker(trackerConfig() as any)); + + const pem = [ + "before", + "-----BEGIN RSA PRIVATE KEY-----", + "MIIEowIBAAKCAQEA1234567890", + "abcdefghijklmnop", + "-----END RSA PRIVATE KEY-----", + "after", + ].join("\n"); + + const result = redactor.redactText(pem); + expect(result.redacted).toBe(true); + expect(result.content).not.toContain("MIIEowIBAAKCAQEA"); + expect(result.content).toContain("[REDACTED"); + }); + + it("redacts multiline values inside JSON", async () => { + const { createRedactor } = await import("../src/engine/redactor.js"); + const { createTracker } = await import("../src/engine/tracker.js"); + const redactor = createRedactor(createTracker(trackerConfig() as any)); + + const result = redactor.redactJson( + JSON.stringify({ password: "line1\nline2\nline3", keep: "ok" }) + ); + expect(JSON.parse(result.content).password).toBe("[REDACTED]"); + expect(result.content).not.toContain("line2"); + }); + + it("fails closed on malformed JSON and never writes it", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + + fs.writeFileSync( + path.join(testDir, "opencode.json"), + '{"token": "sk-LEAKME", invalid json' + ); + + const result = await snapshot({ + projectRoot: testDir, + reason: "malformed", + type: "manual", + trigger: "test", + }); + + const skipped = result.filesSkipped.find((f) => f.path === "opencode.json"); + expect(skipped?.reason).toContain("failed closed"); + + // Ensure the raw secret never reached the shadow repository. + const shadow = getShadowRepoPath(testDir); + const grep = spawnSync("git", ["grep", "-l", "sk-LEAKME"], { cwd: shadow, encoding: "utf-8" }); + expect(grep.stdout.trim()).toBe(""); + }); + + it("redacts secrets in global OpenCode config before storing them", async () => { + const globalDir = path.join(testDir, "global-config"); + fs.mkdirSync(globalDir, { recursive: true }); + fs.writeFileSync( + path.join(globalDir, "config.json"), + JSON.stringify({ apiKey: "sk-GLOBAL-SECRET", name: "global" }) + ); + + const previous = process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; + process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = globalDir; + + try { + const { snapshot } = await import("../src/engine/snapshot.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + + fs.writeFileSync(path.join(testDir, "package.json"), JSON.stringify({ name: "test" })); + await snapshot({ projectRoot: testDir, reason: "global", type: "manual", trigger: "test" }); + + const shadow = getShadowRepoPath(testDir); + const stored = fs.readFileSync( + path.join(shadow, "global-opencode", "config.json"), + "utf-8" + ); + expect(stored).not.toContain("sk-GLOBAL-SECRET"); + expect(stored).toContain("[REDACTED]"); + } finally { + process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = previous ?? NO_GLOBAL; + } + }); + + it("never logs or returns raw secret values", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + fs.writeFileSync( + path.join(testDir, "opencode.json"), + JSON.stringify({ apiKey: "sk-RESULT-SECRET", name: "x" }) + ); + + const result = await snapshot({ + projectRoot: testDir, + reason: "logcheck", + type: "manual", + trigger: "test", + }); + + expect(JSON.stringify(result)).not.toContain("sk-RESULT-SECRET"); + }); + + it("excludes sensitive filenames from snapshots", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const { getConfigPath, getShadowRepoPath } = await import("../src/shared/paths.js"); + + const configPath = getConfigPath(testDir); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(trackerConfig())); + + fs.writeFileSync(path.join(testDir, "secret.key"), "PRIVATE"); + fs.writeFileSync(path.join(testDir, "server.pem"), "PRIVATE"); + fs.writeFileSync(path.join(testDir, "package.json"), JSON.stringify({ name: "test" })); + + const result = await snapshot({ + projectRoot: testDir, + reason: "sensitive", + type: "manual", + trigger: "test", + }); + + expect(result.filesCopied).not.toContain("secret.key"); + expect(result.filesCopied).not.toContain("server.pem"); + + const shadow = getShadowRepoPath(testDir); + expect(fs.existsSync(path.join(shadow, "secret.key"))).toBe(false); + expect(fs.existsSync(path.join(shadow, "server.pem"))).toBe(false); + }); + + it("does not store the configuration's own redaction list as a secret leak", async () => { + // Guard: a file that merely mentions secret key names (not values) stays. + const { createRedactor } = await import("../src/engine/redactor.js"); + const { createTracker } = await import("../src/engine/tracker.js"); + const redactor = createRedactor(createTracker(trackerConfig() as any)); + const result = redactor.redactText("The word token appears here."); + expect(result.content).toContain("token"); + }); +}); diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 0000000..0a8ce27 --- /dev/null +++ b/tests/security.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; + +describe("security hardening", () => { + const testDir = path.join("/tmp", "gts-sec-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + // Path traversal via tracker is rejected. + it("tracker blocks parent dir traversal in path", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: ["*.json"], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: [], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + const result = tracker.shouldTrack("../../../etc/passwd.json"); + expect(result.track).toBe(false); + expect(result.reason).toContain("unsafe"); + }); + + // Tracker hard-excludes actual dangerous paths + it("tracker hard-excludes .env regardless of include pattern", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: ["*", "**/*"], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: [], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + expect(tracker.isHardExcluded(".env")).toBe(true); + }); + + it("tracker hard-excludes node_modules", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: ["*"], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: [], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + expect(tracker.isHardExcluded("node_modules/some-package/index.js")).toBe(true); + }); + + it("tracker blocks id_rsa and id_ed25519", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: ["*"], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: [], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + expect(tracker.isSensitive("id_rsa")).toBe(true); + expect(tracker.isSensitive("id_ed25519")).toBe(true); + }); + + it("tracker blocks .pem and .key files", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: ["*"], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: [], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + expect(tracker.isSensitive("server.pem")).toBe(true); + expect(tracker.isSensitive("private.key")).toBe(true); + }); + + // Redactor now redacts secrets in textual (non-JSON) config too. + it("redactor redacts secrets in YAML/text files", async () => { + const { createRedactor } = await import("../src/engine/redactor.js"); + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: [], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: ["apiKey", "token"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + const redactor = createRedactor(tracker); + const f = path.join(testDir, "config.yaml"); + writeFileSync(f, "apiKey: sk-1234\ntoken: secret"); + const result = await redactor.redactFile(f); + expect(result.redacted).toBe(true); + expect(result.content).not.toContain("sk-1234"); + expect(result.content).not.toContain("secret"); + expect(result.content).toContain("[REDACTED]"); + }); + + // Snapshot handles large files gracefully + it("collectFilesToTrack does not include hard-excluded files", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: ["*", "**/*"], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: [], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + expect(tracker.filterFiles(["package.json", ".env", "node_modules/foo.js", "secret.key"]).tracked).toEqual(["package.json"]); + }); + + // Redactor handles nested object traversal safely + it("redactor handles circular-reference-like objects safely", async () => { + const { createRedactor } = await import("../src/engine/redactor.js"); + const { createTracker } = await import("../src/engine/tracker.js"); + const config = { + version: "0.1" as const, + tracking: { include: [], exclude: [], allowSensitiveFiles: false }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: ["token"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + }; + const tracker = createTracker(config); + const redactor = createRedactor(tracker); + const deep = { level1: { level2: { level3: { level4: { token: "deep-secret" } } } } }; + const result = redactor.redactJson(JSON.stringify(deep)); + const parsed = JSON.parse(result.content); + expect(parsed.level1.level2.level3.level4.token).toBe("[REDACTED]"); + }); + + // Config validation rejects unknown versions + it("config validation rejects future versions", async () => { + const configDir = path.join(testDir, ".git-that-shit", "git-that-shit"); + mkdirSync(configDir, { recursive: true }); + writeFileSync(path.join(configDir, "config.json"), JSON.stringify({ + version: "9.9", + tracking: { include: [], exclude: [], allowSensitiveFiles: true }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available" as const, debounceMs: 2000 }, + secrets: { redactKeys: [], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, + })); + const { loadConfig } = await import("../src/engine/config.js"); + await expect(loadConfig(testDir)).rejects.toThrow("Invalid or missing config version"); + }); + + // Path resolution does not escape project root + it("ensureDir handles deeply nested paths", async () => { + const { ensureDir } = await import("../src/shared/paths.js"); + const deep = path.join(testDir, "a", "b", "c", "d", "e", "f"); + ensureDir(deep); + expect(existsSync(deep)).toBe(true); + }); + + // Doctor checks for sensitive files + it("doctor reports sensitive files if present", async () => { + const { runDoctor } = await import("../src/engine/doctor.js"); + writeFileSync(path.join(testDir, "credentials.json"), "{}"); + mkdirSync(path.join(testDir, ".git"), { recursive: true }); + const result = await runDoctor(testDir); + const sensitiveCheck = result.checks.find((c: any) => c.name === "sensitive-files"); + expect(sensitiveCheck).toBeDefined(); + expect(sensitiveCheck!.status).toBe("warn"); + }); + + // Detector handles command injection patterns + it("detector does not treat 'rmdir' as destructive", async () => { + const { detectDestructiveCommand } = await import("../src/engine/detector.js"); + const result = detectDestructiveCommand("bash", ["rmdir empty-dir"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + expect(result.level).toBe("destructive"); + }); + + it("detector handles pipe chains", async () => { + const { detectDestructiveCommand } = await import("../src/engine/detector.js"); + const result = detectDestructiveCommand("bash", ["rm -rf dir | echo done"], { destructiveOps: { riskyPackageManagerOps: true } }); + expect(result.shouldSnapshot).toBe(true); + }); + + // Snapshot cannot write to parent directories + it("paths.getShadowRepoPath stays within project", async () => { + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + const p = getShadowRepoPath("/project"); + expect(p).toBe("/project/.git-that-shit/git-that-shit/snapshots"); + expect(p).not.toContain(".."); + }); + + it("paths.getManifestPath stays within project", async () => { + const { getManifestPath } = await import("../src/shared/paths.js"); + const p = getManifestPath("/project"); + expect(p).not.toContain(".."); + }); + + it("config does not allow allowSensitiveFiles to include .env in hard excludes", async () => { + const { DEFAULT_CONFIG } = await import("../src/engine/config.js"); + const hardExcludes = (await import("../src/engine/config.js")).getHardExcludes(); + expect(hardExcludes).toContain(".env"); + // Even if allowSensitiveFiles is true, hardExcludes still include .env + }); +}); diff --git a/tests/snapshot-failure.test.ts b/tests/snapshot-failure.test.ts new file mode 100644 index 0000000..f5e0f31 --- /dev/null +++ b/tests/snapshot-failure.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll } from "bun:test"; +import * as path from "path"; +import * as fs from "fs"; +import { spawnSync } from "child_process"; + +const GLOBAL_CONFIG_ENV_PREV = process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = path.join("/tmp", "gts-no-global-failure"); +// Restore the environment so this file never leaks isolation into another test file. +afterAll(() => { + if (GLOBAL_CONFIG_ENV_PREV === undefined) delete process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR; + else process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = GLOBAL_CONFIG_ENV_PREV; +}); + +const configModule = async () => import("../src/engine/config.js"); + +async function writeFailureConfig( + project: string, + overrides: Partial> +): Promise { + const { DEFAULT_CONFIG, saveConfig } = await configModule(); + await saveConfig(project, { + ...DEFAULT_CONFIG, + snapshot: { ...DEFAULT_CONFIG.snapshot, ...(overrides as any) }, + }); +} + +describe("snapshot failure safety", () => { + const root = path.join("/tmp", "gts-fail-" + Date.now()); + + const makeProject = (name: string): string => { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + spawnSync("git", ["init"], { cwd: dir }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version: "1.0.0" })); + return dir; + }; + + const setHook = (project: string, body: string) => { + const hookDir = path.join( + project, + ".git-that-shit", + "git-that-shit", + "snapshots", + ".git", + "hooks" + ); + fs.mkdirSync(hookDir, { recursive: true }); + const hook = path.join(hookDir, "pre-commit"); + fs.writeFileSync(hook, body); + fs.chmodSync(hook, 0o755); + }; + + beforeEach(() => { + fs.mkdirSync(root, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(root)) fs.rmSync(root, { recursive: true, force: true }); + }); + + it("reports low disk space without creating or claiming a snapshot", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const project = makeProject("low-disk"); + await writeFailureConfig(project, { minDiskSpaceMb: Number.MAX_SAFE_INTEGER }); + + const result = await snapshot({ projectRoot: project, reason: "disk", type: "manual", trigger: "test" }); + + expect(result.committed).toBe(false); + expect(result.reason).toBe("low-disk"); + expect(result.verified).toBe(false); + expect(result.hash).toBeUndefined(); + expect(result.error).toContain("disk space"); + }); + + it("reports a held lock instead of proceeding", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const { acquireLock, releaseLock } = await import("../src/engine/lock.js"); + const project = makeProject("locked"); + + await acquireLock(project, "other", "held"); + try { + const result = await snapshot({ projectRoot: project, reason: "locked", type: "manual", trigger: "test" }); + expect(result.committed).toBe(false); + expect(result.reason).toBe("locked"); + expect(result.verified).toBe(false); + expect(result.hash).toBeUndefined(); + } finally { + await releaseLock(project); + } + }); + + it("surfaces a git commit failure as a structured error, never a crash", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const { initShadowRepo } = await import("../src/engine/snapshot.js"); + const project = makeProject("commit-fail"); + await initShadowRepo(project); + setHook(project, "#!/bin/sh\nexit 1\n"); + + const result = await snapshot({ projectRoot: project, reason: "hook", type: "manual", trigger: "test" }); + + expect(result.committed).toBe(false); + expect(result.reason).toBe("error"); + expect(result.verified).toBe(false); + expect(result.failureCode).toBe("error"); + expect(result.error).toBeDefined(); + }); + + it("surfaces a snapshot timeout as a structured error", async () => { + const { snapshot, initShadowRepo } = await import("../src/engine/snapshot.js"); + const project = makeProject("timeout"); + await initShadowRepo(project); + await writeFailureConfig(project, { gitTimeoutMs: 400 }); + setHook(project, "#!/bin/sh\nsleep 3\n"); + + const started = Date.now(); + const result = await snapshot({ projectRoot: project, reason: "timeout", type: "manual", trigger: "test" }); + const elapsed = Date.now() - started; + + expect(result.committed).toBe(false); + expect(result.reason).toBe("error"); + expect(result.verified).toBe(false); + // The subprocess timeout must actually bound the operation. + expect(elapsed).toBeLessThan(2500); + }); + + it("skips an unreadable tracked file (fail closed) without claiming it was stored", async () => { + if (process.platform === "win32") return; + const { snapshot } = await import("../src/engine/snapshot.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + const project = makeProject("unreadable"); + + const ocPath = path.join(project, "opencode.json"); + fs.writeFileSync(ocPath, JSON.stringify({ apiKey: "sk-UNREADABLE", name: "x" })); + fs.chmodSync(ocPath, 0o000); + + try { + const result = await snapshot({ projectRoot: project, reason: "unreadable", type: "manual", trigger: "test" }); + const skipped = result.filesSkipped.find((f) => f.path === "opencode.json"); + expect(skipped?.reason).toContain("failed closed"); + expect(result.filesCopied).not.toContain("opencode.json"); + + const shadow = getShadowRepoPath(project); + const grep = spawnSync("git", ["grep", "-l", "sk-UNREADABLE"], { cwd: shadow, encoding: "utf-8" }); + expect(grep.stdout.trim()).toBe(""); + } finally { + fs.chmodSync(ocPath, 0o644); + } + }); + + it("handles a corrupted shadow repository gracefully and does not touch the project git repo", async () => { + const { snapshot, initShadowRepo } = await import("../src/engine/snapshot.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + const project = makeProject("corrupt"); + await initShadowRepo(project); + + const shadowGit = path.join(getShadowRepoPath(project), ".git"); + fs.rmSync(shadowGit, { recursive: true, force: true }); + // A gitfile that points nowhere: git discovery fails rather than walking up + // into the surrounding project repository. + fs.writeFileSync(shadowGit, "gitdir: /nonexistent/gts-broken\n"); + + const before = spawnSync("git", ["status", "--short"], { cwd: project, encoding: "utf-8" }).stdout; + + const result = await snapshot({ projectRoot: project, reason: "corrupt", type: "manual", trigger: "test" }); + + expect(result.committed).toBe(false); + expect(result.reason).toBe("error"); + expect(result.verified).toBe(false); + + const after = spawnSync("git", ["status", "--short"], { cwd: project, encoding: "utf-8" }).stdout; + expect(after).toBe(before); + }); + + it("only reports a snapshot as verified once the commit resolves", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + const { commitExists } = await import("../src/engine/git.js"); + const project = makeProject("verified"); + + const result = await snapshot({ projectRoot: project, reason: "ok", type: "manual", trigger: "test" }); + expect(result.committed).toBe(true); + expect(result.verified).toBe(true); + expect(result.hash).toBeDefined(); + expect(await commitExists(getShadowRepoPath(project), result.hash!)).toBe(true); + }); + + it("never stores a secret carried in a command preview or manual message", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const { readManifest } = await import("../src/engine/manifest.js"); + const git = await import("../src/engine/git.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + const project = makeProject("secret-preview"); + + const result = await snapshot({ + projectRoot: project, + reason: "apiKey=sk-LEAKREASON0000", + type: "manual", + trigger: "test", + operation: { tool: "bash", preview: "deploy --token=sk-LEAKPREVIEW0000" }, + }); + + expect(JSON.stringify(result)).not.toContain("sk-LEAK"); + + const entries = await readManifest(project); + expect(JSON.stringify(entries)).not.toContain("sk-LEAK"); + + const log = await git.log(getShadowRepoPath(project), 10); + expect(log.map((e) => e.message).join("\n")).not.toContain("sk-LEAK"); + }); +}); + +describe("snapshot failure policy", () => { + it("blocks only classified destructive levels, and only in strict mode", async () => { + const { shouldBlockOperation } = await import("../src/engine/safety.js"); + expect(shouldBlockOperation("destructive", "strict")).toBe(true); + expect(shouldBlockOperation("risky", "strict")).toBe(false); + expect(shouldBlockOperation("config-write", "strict")).toBe(false); + expect(shouldBlockOperation("safe", "strict")).toBe(false); + expect(shouldBlockOperation("destructive", "standard")).toBe(false); + expect(shouldBlockOperation("risky", "standard")).toBe(false); + }); + + it("produces clear, credential-free warnings and block messages", async () => { + const { formatSnapshotFailureWarning, formatBlockMessage } = await import( + "../src/engine/safety.js" + ); + const warning = formatSnapshotFailureWarning( + { reason: "low-disk", error: "token=sk-LEAKWARN" }, + "standard" + ); + expect(warning).toContain("WARNING"); + expect(warning).not.toContain("sk-LEAKWARN"); + + const strictWarning = formatSnapshotFailureWarning({ reason: "locked" }, "strict"); + expect(strictWarning).toContain("STRICT"); + + const block = formatBlockMessage("low-disk", "apiKey=sk-LEAKBLOCK"); + expect(block).toContain("Blocked by Git That Shit strict mode"); + expect(block).not.toContain("sk-LEAKBLOCK"); + expect(block).toContain("destructive"); + }); + + it("correlates pending denials to a tool-call id and caps its size", async () => { + const { PendingDenials } = await import("../src/engine/safety.js"); + const denials = new PendingDenials(2); + + denials.add("call-1", "first"); + expect(denials.get("call-1")).toBe("first"); + expect(denials.has("call-1")).toBe(true); + + denials.add("call-2", "second"); + denials.add("call-3", "third"); // evicts the oldest + expect(denials.size).toBe(2); + expect(denials.has("call-1")).toBe(false); + expect(denials.has("call-3")).toBe(true); + + denials.forget("call-2"); + expect(denials.has("call-2")).toBe(false); + + denials.add("", "ignored"); + expect(denials.size).toBe(1); + + denials.clear(); + expect(denials.size).toBe(0); + }); +}); diff --git a/tests/snapshot.test.ts b/tests/snapshot.test.ts new file mode 100644 index 0000000..3f797cc --- /dev/null +++ b/tests/snapshot.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs"; +import { spawnSync } from "child_process"; + +// Keep snapshot tests hermetic: do not read the developer's real global config. +process.env.GIT_THAT_SHIT_GLOBAL_CONFIG_DIR = path.join("/tmp", "gts-no-global-" + Date.now()); + +describe("snapshot lifecycle", () => { + const testDir = path.join("/tmp", "gts-snap-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + spawnSync("git", ["init"], { cwd: testDir }); + writeFileSync(path.join(testDir, "package.json"), JSON.stringify({ name: "test" })); + writeFileSync(path.join(testDir, "tsconfig.json"), JSON.stringify({ compilerOptions: {} })); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("initShadowRepo creates shadow repo", async () => { + const { initShadowRepo } = await import("../src/engine/snapshot.js"); + await initShadowRepo(testDir); + const { isGitRepo } = await import("../src/engine/git.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + expect(await isGitRepo(getShadowRepoPath(testDir))).toBe(true); + }); + + it("initShadowRepo is idempotent", async () => { + const { initShadowRepo } = await import("../src/engine/snapshot.js"); + await initShadowRepo(testDir); + await initShadowRepo(testDir); + const { isGitRepo } = await import("../src/engine/git.js"); + const { getShadowRepoPath } = await import("../src/shared/paths.js"); + expect(await isGitRepo(getShadowRepoPath(testDir))).toBe(true); + }); + + it("snapshot creates a commit", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const result = await snapshot({ projectRoot: testDir, reason: "test", type: "manual", trigger: "test" }); + expect(result.committed).toBe(true); + expect(result.shortHash).toBeDefined(); + expect(result.shortHash!.length).toBeGreaterThanOrEqual(7); + }); + + it("snapshot records copied files", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const result = await snapshot({ projectRoot: testDir, reason: "test", type: "manual", trigger: "test" }); + expect(result.filesCopied.length).toBeGreaterThanOrEqual(1); + expect(result.filesCopied).toContain("package.json"); + }); + + it("snapshot returns committed:false when no changes", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + await snapshot({ projectRoot: testDir, reason: "first", type: "manual", trigger: "test" }); + const result = await snapshot({ projectRoot: testDir, reason: "second", type: "manual", trigger: "test" }); + expect(result.committed).toBe(false); + expect(result.reason).toBe("no-changes"); + }); + + it("snapshot returns committed:false when no config files found", async () => { + const { snapshot } = await import("../src/engine/snapshot.js"); + const emptyDir = path.join(testDir, "empty"); + mkdirSync(emptyDir); + spawnSync("git", ["init"], { cwd: emptyDir }); + const result = await snapshot({ projectRoot: emptyDir, reason: "test", type: "manual", trigger: "test" }); + expect(result.committed).toBe(false); + expect(result.reason).toBe("no-changes"); + }); + + it("snapshot redacts secrets in JSON files", async () => { + writeFileSync(path.join(testDir, "opencode.json"), JSON.stringify({ + apiKey: "sk-1234567890", + name: "test", + })); + const { snapshot } = await import("../src/engine/snapshot.js"); + const result = await snapshot({ projectRoot: testDir, reason: "test redact", type: "manual", trigger: "test" }); + expect(result.filesRedacted.length).toBeGreaterThanOrEqual(1); + expect(result.filesCopied).toContain("opencode.json"); + }); + + it("listSnapshots returns snapshots", async () => { + const { snapshot, listSnapshots } = await import("../src/engine/snapshot.js"); + await snapshot({ projectRoot: testDir, reason: "first", type: "manual", trigger: "test" }); + writeFileSync(path.join(testDir, "package.json"), JSON.stringify({ name: "changed" })); + await snapshot({ projectRoot: testDir, reason: "second", type: "manual", trigger: "test" }); + const entries = await listSnapshots(testDir); + expect(entries.length).toBe(2); + expect(entries[0].reason).toBe("first"); + expect(entries[1].reason).toBe("second"); + }); + + it("getSnapshotDiff returns diff", async () => { + const { snapshot, getSnapshotDiff } = await import("../src/engine/snapshot.js"); + await snapshot({ projectRoot: testDir, reason: "first", type: "manual", trigger: "test" }); + writeFileSync(path.join(testDir, "package.json"), JSON.stringify({ name: "changed" })); + const result = await snapshot({ projectRoot: testDir, reason: "second", type: "manual", trigger: "test" }); + const diff = await getSnapshotDiff(testDir, result.hash!); + expect(diff.length).toBeGreaterThan(0); + }); + + it("getSnapshotFiles returns file list", async () => { + const { snapshot, getSnapshotFiles } = await import("../src/engine/snapshot.js"); + const result = await snapshot({ projectRoot: testDir, reason: "test", type: "manual", trigger: "test" }); + const files = await getSnapshotFiles(testDir, result.hash!); + expect(files.length).toBeGreaterThanOrEqual(1); + }); + + it("getSnapshotContent returns file content", async () => { + const { snapshot, getSnapshotContent } = await import("../src/engine/snapshot.js"); + const result = await snapshot({ projectRoot: testDir, reason: "test", type: "manual", trigger: "test" }); + const content = await getSnapshotContent(testDir, result.hash!, "package.json"); + expect(content).toContain("test"); + }); +}); diff --git a/tests/tracker.test.ts b/tests/tracker.test.ts new file mode 100644 index 0000000..db76a9c --- /dev/null +++ b/tests/tracker.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"; +import type { GitThatShitConfig } from "../src/shared/types.js"; + +const DEFAULT_CONFIG: GitThatShitConfig = { + version: "0.1", + tracking: { + include: ["*.json", "**/*.md", "package.json", "tsconfig.json", ".env.example"], + exclude: ["**/node_modules/**"], + allowSensitiveFiles: false, + }, + destructiveOps: { riskyPackageManagerOps: true, configWrites: true }, + fileEdits: { mode: "pre-if-available", debounceMs: 2000 }, + secrets: { redactKeys: ["token", "apiKey", "secret", "password"], warnOnSensitive: true }, + snapshot: { maxFileSizeMb: 10, minDiskSpaceMb: 100 }, + restore: { autoPreRestoreSnapshot: true }, +}; + +describe("Tracker", () => { + const testDir = path.join("/tmp", "gts-tracker-" + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true }); + }); + + it("globToRegex converts simple glob", async () => { + const { Tracker } = await import("../src/engine/tracker.js"); + const tracker = new (Tracker as any)(DEFAULT_CONFIG); + const re = (tracker as any).globToRegex("*.json"); + expect(re.test("package.json")).toBe(true); + expect(re.test("file.txt")).toBe(false); + }); + + it("globToRegex handles ** patterns", async () => { + const { Tracker } = await import("../src/engine/tracker.js"); + const tracker = new (Tracker as any)(DEFAULT_CONFIG); + const re = (tracker as any).globToRegex("**/*.json"); + expect(re.test("foo/bar/config.json")).toBe(true); + expect(re.test("config.json")).toBe(true); + }); + + it("globToRegex handles leading dot patterns", async () => { + const { Tracker } = await import("../src/engine/tracker.js"); + const tracker = new (Tracker as any)(DEFAULT_CONFIG); + const re = (tracker as any).globToRegex(".env"); + expect(re.test(".env")).toBe(true); + expect(re.test("/.env")).toBe(true); + }); + + it("isHardExcluded blocks node_modules", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isHardExcluded("node_modules/foo/index.js")).toBe(true); + }); + + it("isHardExcluded blocks .git", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isHardExcluded(".git/config")).toBe(true); + }); + + it("isHardExcluded blocks dist", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isHardExcluded("dist/bundle.js")).toBe(true); + }); + + it("isHardExcluded blocks .env files", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isHardExcluded(".env")).toBe(true); + }); + + it("isHardExcluded blocks .pem files", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isHardExcluded("secret.pem")).toBe(true); + }); + + it("isHardExcluded blocks .key files", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isHardExcluded("private.key")).toBe(true); + }); + + it("isHardExcluded passes for normal files", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isHardExcluded("src/index.ts")).toBe(false); + expect(tracker.isHardExcluded("package.json")).toBe(false); + }); + + it("isSensitive detects .env", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isSensitive(".env")).toBe(true); + }); + + it("isSensitive detects credentials.json", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isSensitive("credentials.json")).toBe(true); + }); + + it("isSensitive detects service-account files", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isSensitive("service-account-prod.json")).toBe(true); + }); + + it("isSensitive allows .env.example", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isSensitive(".env.example")).toBe(false); + }); + + it("isSensitive allows .env.sample", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isSensitive(".env.sample")).toBe(false); + }); + + it("isSensitive allows .env.template", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isSensitive(".env.template")).toBe(false); + }); + + it("shouldTrack matches include patterns", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + const result = tracker.shouldTrack("package.json"); + expect(result.track).toBe(true); + }); + + it("shouldTrack rejects hard excluded", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + const result = tracker.shouldTrack("node_modules/foo.js"); + expect(result.track).toBe(false); + }); + + it("shouldTrack rejects unknown files", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + const result = tracker.shouldTrack("some_random_file.xyz"); + expect(result.track).toBe(false); + }); + + it("shouldTrack respects user exclude patterns", async () => { + const config = { ...DEFAULT_CONFIG, tracking: { ...DEFAULT_CONFIG.tracking, include: ["*.json"], exclude: ["**/secret*.json"] } }; + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(config); + expect(tracker.shouldTrack("data.json").track).toBe(true); + expect(tracker.shouldTrack("secret_data.json").track).toBe(false); + }); + + it("shouldTrack hard-excludes win over allowSensitiveFiles for .env", async () => { + const config = { ...DEFAULT_CONFIG, tracking: { ...DEFAULT_CONFIG.tracking, allowSensitiveFiles: true } }; + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(config); + const result = tracker.shouldTrack(".env"); + expect(result.track).toBe(false); + expect(result.reason).toBe("hard excluded"); + }); + + it("isSecretKey matches configured keys", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.isSecretKey("apiKey")).toBe(true); + expect(tracker.isSecretKey("token")).toBe(true); + expect(tracker.isSecretKey("password")).toBe(true); + expect(tracker.isSecretKey("notakey")).toBe(false); + }); + + it("filterFiles returns tracked and skipped", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + const files = ["package.json", "tsconfig.json", "node_modules/foo.js", "unknown.xyz"]; + const result = tracker.filterFiles(files); + expect(result.tracked).toContain("package.json"); + expect(result.tracked).toContain("tsconfig.json"); + expect(result.skipped.length).toBeGreaterThanOrEqual(1); + }); + + it("findFiles walks directories and returns matching files", async () => { + mkdirSync(path.join(testDir, "subdir"), { recursive: true }); + writeFileSync(path.join(testDir, "a.json"), "{}"); + writeFileSync(path.join(testDir, "subdir", "b.json"), "{}"); + writeFileSync(path.join(testDir, "readme.md"), "# hi"); + const { findFiles } = await import("../src/engine/tracker.js"); + const results = findFiles(testDir, ["*.json", "*.md"]); + expect(results).toContain("a.json"); + expect(results).toContain("readme.md"); + expect(results.some((r: string) => r.includes("subdir/b.json"))).toBe(true); + }); + + it("findFiles excludes node_modules and dist dirs", async () => { + mkdirSync(path.join(testDir, "node_modules", "pkg"), { recursive: true }); + writeFileSync(path.join(testDir, "node_modules", "pkg", "index.js"), ""); + writeFileSync(path.join(testDir, "valid.json"), "{}"); + const { findFiles } = await import("../src/engine/tracker.js"); + const results = findFiles(testDir, ["*.json", "*.js"]); + expect(results).toContain("valid.json"); + expect(results.some((r: string) => r.includes("node_modules"))).toBe(false); + }); + + it("findFiles handles empty patterns", async () => { + const { findFiles } = await import("../src/engine/tracker.js"); + expect(findFiles(testDir, [])).toEqual([]); + }); + + it("findFiles handles non-existent directory", async () => { + const { findFiles } = await import("../src/engine/tracker.js"); + expect(findFiles("/nonexistent", ["*"])).toEqual([]); + }); + + it("getIncludePatterns returns config patterns", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.getIncludePatterns()).toEqual(DEFAULT_CONFIG.tracking.include); + }); + + it("getExcludePatterns returns config patterns", async () => { + const { createTracker } = await import("../src/engine/tracker.js"); + const tracker = createTracker(DEFAULT_CONFIG); + expect(tracker.getExcludePatterns()).toEqual(DEFAULT_CONFIG.tracking.exclude); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 3e428d6..f6922dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", - "declaration": false, + "declaration": true, "declarationMap": false, "sourceMap": false, "strict": false,