diff --git a/.changeset/README.md b/.changeset/README.md index 8d816bd88..dd8acfff4 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -14,7 +14,9 @@ Select `hunkdiff` and choose the semver bump that matches the shipped CLI/packag - `minor` for new user-facing features - `major` for breaking changes -`package.json` intentionally lists `"."` in `workspaces` so Changesets can discover the root `hunkdiff` package. Keep that entry unless Hunk moves the publishable package out of the repository root. +The private root workspace lists `packages/*`, and Changesets discovers the sole published +`hunkdiff` package at `packages/hunk/package.json`. Private implementation workspaces remain +ignored and are never published. For maintenance-only PRs that should not appear in release notes, create an empty changeset: @@ -28,6 +30,7 @@ Release prep runs: bun run release:version ``` -That consumes the pending `.changeset/*.md` files, updates `CHANGELOG.md`, and bumps package versions for the release commit. +That consumes the pending `.changeset/*.md` files, updates `CHANGELOG.md`, and bumps +`packages/hunk/package.json` for the release commit. After the tag release publishes npm packages and GitHub release assets, verify Homebrew through `Homebrew/homebrew-core`. Hunk is on Homebrew's Autobump list, so do not open manual simple version-bump PRs. Wait for the automated `hunk ` PR, confirm it merges, then verify `brew install hunk` resolves to the released version. Use `brew bump-formula-pr hunk --version ` only if Homebrew maintainers ask for a manual bump or Autobump stalls unexpectedly. diff --git a/.changeset/config.json b/.changeset/config.json index 78f591628..3168d5d68 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -17,6 +17,11 @@ "@hunk/session-broker", "@hunk/session-broker-bun", "@hunk/session-broker-core", - "@hunk/session-broker-node" + "@hunk/session-broker-node", + "@hunk/term-video", + "@hunk/git", + "@hunk/jj", + "@hunk/sapling", + "@hunk/vcs" ] } diff --git a/.changeset/curly-pandas-package.md b/.changeset/curly-pandas-package.md new file mode 100644 index 000000000..f2a32554e --- /dev/null +++ b/.changeset/curly-pandas-package.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Move Hunk into a package-first Bun workspace, statically bundle the private Git, Jujutsu, and Sapling provider packages, and add stable package-level activation controls for managed extensions. diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index c20aae747..c50c24a54 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -1,5 +1,5 @@ /** - * Enforces module boundaries on the production import graph (`src/` plus `packages/`). + * Enforces module boundaries on the production import graph (`packages/hunk/src/` plus `packages/`). * * Each rule names one boundary of the target architecture described in * docs/module-boundaries.md. Pre-existing violations live in @@ -8,27 +8,27 @@ * `bun run deps:check` fails on any violation not in the baseline. */ -// UI files allowed to couple to src/app and src/session: the composition shell, the two +// UI files allowed to couple to packages/hunk/src/app and packages/hunk/src/session: the composition shell, the two // named session adapter hooks, and the session-navigation resolution helper those hooks -// share. Everything else in src/ui stays presentation-only. +// share. Everything else in packages/hunk/src/ui stays presentation-only. const UI_SESSION_ADAPTERS = [ - "^src/ui/App\\.tsx$", - "^src/ui/AppHost\\.tsx$", - "^src/ui/runInteractiveApp\\.tsx$", - "^src/ui/hooks/useHunkSessionBridge\\.ts$", - "^src/ui/hooks/useTerminalReview\\.ts$", - "^src/ui/lib/reviewState\\.ts$", + "^packages/hunk/src/ui/App\\.tsx$", + "^packages/hunk/src/ui/AppHost\\.tsx$", + "^packages/hunk/src/ui/runInteractiveApp\\.tsx$", + "^packages/hunk/src/ui/hooks/useHunkSessionBridge\\.ts$", + "^packages/hunk/src/ui/hooks/useTerminalReview\\.ts$", + "^packages/hunk/src/ui/lib/reviewState\\.ts$", ]; // Every way the shipped product is entered: the CLI, the highlight worker thread, the two -// published facades, and the skill generator. A module under src/ that no entry reaches, +// published facades, and the skill generator. A module under packages/hunk/src/ that no entry reaches, // directly or transitively, is not in the product. const PRODUCTION_ENTRY_POINTS = [ - "^src/main\\.tsx$", - "^src/highlightWorkerEntry\\.ts$", - "^src/opentui/index\\.ts$", - "^src/extension-api/index\\.ts$", - "^src/hunk-review/skillDocument\\.ts$", + "^packages/hunk/src/main\\.tsx$", + "^packages/hunk/src/highlightWorkerEntry\\.ts$", + "^packages/hunk/src/opentui/index\\.ts$", + "^packages/hunk/src/extension-api/index\\.ts$", + "^packages/hunk/src/hunk-review/skillDocument\\.ts$", ]; // Modules kept alive by tests alone. The cruise excludes tests, so these look unreachable @@ -37,12 +37,12 @@ const PRODUCTION_ENTRY_POINTS = [ // without the coverage to justify it. const TEST_ONLY_MODULES = [ // Note-height measurement exercised by the review-conformance corpus. - "^src/core/review/noteSize\\.ts$", + "^packages/hunk/src/core/review/noteSize\\.ts$", // The floating agent-note popover and its measurement helper. Nothing renders them since // notes moved into the diff flow as STML cards; their unit tests are the only consumers // left, so they are quarantined here until that call is made rather than deleted blind. - "^src/ui/components/panes/AgentCard\\.tsx$", - "^src/ui/lib/agentPopover\\.ts$", + "^packages/hunk/src/ui/components/panes/AgentCard\\.tsx$", + "^packages/hunk/src/ui/lib/agentPopover\\.ts$", ]; module.exports = { @@ -58,75 +58,78 @@ module.exports = { { name: "extension-api-is-import-free", comment: - "src/extension-api is the published contract; declaration emission publishes whatever it reaches (scripts/check-pack.ts gates the pack, this gates the graph).", + "packages/hunk/src/extension-api is the published contract; declaration emission publishes whatever it reaches (scripts/check-pack.ts gates the pack, this gates the graph).", severity: "error", - from: { path: "^src/extension-api/" }, - to: { path: "^(src|packages)/", pathNot: "^src/extension-api/" }, + from: { path: "^packages/hunk/src/extension-api/" }, + to: { path: "^packages/", pathNot: "^packages/hunk/src/extension-api/" }, }, { name: "lib-is-a-leaf", comment: - "src/lib holds dependency-free helpers usable from any tier; it may reach the import-free extension API contract and nothing else.", + "packages/hunk/src/lib holds dependency-free helpers usable from any tier; it may reach the import-free extension API contract and nothing else.", severity: "error", - from: { path: "^src/lib/" }, - to: { path: "^(src|packages)/", pathNot: "^src/(lib|extension-api)/" }, + from: { path: "^packages/hunk/src/lib/" }, + to: { path: "^packages/", pathNot: "^packages/hunk/src/(lib|extension-api)/" }, }, { name: "core-stays-domain", comment: - "src/core is the domain model. It may use src/lib and the extension-api contract, but never the UI, app composition, session brokering, extension host, or opentui facade above it.", + "packages/hunk/src/core is the domain model. It may use packages/hunk/src/lib and the extension-api contract, but never the UI, app composition, session brokering, extension host, or opentui facade above it.", severity: "error", - from: { path: "^src/core/" }, - to: { path: "^src/(ui|app|session|extensions|opentui)/" }, + from: { path: "^packages/hunk/src/core/" }, + to: { path: "^packages/hunk/src/(ui|app|session|extensions|opentui)/" }, }, { name: "extensions-host-stays-below-surfaces", comment: - "The extension host and bundled extensions sit below the surfaces that load them. The bundled UI tier (src/extensions/default/ui/) is exempt from the src/ui half by documented design: its dogfooding boundary is the published props contract — data, actions, theme — while rendering helpers are host code (see the sidebar module header).", + "The extension host and bundled extensions sit below the surfaces that load them. The bundled UI tier (packages/hunk/src/extensions/default/ui/) is exempt from the packages/hunk/src/ui half by documented design: its dogfooding boundary is the published props contract — data, actions, theme — while rendering helpers are host code (see the sidebar module header).", severity: "error", - from: { path: "^src/extensions/", pathNot: "^src/extensions/default/ui/" }, - to: { path: "^src/(ui|app|session|opentui)/" }, + from: { + path: "^packages/hunk/src/extensions/", + pathNot: "^packages/hunk/src/extensions/default/ui/", + }, + to: { path: "^packages/hunk/src/(ui|app|session|opentui)/" }, }, { name: "bundled-ui-extensions-render-only", comment: - "The bundled UI tier may consume src/ui rendering helpers as host code, but composition and session brokering stay out of reach — a pane gets its data and actions through the published props.", + "The bundled UI tier may consume packages/hunk/src/ui rendering helpers as host code, but composition and session brokering stay out of reach — a pane gets its data and actions through the published props.", severity: "error", - from: { path: "^src/extensions/default/ui/" }, - to: { path: "^src/(app|session|opentui)/" }, + from: { path: "^packages/hunk/src/extensions/default/ui/" }, + to: { path: "^packages/hunk/src/(app|session|opentui)/" }, }, { name: "session-stays-below-app-and-ui", comment: - "src/session brokers transport and protocol. It consumes core and packages; the app tier registers into it, not the other way round.", + "packages/hunk/src/session brokers transport and protocol. It consumes core and packages; the app tier registers into it, not the other way round.", severity: "error", - from: { path: "^src/session/" }, - to: { path: "^src/(ui|app|extensions|opentui)/" }, + from: { path: "^packages/hunk/src/session/" }, + to: { path: "^packages/hunk/src/(ui|app|extensions|opentui)/" }, }, { name: "app-composes-without-ui", comment: - "src/app wires core, extensions, and session together for startup; rendering stays in src/ui, which imports app — never the reverse.", + "packages/hunk/src/app wires core, extensions, and session together for startup; rendering stays in packages/hunk/src/ui, which imports app — never the reverse.", severity: "error", - from: { path: "^src/app/" }, - to: { path: "^src/(ui|opentui)/" }, + from: { path: "^packages/hunk/src/app/" }, + to: { path: "^packages/hunk/src/(ui|opentui)/" }, }, { name: "ui-couples-to-session-via-adapters", comment: - "Only the composition shell and the named session adapter hooks may import src/app or src/session; ordinary UI components and helpers stay presentation-only so the review surface can move to other hosts.", + "Only the composition shell and the named session adapter hooks may import packages/hunk/src/app or packages/hunk/src/session; ordinary UI components and helpers stay presentation-only so the review surface can move to other hosts.", severity: "error", - from: { path: "^src/ui/", pathNot: UI_SESSION_ADAPTERS }, - to: { path: "^src/(app|session)/" }, + from: { path: "^packages/hunk/src/ui/", pathNot: UI_SESSION_ADAPTERS }, + to: { path: "^packages/hunk/src/(app|session)/" }, }, { name: "no-dead-modules", comment: - "Every module under src/ earns its place by being reachable from an entry point. Dead files are worse than clutter: they still import, so they hold boundaries hostage and answer questions nobody asks. `orphan` only catches fully disconnected files, which misses dead code that still has dependencies — reachability catches both. A flagged module is either deleted or, if tests are its only real consumer, listed in TEST_ONLY_MODULES with a reason.", + "Every module under packages/hunk/src/ earns its place by being reachable from an entry point. Dead files are worse than clutter: they still import, so they hold boundaries hostage and answer questions nobody asks. `orphan` only catches fully disconnected files, which misses dead code that still has dependencies — reachability catches both. A flagged module is either deleted or, if tests are its only real consumer, listed in TEST_ONLY_MODULES with a reason.", severity: "error", from: { path: PRODUCTION_ENTRY_POINTS }, to: { - path: "^src/", + path: "^packages/hunk/src/", pathNot: [...PRODUCTION_ENTRY_POINTS, ...TEST_ONLY_MODULES], reachable: false, }, @@ -137,36 +140,65 @@ module.exports = { "core/bootstrap.ts composes the leaves: it names the changeset, the parsed input, the resolved preferences, and the detected theme mode to describe one launch. A module directory importing it back would invert that layering and rebuild the grab-bag cycle the 2026-08 phases dismantled. core/changeset/loaders.ts is the single exception — loadAppBootstrap assembles the value, so it names the shape it returns; its natural home is the app tier, and moving it there retires this exception.", severity: "error", from: { - path: "^src/core/(changeset|run|process|install|review|vcs|watch|patch|theme)/", - pathNot: "^src/core/changeset/loaders\\.ts$", + path: "^packages/hunk/src/core/(changeset|run|process|install|review|vcs|watch|patch|theme)/", + pathNot: "^packages/hunk/src/core/changeset/loaders\\.ts$", }, - to: { path: "^src/core/bootstrap\\.ts$" }, + to: { path: "^packages/hunk/src/core/bootstrap\\.ts$" }, }, { name: "review-reducer-is-module-internal", comment: - "The review reducer applies actions; callers state intent instead, so surfaces cannot reach past planReviewIntent into the transition table. First of the per-module interior rules — this establishes the mechanism later phases extend to the rest of src/core (identity.ts and the other named model modules stay public by design).", + "The review reducer applies actions; callers state intent instead, so surfaces cannot reach past planReviewIntent into the transition table. First of the per-module interior rules — this establishes the mechanism later phases extend to the rest of packages/hunk/src/core (identity.ts and the other named model modules stay public by design).", severity: "error", - from: { path: "^src/", pathNot: "^src/core/review/" }, - to: { path: "^src/core/review/reducer\\.ts$" }, + from: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/core/review/" }, + to: { path: "^packages/hunk/src/core/review/reducer\\.ts$" }, }, { name: "changeset-internals-stay-in-module", comment: "core/changeset owns the changeset model and the pipeline that acquires one. Outsiders name the model, the loaders, and the per-file helpers they build on (model, loaders, diffFile, fileSource, fileLanguage, binary, diffPaths, hunkHeader, hunkSummary); the patch-to-model parse, the Pierre extension-table lookup, and the sidecar reader are steps inside that pipeline, reached through the loaders instead.", severity: "error", - from: { path: "^src/", pathNot: "^src/core/changeset/" }, + from: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/core/changeset/" }, to: { - path: "^src/core/changeset/(fromPatch|fileLanguageLookup|sidecar)\\.ts$", + path: "^packages/hunk/src/core/changeset/(fromPatch|fileLanguageLookup|sidecar)\\.ts$", }, }, { name: "packages-stay-standalone", comment: - "Workspace packages are standalone publishable units; they never import the app source tree.", + "Private workspaces never import application internals; providers use only hunkdiff/extension.", severity: "error", - from: { path: "^packages/" }, - to: { path: "^src/" }, + from: { path: "^packages/(?!hunk/)" }, + to: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/extension-api/" }, + }, + { + name: "vcs-domain-is-a-leaf", + comment: + "Provider-neutral VCS infrastructure cannot depend on the app or provider implementations.", + severity: "error", + from: { path: "^packages/hunk-vcs/" }, + to: { + path: "^packages/(hunk/src|hunk-git|hunk-jj|hunk-sapling)/", + pathNot: "^packages/hunk/src/extension-api/", + }, + }, + { + name: "providers-use-public-api-only", + comment: + "Bundled providers depend on hunkdiff/extension and shared utilities, never private app modules.", + severity: "error", + from: { path: "^packages/hunk-(git|jj|sapling)/" }, + to: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/extension-api/" }, + }, + { + name: "only-bundled-composition-imports-providers", + comment: "The app activates private provider packages at one static composition seam.", + severity: "error", + from: { + path: "^packages/hunk/src/", + pathNot: "^packages/hunk/src/extensions/bundledPackages\\.ts$", + }, + to: { path: "^packages/hunk-(git|jj|sapling)/" }, }, ], options: { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eabc5cf5d..b5d491707 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,8 @@ jobs: pkg_dir="$(mktemp -d)" install_dir="$(mktemp -d)" node_dir="$(dirname "$(command -v node)")" - npm pack --pack-destination "$pkg_dir" >/dev/null + bun run stage:source-npm + npm pack ./dist/source-npm/hunkdiff --ignore-scripts --pack-destination "$pkg_dir" >/dev/null pkg="$(find "$pkg_dir" -maxdepth 1 -name 'hunkdiff-*.tgz' | head -n1)" npm install -g --prefix "$install_dir" "$pkg" PATH="$install_dir/bin:$node_dir:/usr/bin:/bin" diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 86b125a71..eba4dcfeb 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -212,7 +212,8 @@ jobs: pkg_dir="$(mktemp -d)" install_dir="$(mktemp -d)" node_dir="$(dirname "$(command -v node)")" - npm pack --pack-destination "$pkg_dir" >/dev/null + bun run stage:source-npm + npm pack ./dist/source-npm/hunkdiff --ignore-scripts --pack-destination "$pkg_dir" >/dev/null pkg="$(find "$pkg_dir" -maxdepth 1 -name 'hunkdiff-*.tgz' | head -n1)" npm install -g --prefix "$install_dir" "$pkg" PATH="$install_dir/bin:$node_dir:/usr/bin:/bin" diff --git a/AGENTS.md b/AGENTS.md index 1ffb65328..0ada4edb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ DiffFile[] -> projectReviewDocument -> ReviewDocumentV1 -> ReviewStore ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> surface projection ``` -- **Model:** `src/core/review/{types,document,identity}.ts` owns the ordered, JSON-safe document. +- **Model:** `packages/hunk/src/core/review/{types,document,identity}.ts` owns the ordered, JSON-safe document. File order is review/sidebar order; use `key` (referenced as `fileKey` elsewhere), `contentIdentity`, and `sourceIdentity` (cached source text additionally requires `sourceAttested`) — not runtime IDs or indexes — across reloads/surfaces. @@ -53,9 +53,9 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> repaid seam finding deletes copies, adds a file or banned-symbol tombstone and adversarial fixture, registers consumers, and updates `docs/browser-review-seam-audit.md`. -- Bundled VCS implementations live under `src/extensions/default/vcs//` and consume the - public extension contract; `src/app` composes their registrations into the provider-neutral - core VCS catalog. Do not add provider commands, spawning, or source readers under `src/core`. +- Bundled VCS implementations live under `packages/hunk-/src/` and consume the public + extension contract; `packages/hunk/src/app` composes their registrations into the provider-neutral + core VCS catalog. Do not add provider commands, spawning, or source readers under `packages/hunk/src/core`. - `hunk daemon serve` is the one loopback daemon for all live sessions; sessions auto-start and register with it rather than opening per-TUI ports. Reuse `classifyReviewPublication` and `ReviewChunkAssembler` for publication ordering and bounded, digest-verified resources. Browser @@ -63,17 +63,17 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> its digest. Transport semantics come from the browser-safe review protocol modules and the existing intent path. See `docs/browser-review-rebuild.md` and the relevant module headers. - User and bundled extensions share one API and registry. Shipped VCS backends and the built-in - sidebar register through the public contract. Keep `src/extension-api/types.ts` import-free, + sidebar register through the public contract. Keep `packages/hunk/src/extension-api/types.ts` import-free, bundled VCS renderer-free, repo-local extensions trust-gated, and bundled extensions active under `--no-extensions`. See `docs/extension-architecture.md`, `docs/extensions.md`, and - `skills/hunk-extensions/SKILL.md`. + `packages/hunk/skills/hunk-extensions/SKILL.md`. - Sidecar file order is intentional sidebar and review-stream order. - Derive shared rendering, navigation, scrolling, and note behavior from one planning layer. Make shared geometry explicit, and remove obsolete paths instead of retaining parallel implementations. ## architectural rules -- Import boundaries between `src/` top-level trees are enforced by `bun run deps:check` +- Import boundaries between `packages/hunk/src/` top-level trees are enforced by `bun run deps:check` (dependency-cruiser; rules in `.dependency-cruiser.cjs`, target tiers in `docs/module-boundaries.md`). The known-violations baseline is shrink-only: fix an edge, rerun `bun run deps:baseline`, never add to it. @@ -95,16 +95,16 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> ## theme guidance -- Built-in theme ids and source metadata live in `src/core/theme/catalog.ts`; `src/ui/themes.ts` +- Built-in theme ids and source metadata live in `packages/hunk/src/core/theme/catalog.ts`; `packages/hunk/src/ui/themes.ts` derives Hunk's semantic `AppTheme` values. - When adding or renaming a built-in theme, update validation, public exports, docs/examples, the appropriate Changeset, and tests. Keep source palette tokens separate from semantic mappings and cover non-trivial derived colors. -- `BUNDLED_SHIKI_THEME_DIFF_COLORS` in `src/core/theme/catalog.ts` is generated. Edit the sourcing policy in `scripts/generate-theme-diff-colors.ts`, then run `bun run generate:theme-colors`. +- `BUNDLED_SHIKI_THEME_DIFF_COLORS` in `packages/hunk/src/core/theme/catalog.ts` is generated. Edit the sourcing policy in `scripts/generate-theme-diff-colors.ts`, then run `bun run generate:theme-colors`. ## testing -- Colocate unit tests with the code they cover (`src/core/foo.ts` + `src/core/foo.test.ts`, `src/ui/AppHost.*.test.tsx`, `src/ui/lib/*.test.ts`). +- Colocate unit tests with the code they cover (`packages/hunk/src/core/foo.ts` + `packages/hunk/src/core/foo.test.ts`, `packages/hunk/src/ui/AppHost.*.test.tsx`, `packages/hunk/src/ui/lib/*.test.ts`). - Put shared unit-test helpers in `test/helpers/`. - Name test helpers so they explicitly include `Test` and are clearly test-only (`createTestDiffFile`). - Use repo-level `test/` directories by intent: @@ -145,11 +145,11 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> - Agent context belongs beside the code, not hidden in a separate mode or workflow. - Agent notes are hunk-specific: show notes for the selected hunk, render them in the diff flow near the annotated row, and keep a clear spatial relationship to the code they explain. - Keep note behavior explicit. If the UI intentionally prioritizes one note, one selection, or one active target, encode that as a named policy rather than scattering array-index assumptions through the codebase. -- STML markup notes (experimental) live in `src/ui/lib/stml/`. The layout engine is deliberately a deterministic line layout, not OpenTUI flexbox: the row-windowed review stream needs exact note heights before mount, so `(markup, width)` must always produce the same lines. Colors stay symbolic until render time so measurement never needs a theme. Do not "simplify" this into flexbox renderables, and keep note-card geometry in `agentNoteGeometry` as the single source for rendering, measurement, and agent-facing width reporting. +- STML markup notes (experimental) live in `packages/hunk/src/ui/lib/stml/`. The layout engine is deliberately a deterministic line layout, not OpenTUI flexbox: the row-windowed review stream needs exact note heights before mount, so `(markup, width)` must always produce the same lines. Colors stay symbolic until render time so measurement never needs a theme. Do not "simplify" this into flexbox renderables, and keep note-card geometry in `agentNoteGeometry` as the single source for rendering, measurement, and agent-facing width reporting. - Keep temporary sidecars concise and review-oriented. Their file order is intentional, while the visible note UI remains hunk-note driven rather than showing generic explainer cards. -- Agents review via `skills/hunk-review/SKILL.md` using `hunk session *` commands; do not run interactive TUI commands directly. -- `skills/hunk-review/SKILL.md` is generated. Edit `src/hunk-review/skillDocument.ts`, `src/session/agent/surface.ts`, or `src/session/agent/errors.ts`, then run `bun run generate:skill`; never hand-edit the skill file. +- Agents review via `packages/hunk/skills/hunk-review/SKILL.md` using `hunk session *` commands; do not run interactive TUI commands directly. +- `packages/hunk/skills/hunk-review/SKILL.md` is generated. Edit `packages/hunk/src/hunk-review/skillDocument.ts`, `packages/hunk/src/session/agent/surface.ts`, or `packages/hunk/src/session/agent/errors.ts`, then run `bun run generate:skill`; never hand-edit the skill file. ## binary notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84b7d8c4f..9f027c9f2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ Please discuss a change before implementation when it introduces or substantiall Before adding a built-in workflow, integration, or alternate presentation, check whether it can be implemented as an extension. Extensions are usually the better home for opt-in behavior such as VCS integrations, sidebars, file views, commands, keyboard modes, line highlighters, and repository-specific review workflows. -Start with [`docs/extensions.md`](docs/extensions.md) and the checked-in [`examples/extensions/`](examples/extensions/). If you use a coding agent, [`skills/hunk-extensions/SKILL.md`](skills/hunk-extensions/SKILL.md) maps the public API and its implementation. A small prototype is often the fastest way to learn whether the current API is enough. +Start with [`docs/extensions.md`](docs/extensions.md) and the checked-in [`examples/extensions/`](examples/extensions/). If you use a coding agent, [`packages/hunk/skills/hunk-extensions/SKILL.md`](packages/hunk/skills/hunk-extensions/SKILL.md) maps the public API and its implementation. A small prototype is often the fastest way to learn whether the current API is enough. If the extension API cannot express the idea, do not immediately bypass it with feature-specific core code. Explain: @@ -80,7 +80,7 @@ Install dependencies and run Hunk from source: ```bash bun install -bun run src/main.tsx -- diff +bun run start -- -- diff ``` Nix users can run `nix develop` or use [direnv](https://direnv.net/) to enter the development shell. diff --git a/benchmarks/bootstrap-load.ts b/benchmarks/bootstrap-load.ts index 95c709318..6da0bad5d 100644 --- a/benchmarks/bootstrap-load.ts +++ b/benchmarks/bootstrap-load.ts @@ -5,8 +5,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { performance } from "perf_hooks"; import { parsePatchFiles } from "@pierre/diffs"; -import { getBundledVcsCatalog } from "../src/app/vcsCatalog"; -import { loadAppBootstrap } from "../src/core/changeset/loaders"; +import { getBundledVcsCatalog } from "../packages/hunk/src/app/vcsCatalog"; +import { loadAppBootstrap } from "../packages/hunk/src/core/changeset/loaders"; const FILE_COUNT = 64; const LINES_PER_FILE = 420; diff --git a/benchmarks/changeset-parse.ts b/benchmarks/changeset-parse.ts index a090f999b..fdd642521 100644 --- a/benchmarks/changeset-parse.ts +++ b/benchmarks/changeset-parse.ts @@ -1,9 +1,9 @@ // Benchmark raw patch parsing and sanitized DiffFile construction for several diff shapes. import { performance } from "perf_hooks"; import { parsePatchFiles } from "@pierre/diffs"; -import { buildDiffFile } from "../src/core/changeset/diffFile"; -import { findPatchChunk, splitPatchIntoFileChunks } from "../src/core/patch/chunks"; -import { sanitizePatchText } from "../src/core/patch/sanitize"; +import { buildDiffFile } from "../packages/hunk/src/core/changeset/diffFile"; +import { findPatchChunk, splitPatchIntoFileChunks } from "../packages/hunk/src/core/patch/chunks"; +import { sanitizePatchText } from "../packages/hunk/src/core/patch/sanitize"; import { createSyntheticPatch } from "./lib/fixtures"; interface Scenario { diff --git a/benchmarks/compact-highlight-payload.ts b/benchmarks/compact-highlight-payload.ts index f09189b59..674c611c1 100644 --- a/benchmarks/compact-highlight-payload.ts +++ b/benchmarks/compact-highlight-payload.ts @@ -3,20 +3,20 @@ // normalizes it, and the terminal paint seam remains a later milestone. import { performance } from "node:perf_hooks"; import { cleanLastNewline, parseDiffFromFile } from "@pierre/diffs"; -import type { DiffFile } from "../src/core/changeset/model"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; import { buildSplitRows, loadHighlightedDiff, type HighlightedDiffCode, -} from "../src/ui/diff/diffRows"; +} from "../packages/hunk/src/ui/diff/diffRows"; import { compactHighlightRunsForLine, compactHighlightTransferList, compactHighlightedDiffByteLength, encodeCompactHighlightedDiff, validateCompactHighlightedDiff, -} from "../src/ui/diff/worker"; -import { resolveTheme } from "../src/ui/themes"; +} from "../packages/hunk/src/ui/diff/worker"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; const LINE_COUNT = Number(process.env.HUNK_COMPACT_HIGHLIGHT_LINES ?? 8_000); const SAMPLES = Number(process.env.HUNK_COMPACT_HIGHLIGHT_SAMPLES ?? 7); diff --git a/benchmarks/geometry-memory.ts b/benchmarks/geometry-memory.ts index 04061d66a..53f402157 100644 --- a/benchmarks/geometry-memory.ts +++ b/benchmarks/geometry-memory.ts @@ -1,8 +1,8 @@ // Track retained memory for the all-files geometry cache used by review scrolling/navigation. import { heapStats } from "bun:jsc"; import { performance } from "node:perf_hooks"; -import { measureDiffSectionGeometry } from "../src/ui/diff/diffSectionGeometry"; -import { resolveTheme } from "../src/ui/themes"; +import { measureDiffSectionGeometry } from "../packages/hunk/src/ui/diff/diffSectionGeometry"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { createGiantSingleDiffFile, createLargeSplitStreamBootstrap, diff --git a/benchmarks/highlight-cache-layers.ts b/benchmarks/highlight-cache-layers.ts index f77ad098b..4b165e2ad 100644 --- a/benchmarks/highlight-cache-layers.ts +++ b/benchmarks/highlight-cache-layers.ts @@ -1,10 +1,10 @@ // Compare a resident main-process cache hit with a worker-LRU revisit after main-cache eviction. import { performance } from "node:perf_hooks"; import { parseDiffFromFile } from "@pierre/diffs"; -import type { DiffFile } from "../src/core/changeset/model"; -import { resolveTheme } from "../src/ui/themes"; -import { disposeHighlightWorker } from "../src/ui/diff/worker/highlightWorkerClient"; -import { prefetchHighlightedDiff } from "../src/ui/diff/useHighlightedDiff"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; +import { disposeHighlightWorker } from "../packages/hunk/src/ui/diff/worker/highlightWorkerClient"; +import { prefetchHighlightedDiff } from "../packages/hunk/src/ui/diff/useHighlightedDiff"; const lineCount = 8_000; const theme = resolveTheme("github-dark-default", null); diff --git a/benchmarks/highlight-prefetch.ts b/benchmarks/highlight-prefetch.ts index 1ccd7946c..340e14338 100644 --- a/benchmarks/highlight-prefetch.ts +++ b/benchmarks/highlight-prefetch.ts @@ -5,9 +5,9 @@ import React from "react"; import { testRender } from "@opentui/react/test-utils"; import { parseDiffFromFile } from "@pierre/diffs"; import { act } from "react"; -import { AppHost } from "../src/ui/AppHost"; -import type { AppBootstrap } from "../src/core/bootstrap"; -import type { DiffFile } from "../src/core/changeset/model"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; +import type { AppBootstrap } from "../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; function createDiffFile(index: number, marker: string): DiffFile { const path = `src/example${index}.ts`; diff --git a/benchmarks/huge-stream.ts b/benchmarks/huge-stream.ts index c4c006ac2..1d96bc196 100644 --- a/benchmarks/huge-stream.ts +++ b/benchmarks/huge-stream.ts @@ -7,7 +7,7 @@ import { performance } from "node:perf_hooks"; import { testRender } from "@opentui/react/test-utils"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createHugeStreamBootstrap, GIANT_SINGLE_FILE_LINES, diff --git a/benchmarks/interaction-latency.ts b/benchmarks/interaction-latency.ts index d1c3ee983..6fc0ed7e8 100644 --- a/benchmarks/interaction-latency.ts +++ b/benchmarks/interaction-latency.ts @@ -4,7 +4,7 @@ import { performance } from "node:perf_hooks"; import { testRender } from "@opentui/react/test-utils"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createLargeSplitStreamBootstrap, DEFAULT_FILE_COUNT, diff --git a/benchmarks/large-stream-fixture.ts b/benchmarks/large-stream-fixture.ts index c55366de3..c6dd56af9 100644 --- a/benchmarks/large-stream-fixture.ts +++ b/benchmarks/large-stream-fixture.ts @@ -1,6 +1,6 @@ import { parseDiffFromFile, parsePatchFiles } from "@pierre/diffs"; -import type { AppBootstrap } from "../src/core/bootstrap"; -import type { DiffFile } from "../src/core/changeset/model"; +import type { AppBootstrap } from "../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; export const DEFAULT_FILE_COUNT = 180; export const DEFAULT_LINES_PER_FILE = 120; diff --git a/benchmarks/large-stream-profile.ts b/benchmarks/large-stream-profile.ts index c5d1b8d97..eeaa64fc0 100644 --- a/benchmarks/large-stream-profile.ts +++ b/benchmarks/large-stream-profile.ts @@ -1,10 +1,10 @@ // Profile large split-mode review streams by timing the main pure planning stages // before the React tree and renderer get involved. import { performance } from "perf_hooks"; -import { buildSplitRows } from "../src/ui/diff/diffRows"; -import { buildReviewRenderPlan } from "../src/ui/diff/reviewRenderPlan"; -import { measureDiffSectionGeometry } from "../src/ui/diff/diffSectionGeometry"; -import { resolveTheme } from "../src/ui/themes"; +import { buildSplitRows } from "../packages/hunk/src/ui/diff/diffRows"; +import { buildReviewRenderPlan } from "../packages/hunk/src/ui/diff/reviewRenderPlan"; +import { measureDiffSectionGeometry } from "../packages/hunk/src/ui/diff/diffSectionGeometry"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { createLargeSplitStreamFiles, DEFAULT_FILE_COUNT, diff --git a/benchmarks/large-stream.ts b/benchmarks/large-stream.ts index 9a82c730b..1c460762d 100644 --- a/benchmarks/large-stream.ts +++ b/benchmarks/large-stream.ts @@ -2,8 +2,8 @@ import { performance } from "perf_hooks"; import React from "react"; import { testRender } from "@opentui/react/test-utils"; -import { AppHost } from "../src/ui/AppHost"; -import { VIEWPORT_READ_COALESCE_MS } from "../src/ui/lib/viewportTiming"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; +import { VIEWPORT_READ_COALESCE_MS } from "../packages/hunk/src/ui/lib/viewportTiming"; import { createLargeSplitStreamBootstrap, DEFAULT_FILE_COUNT, diff --git a/benchmarks/memory.ts b/benchmarks/memory.ts index cac06e3b3..34fb51d16 100644 --- a/benchmarks/memory.ts +++ b/benchmarks/memory.ts @@ -3,10 +3,10 @@ import { performance } from "perf_hooks"; import React from "react"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { buildSplitRows } from "../src/ui/diff/diffRows"; -import { buildReviewRenderPlan } from "../src/ui/diff/reviewRenderPlan"; -import { resolveTheme } from "../src/ui/themes"; -import { AppHost } from "../src/ui/AppHost"; +import { buildSplitRows } from "../packages/hunk/src/ui/diff/diffRows"; +import { buildReviewRenderPlan } from "../packages/hunk/src/ui/diff/reviewRenderPlan"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; const viewport = { width: 240, height: 28 } as const; diff --git a/benchmarks/navigation-memory.ts b/benchmarks/navigation-memory.ts index 18dcca633..a6193feec 100644 --- a/benchmarks/navigation-memory.ts +++ b/benchmarks/navigation-memory.ts @@ -3,7 +3,7 @@ import { testRender } from "@opentui/react/test-utils"; import { performance } from "node:perf_hooks"; import React from "react"; import { act } from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; type MemorySample = { diff --git a/benchmarks/non-ascii-stream.ts b/benchmarks/non-ascii-stream.ts index 9ce73db95..9c73ed441 100644 --- a/benchmarks/non-ascii-stream.ts +++ b/benchmarks/non-ascii-stream.ts @@ -5,7 +5,7 @@ import { performance } from "node:perf_hooks"; import { testRender } from "@opentui/react/test-utils"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; import { destroyRenderer, diff --git a/benchmarks/render-layout.ts b/benchmarks/render-layout.ts index d8c974151..6d5effc07 100644 --- a/benchmarks/render-layout.ts +++ b/benchmarks/render-layout.ts @@ -1,9 +1,9 @@ // Benchmark pure diff row/layout planning across split, stack, and size-shape cases. import { performance } from "perf_hooks"; -import { buildSplitRows, buildStackRows } from "../src/ui/diff/diffRows"; -import { buildReviewRenderPlan } from "../src/ui/diff/reviewRenderPlan"; -import { measureDiffSectionGeometry } from "../src/ui/diff/diffSectionGeometry"; -import { resolveTheme } from "../src/ui/themes"; +import { buildSplitRows, buildStackRows } from "../packages/hunk/src/ui/diff/diffRows"; +import { buildReviewRenderPlan } from "../packages/hunk/src/ui/diff/reviewRenderPlan"; +import { measureDiffSectionGeometry } from "../packages/hunk/src/ui/diff/diffSectionGeometry"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { createLargeSplitStreamFiles } from "./large-stream-fixture"; const theme = resolveTheme("midnight", null); diff --git a/benchmarks/resize-memory.ts b/benchmarks/resize-memory.ts index ca3b59e74..8e3840c19 100644 --- a/benchmarks/resize-memory.ts +++ b/benchmarks/resize-memory.ts @@ -4,7 +4,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { performance } from "node:perf_hooks"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; type MemorySample = { diff --git a/benchmarks/run.ts b/benchmarks/run.ts index d62688a25..9dd4e74cd 100644 --- a/benchmarks/run.ts +++ b/benchmarks/run.ts @@ -99,7 +99,9 @@ function gitSha() { async function packageVersion() { try { - const packageJson = JSON.parse(await Bun.file("package.json").text()) as { version?: string }; + const packageJson = JSON.parse(await Bun.file("packages/hunk/package.json").text()) as { + version?: string; + }; return packageJson.version; } catch { return undefined; diff --git a/benchmarks/terminal-width.ts b/benchmarks/terminal-width.ts index 012a323d5..53a32c565 100644 --- a/benchmarks/terminal-width.ts +++ b/benchmarks/terminal-width.ts @@ -1,7 +1,7 @@ // Benchmark Hunk's scalar fast path and cached complex-cluster path against string-width. import { performance } from "node:perf_hooks"; import stringWidth from "string-width"; -import { measureTextWidth } from "../src/ui/lib/text"; +import { measureTextWidth } from "../packages/hunk/src/ui/lib/text"; const ITERATIONS = 2_000; const WARMUP_ITERATIONS = 50; diff --git a/benchmarks/worker-highlight-cache.ts b/benchmarks/worker-highlight-cache.ts index 2e344423c..70a7d692b 100644 --- a/benchmarks/worker-highlight-cache.ts +++ b/benchmarks/worker-highlight-cache.ts @@ -4,8 +4,8 @@ import { parseDiffFromFile } from "@pierre/diffs"; import { disposeHighlightWorker, highlightDiffInWorker, -} from "../src/ui/diff/worker/highlightWorkerClient"; -import { compactHighlightedDiffByteLength } from "../src/ui/diff/worker/highlightCompact"; +} from "../packages/hunk/src/ui/diff/worker/highlightWorkerClient"; +import { compactHighlightedDiffByteLength } from "../packages/hunk/src/ui/diff/worker/highlightCompact"; const lineCount = 8_000; const additions = Array.from( diff --git a/benchmarks/working-tree-load.ts b/benchmarks/working-tree-load.ts index d8156cdef..285990174 100644 --- a/benchmarks/working-tree-load.ts +++ b/benchmarks/working-tree-load.ts @@ -1,7 +1,7 @@ // Benchmark git-backed working-tree loading, including untracked file handling. import { performance } from "perf_hooks"; -import { getBundledVcsCatalog } from "../src/app/vcsCatalog"; -import { loadAppBootstrap } from "../src/core/changeset/loaders"; +import { getBundledVcsCatalog } from "../packages/hunk/src/app/vcsCatalog"; +import { loadAppBootstrap } from "../packages/hunk/src/core/changeset/loaders"; import { addUntrackedFiles, createChangedRepo } from "./lib/fixtures"; interface Scenario { diff --git a/benchmarks/wrapped-cjk.ts b/benchmarks/wrapped-cjk.ts index 760e441f5..efe32beb7 100644 --- a/benchmarks/wrapped-cjk.ts +++ b/benchmarks/wrapped-cjk.ts @@ -4,12 +4,12 @@ import { performance } from "node:perf_hooks"; import { parsePatchFiles } from "@pierre/diffs"; import { testRender } from "@opentui/react/test-utils"; import React, { act } from "react"; -import type { AppBootstrap } from "../src/core/bootstrap"; -import type { DiffFile } from "../src/core/changeset/model"; -import { AppHost } from "../src/ui/AppHost"; -import { prefetchHighlightedDiff } from "../src/ui/diff/useHighlightedDiff"; -import { VIEWPORT_READ_COALESCE_MS } from "../src/ui/lib/viewportTiming"; -import { resolveTheme } from "../src/ui/themes"; +import type { AppBootstrap } from "../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; +import { prefetchHighlightedDiff } from "../packages/hunk/src/ui/diff/useHighlightedDiff"; +import { VIEWPORT_READ_COALESCE_MS } from "../packages/hunk/src/ui/lib/viewportTiming"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { destroyRenderer, renderPass, diff --git a/bun.lock b/bun.lock index 333ce24c8..c41c3858f 100644 --- a/bun.lock +++ b/bun.lock @@ -4,16 +4,6 @@ "workspaces": { "": { "name": "hunk", - "dependencies": { - "bun": "^1.3.14", - "chokidar": "^4.0.3", - "commander": "^14.0.3", - "diff": "^8.0.3", - "get-east-asian-width": "^1.5.0", - "shell-quote": "1.9.0", - "string-width": "^8.2.1", - "zod": "~4.4.3", - }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", "@hunk/session-broker": "workspace:*", @@ -28,6 +18,7 @@ "@types/react": "^19.2.14", "@types/ws": "^8.18.1", "dependency-cruiser": "18.2.0", + "diff": "^8.0.3", "knip": "^6.32.0", "lint-staged": "^16.4.0", "marked": "17.0.1", @@ -35,8 +26,45 @@ "oxlint": "^1.56.0", "react": "^19.2.4", "simple-git-hooks": "^2.13.1", + "string-width": "^8.2.1", "tuistory": "^0.11.0", "typescript": "^5.9.3", + "ws": "^8.18.3", + }, + }, + "packages/hunk": { + "name": "hunkdiff", + "version": "0.21.1", + "bin": { + "hunk": "./bin/hunk.cjs", + "hunkdiff": "./bin/hunk.cjs", + }, + "dependencies": { + "bun": "^1.3.14", + "chokidar": "^4.0.3", + "commander": "^14.0.3", + "diff": "^8.0.3", + "get-east-asian-width": "^1.5.0", + "shell-quote": "1.9.0", + "string-width": "^8.2.1", + "zod": "~4.4.3", + }, + "devDependencies": { + "@hunk/git": "workspace:*", + "@hunk/jj": "workspace:*", + "@hunk/sapling": "workspace:*", + "@hunk/session-broker": "workspace:*", + "@hunk/session-broker-bun": "workspace:*", + "@hunk/session-broker-core": "workspace:*", + "@hunk/vcs": "workspace:*", + "@opentui/core": "^0.5.6", + "@opentui/react": "^0.5.6", + "@pierre/diffs": "1.3.5", + "@shikijs/themes": "3.23.0", + "@types/bun": "1.3.14", + "@types/react": "^19.2.14", + "react": "^19.2.4", + "typescript": "^5.9.3", }, "peerDependencies": { "@opentui/core": "^0.5.6", @@ -48,6 +76,52 @@ "@pierre/diffs", ], }, + "packages/hunk-git": { + "name": "@hunk/git", + "version": "0.0.0", + "dependencies": { + "@hunk/vcs": "workspace:*", + }, + "devDependencies": { + "hunkdiff": "workspace:*", + }, + "peerDependencies": { + "hunkdiff": "^0.21.1", + }, + }, + "packages/hunk-jj": { + "name": "@hunk/jj", + "version": "0.0.0", + "dependencies": { + "@hunk/vcs": "workspace:*", + }, + "devDependencies": { + "hunkdiff": "workspace:*", + }, + "peerDependencies": { + "hunkdiff": "^0.21.1", + }, + }, + "packages/hunk-sapling": { + "name": "@hunk/sapling", + "version": "0.0.0", + "dependencies": { + "@hunk/vcs": "workspace:*", + }, + "devDependencies": { + "hunkdiff": "workspace:*", + }, + "peerDependencies": { + "hunkdiff": "^0.21.1", + }, + }, + "packages/hunk-vcs": { + "name": "@hunk/vcs", + "version": "0.0.0", + "devDependencies": { + "hunkdiff": "workspace:*", + }, + }, "packages/session-broker": { "name": "@hunk/session-broker", "version": "0.0.0", @@ -103,6 +177,12 @@ "@hono/node-ws": ["@hono/node-ws@1.3.1", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.11", "hono": "^4.6.0" } }, "sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA=="], + "@hunk/git": ["@hunk/git@workspace:packages/hunk-git"], + + "@hunk/jj": ["@hunk/jj@workspace:packages/hunk-jj"], + + "@hunk/sapling": ["@hunk/sapling@workspace:packages/hunk-sapling"], + "@hunk/session-broker": ["@hunk/session-broker@workspace:packages/session-broker"], "@hunk/session-broker-bun": ["@hunk/session-broker-bun@workspace:packages/session-broker-bun"], @@ -113,6 +193,8 @@ "@hunk/term-video": ["@hunk/term-video@workspace:packages/term-video"], + "@hunk/vcs": ["@hunk/vcs@workspace:packages/hunk-vcs"], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], "@opentui/core": ["@opentui/core@0.5.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.6", "@opentui/core-darwin-x64": "0.5.6", "@opentui/core-linux-arm64": "0.5.6", "@opentui/core-linux-arm64-musl": "0.5.6", "@opentui/core-linux-x64": "0.5.6", "@opentui/core-linux-x64-musl": "0.5.6", "@opentui/core-win32-arm64": "0.5.6", "@opentui/core-win32-x64": "0.5.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ=="], @@ -495,6 +577,8 @@ "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "hunkdiff": ["hunkdiff@workspace:packages/hunk"], + "ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 5020d45bc..318abc45b 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -8,7 +8,7 @@ Use Hunk with agents in two ways: ## Recommended workflow: steer a live Hunk window 1. Open Hunk in one terminal with a normal review command such as `hunk diff` or `hunk show`. -2. Load the Hunk review skill: [`skills/hunk-review/SKILL.md`](../skills/hunk-review/SKILL.md). +2. Load the Hunk review skill: [`packages/hunk/skills/hunk-review/SKILL.md`](../packages/hunk/skills/hunk-review/SKILL.md). 3. Ask the agent to use the skill and review the current session. A good generic prompt is: diff --git a/docs/browser-review-rebuild.md b/docs/browser-review-rebuild.md index 7d8d9ecce..d493893f6 100644 --- a/docs/browser-review-rebuild.md +++ b/docs/browser-review-rebuild.md @@ -15,20 +15,20 @@ Phase 6 carries the `minor` changeset announcing the feature. ## Phase 0 — seam contract and guardrails (this doc) -- Boundary gates for `src/core/review/` (the shared review model), `src/session/reviewProtocol.ts` - (the wire schema), and `src/web/` (the browser client). The gates tolerate absent trees, so +- Boundary gates for `packages/hunk/src/core/review/` (the shared review model), `packages/hunk/src/session/reviewProtocol.ts` + (the wire schema), and `packages/hunk/src/web/` (the browser client). The gates tolerate absent trees, so they land ahead of the code they constrain. - A shrink-only debt map for the Node-only primitives the prototype's model files still carry; each entry must be repaid with a platform-neutral implementation before a browser bundle may import that file. - The existing architecture boundaries stay at full strength. The prototype relocated bundled - VCS providers into `src/core/vcs/` and weakened this suite to compensate; that relocation must + VCS providers into `packages/hunk/src/core/vcs/` and weakened this suite to compensate; that relocation must not ride along with any rebuild phase — extraction PRs land against the restored gates. ## Phase 1 — review model + terminal adoption (three PRs) 1. **Review store**: `state / actions / reducer / store / intents / selectors` in - `src/core/review/`, with `useReviewController` / `App` / `AppHost` refactored onto it in the + `packages/hunk/src/core/review/`, with `useReviewController` / `App` / `AppHost` refactored onto it in the same PR. Behavior-neutral; existing PTY integration tests must pass untouched. 2. **Review document projection + diff geometry**: `document / identity / sourceIdentity / anchors / contentManifest / notes / expansion / reconcile / jsonStream` plus the geometry @@ -49,7 +49,7 @@ existing PTY suite passing untouched. ## Phase 2 — producer runtime -`src/app/reviewSessionRuntime.ts`: generations, snapshot serving, resource materialization, +`packages/hunk/src/app/reviewSessionRuntime.ts`: generations, snapshot serving, resource materialization, serving the existing `hunk session` surface only. Resource read failures map to distinct error codes (integrity failures are never collapsed into `unknown-resource`). @@ -176,7 +176,7 @@ browser mirrors the terminal theme. ## Commands and keyboard shortcuts in the browser -The terminal command system (`src/ui/lib/appCommands.ts`) fuses three separable things per +The terminal command system (`packages/hunk/src/ui/lib/appCommands.ts`) fuses three separable things per command: identity (id, title, chords), binding (terminal `KeyEvent` matching), and effect (closures over live App state). Making commands work in the browser means splitting them, not transporting them: diff --git a/docs/changelog-on-hunk-dev.md b/docs/changelog-on-hunk-dev.md index 73f663f92..4b186eeb4 100644 --- a/docs/changelog-on-hunk-dev.md +++ b/docs/changelog-on-hunk-dev.md @@ -134,6 +134,6 @@ none), and patch chips appear only when a series has more than one release. launch-video pipeline keeps generated media out of Git. - **Contributor lists.** The GitHub release bodies name first-time contributors, which is community goodwill and organic links. `CHANGELOG.md` does not carry authors, so this needs a second input. -- **The in-app update notice.** `src/core/process/updateNotice.ts` tells users a new version exists without +- **The in-app update notice.** `packages/hunk/src/core/process/updateNotice.ts` tells users a new version exists without linking what changed. Appending `hunk.dev/changelog/` is the highest-intent entry point available and is tracked separately. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 8e9b6e773..f4f9bd826 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -9,19 +9,19 @@ exists so you know which module owns what. ## Tiers and loading Extensions come in two tiers running through the same per-extension API -object and registry collection (`src/extensions/runExtension.ts`): +object and registry collection (`packages/hunk/src/extensions/runExtension.ts`): - **User extensions** load at interactive-app startup, before - `loadAppBootstrap` (`src/extensions/startup.ts`, `src/extensions/host.ts`). - Discovery groups and trust gating: `src/extensions/discovery.ts`, - `src/extensions/trust.ts`. -- **Bundled extensions** live in `src/extensions/default/` and are compiled - into the binary. `default/vcs/{git,jujutsu,sapling}` is statically imported - by the app composition root (`app/vcsCatalog.ts`) and loaded synchronously - before config resolution, so backends exist without making core import the - extension host. `default/ui/index.ts` is deliberately not part of that list: - it synchronously loads the bundled files and delegated review-info panes through - `runExtensionFactory` only where the app resolves UI panes. + `loadAppBootstrap` (`packages/hunk/src/extensions/startup.ts`, `packages/hunk/src/extensions/host.ts`). + Discovery groups and trust gating: `packages/hunk/src/extensions/discovery.ts`, + `packages/hunk/src/extensions/trust.ts`. +- **Bundled extensions** are compiled into the binary. The Git, Jujutsu, and Sapling + providers live in `packages/hunk-git`, `packages/hunk-jj`, and + `packages/hunk-sapling`; `packages/hunk/src/extensions/bundledPackages.ts` statically + composes their package descriptors before config resolution, so backends exist + without making core import the extension host. Bundled UI lives under + `packages/hunk/src/extensions/default/ui/` and loads through `runExtensionFactory` + only where the app resolves UI panes. Git, built-in file navigation, and delegated change-request identity use the public `registerVcsAdapter` and `registerPane` paths. The external [Hunk Lens](https://github.com/modem-dev/hunk-lens) @@ -39,15 +39,15 @@ reserved ids (`hunk`, plus the base catalog's bundled backend ids), ids outside `/^[A-Za-z0-9][A-Za-z0-9_-]*$/` (a dot or colon would make the composed ids unsplittable), and the later of two sources claiming one id; each refusal is a load issue and costs only that extension. The rules themselves are stated in -`src/extensions/extensionIds.ts`. +`packages/hunk/src/extensions/extensionIds.ts`. ## One registry, one apply path Registrations (session behavior, themes, file languages, VCS adapters, changeset transforms, panes, interactive commands, top-level CLI commands, lifecycle/UI events, and inter-extension bus listeners) collect into one -`ExtensionRegistry` (`src/extensions/types.ts`) and are resolved/applied -through `src/extensions/apply.ts` on both startup and reload. File-language registrations stay as +`ExtensionRegistry` (`packages/hunk/src/extensions/types.ts`) and are resolved/applied +through `packages/hunk/src/extensions/apply.ts` on both startup and reload. File-language registrations stay as declarative extension, filename, or glob selectors until `fileLanguageLookup.ts` resolves them; Hunk then pins that answer into Pierre's metadata so rendering cannot re-derive a conflicting language. A live reload replaces the compiled selector generation while preparing its changeset @@ -80,7 +80,7 @@ the handler releases I/O. ## Host-served runtime modules Extension files import `react`, `@opentui/*`, and `hunkdiff/extension` as -host-served runtime modules (`src/extensions/hostRuntimeModules.ts`): a +host-served runtime modules (`packages/hunk/src/extensions/hostRuntimeModules.ts`): a per-extension-directory Bun loader hook transpiles extension source and rewrites those specifiers to prefixed virtual modules backed by the host's own instances. That identity is what lets `registerPane` components @@ -91,14 +91,14 @@ commands never pay OpenTUI's native-library extraction). ## Four-edge pane system -`src/ui/lib/extensionPanes.ts` owns open state, availability, and one rectangle +`packages/hunk/src/ui/lib/extensionPanes.ts` owns open state, availability, and one rectangle plan for panes, dividers, and review bounds. Left/right panes consume columns; top/bottom panes consume rows from the central review column, outside review stream coordinates. Pane registrations may opt into a body-axis `fraction`; the planner resolves it to an integer target before applying bounds and lets a session-local divider drag override that automatic size. -`src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions, +`packages/hunk/src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions, immutable delegated review metadata, and failure containment. The fixed three-row `hunk:review-info` top pane uses one border row above two metadata rows and is available only for delegated change requests, so ordinary reviews spend no geometry on it. `DiffPane` exposes optional current-line paint — the row @@ -109,22 +109,22 @@ normalize into this same registry and layout path. ## File-view system File-view registrations are selected per file but remain inside the one -host-owned review stream. `src/ui/fileViews/useFileViews.ts` bounds asynchronous +host-owned review stream. `packages/hunk/src/ui/fileViews/useFileViews.ts` bounds asynchronous extension work and retains only immutable layouts accepted by -`src/ui/fileViews/layout.ts`; width and registration identity are part of that +`packages/hunk/src/ui/fileViews/layout.ts`; width and registration identity are part of that accepted geometry. A stateful view has no such identity to change, so `ctx.fileViews.refresh` bumps an invalidation epoch owned by -`src/ui/fileViews/useFilePresentationController.ts` and modeled in -`src/ui/fileViews/state.ts`. That epoch participates in the same retention key, +`packages/hunk/src/ui/fileViews/useFilePresentationController.ts` and modeled in +`packages/hunk/src/ui/fileViews/state.ts`. That epoch participates in the same retention key, re-preparing the files presenting that view while their current rows stay visible. One map counts both view-wide and per-file invalidation, and `fileViewLayoutEpoch` is the single place that composes them into the epoch a -`(file, view)` preparation is retained under. `src/ui/fileViews/renderPlan.ts` is the shared insertion +`(file, view)` preparation is retained under. `packages/hunk/src/ui/fileViews/renderPlan.ts` is the shared insertion plan for validated extension rows and host-owned inline notes. It resolves only unambiguous exact-source bindings and returns an explicit unresolved set, so `DiffPane` falls the complete file back to Pierre rather than guessing or -silently dropping review data. `src/ui/fileViews/geometry.ts` measures that same -plan, and `src/ui/components/panes/FileView.tsx` windows and paints it. Extension +silently dropping review data. `packages/hunk/src/ui/fileViews/geometry.ts` measures that same +plan, and `packages/hunk/src/ui/components/panes/FileView.tsx` windows and paints it. Extension components can paint only their fixed validated rectangles; note cards, scrolling, hunk bounds, and navigation remain host-owned. @@ -132,46 +132,46 @@ scrolling, hunk bounds, and navigation remain host-owned. Line highlighters mark character ranges inside Hunk's own diff rendering, so the system is deliberately split between a pull-based preparation half and a -paint-only application half. `src/ui/highlights/useLineHighlights.ts` bounds +paint-only application half. `packages/hunk/src/ui/highlights/useLineHighlights.ts` bounds asynchronous extension work with the same timeout/concurrency discipline as file views and retains only marks accepted by -`src/ui/highlights/validate.ts`; results cache under `(file, highlighter, +`packages/hunk/src/ui/highlights/validate.ts`; results cache under `(file, highlighter, epoch)`, and each file's merged mark array keeps a stable identity while its inputs are unchanged so row memoization can hold. The epoch is owned by -`src/ui/highlights/useLineHighlightsController.ts` behind +`packages/hunk/src/ui/highlights/useLineHighlightsController.ts` behind `ctx.highlights.refresh`, using the shared scoped-epoch policy in -`src/ui/lib/scopedEpochs.ts` — the same module `src/ui/fileViews/state.ts` +`packages/hunk/src/ui/lib/scopedEpochs.ts` — the same module `packages/hunk/src/ui/fileViews/state.ts` delegates to — and the shared bounded `readDocument` capability lives in -`src/ui/lib/extensionDocumentReader.ts`. +`packages/hunk/src/ui/lib/extensionDocumentReader.ts`. -Application is paint-time by construction. `src/ui/diff/lineHighlightPaint.ts` +Application is paint-time by construction. `packages/hunk/src/ui/diff/lineHighlightPaint.ts` owns the one mapping from source coordinates (raw code-unit offsets) to terminal columns — sanitize-aware, tab-aware, snapped outward to grapheme clusters, with context and gap lines sharing one range list under both side keys — and the one span transform that repaints backgrounds without changing -text. `src/ui/diff/rowStyle.ts` resolves tones against the actual line +text. `packages/hunk/src/ui/diff/rowStyle.ts` resolves tones against the actual line background with the word-diff minimum-contrast guarantee. -`src/ui/diff/CodeRowView.tsx` applies the transform through the cell painter, +`packages/hunk/src/ui/diff/CodeRowView.tsx` applies the transform through the cell painter, which keeps highlights out of `buildDiffSectionRowPlan`, its caches, and every geometry measurement: a highlight change is a repaint, never a re-plan. -`src/ui/diff/DiffRowView.tsx` remains only the memoized dispatch facade; raw-row +`packages/hunk/src/ui/diff/DiffRowView.tsx` remains only the memoized dispatch facade; raw-row adaptation there supports the public OpenTUI and extension current-line surfaces, while -`src/ui/diff/cursorHighlight.ts` owns stable-key cursor matching. The static pager never +`packages/hunk/src/ui/diff/cursorHighlight.ts` owns stable-key cursor matching. The static pager never runs extension code, so highlights are interactive-only. Agent attention marks (`hunk session highlight add` / `clear`) join this same pipeline rather than growing a second one: `useTerminalReview.ts` validates each daemon-pushed mark with the same `validate.ts` contract and caps, holds -them per file, and `src/ui/highlights/merge.ts` appends them after extension +them per file, and `packages/hunk/src/ui/highlights/merge.ts` appends them after extension marks in the one map `DiffPane` paints from — so agent marks share paint, contrast, and geometry guarantees, and win where ranges overlap. Unlike extension marks, nothing re-derives agent marks after a reload, so -`src/ui/highlights/reconcile.ts` carries them across a document replacement only +`packages/hunk/src/ui/highlights/reconcile.ts` carries them across a document replacement only for files whose `contentIdentity` is unchanged — those still show the same characters — and drops the rest. Line-target `session navigate` reuses the same `revealLine` landing policy `ctx.navigation.revealLine` gets. -`src/ui/fileViews/mode.ts` owns file-view mode activation, validity, and callback +`packages/hunk/src/ui/fileViews/mode.ts` owns file-view mode activation, validity, and callback containment. The presentation controller stores the active mode and funnels all exit paths through one teardown, including re-entrant handoffs. @@ -181,17 +181,17 @@ keyboard modes and app commands. `"handled"` and `"exit"` consume the key; Session-wide modes registered through `registerKeyboardMode` are resolved with the same extension ownership and first-registration rules as other surfaces. -`src/ui/keyboardModes/useKeyboardModeController.ts` owns the one active session +`packages/hunk/src/ui/keyboardModes/useKeyboardModeController.ts` owns the one active session mode, with eager ref state for input chunks, registry-generation authority, contained synchronous lifecycle callbacks, and one teardown used by Escape, status, menu, reload, and unmount. Mode controls are activation-scoped; `onEnter` and `onExit` cannot change ownership, while `onKey` may deliberately replace its activation without letting the outgoing callback defeat recovery or manipulate the replacement. -`src/ui/lib/extensionKeyEvent.ts` freezes the method-free public key snapshot +`packages/hunk/src/ui/lib/extensionKeyEvent.ts` freezes the method-free public key snapshot used by both session and file-view mode delivery, so OpenTUI events and their consumption methods never cross the extension boundary. Their shared -`src/ui/lib/synchronousExtensionCallback.ts` path contains lifecycle failures, +`packages/hunk/src/ui/lib/synchronousExtensionCallback.ts` path contains lifecycle failures, rejects thenables without leaving unhandled rejections, and normalizes key results; each mode module supplies only its context and attributed warnings. A focused file-view mode may overlap and temporarily outrank a session mode; @@ -200,20 +200,20 @@ leaving it resumes the session mode rather than destroying unrelated state. ## Command system Every app-level keyboard shortcut is a named command in one dispatch table -(`src/ui/lib/appCommands.ts`), each id under Hunk's reserved vendor namespace +(`packages/hunk/src/ui/lib/appCommands.ts`), each id under Hunk's reserved vendor namespace (`hunk.app.quit`, `hunk.review.nextHunk`) — which is what keeps built-in ids and extension-owned ids in disjoint spaces however either grows; modal surfaces (dialogs, menus, focused inputs) own their keys first and are deliberately not commands. Extension `registerCommand` entries join the same table via -`src/ui/lib/extensionCommands.ts` — built-ins win key conflicts, refused one +`packages/hunk/src/ui/lib/extensionCommands.ts` — built-ins win key conflicts, refused one chord at a time and detected by probing matchers with a synthesized event -(`src/lib/commandKeys.ts`). Command handlers receive pane controls and a selection snapshot from -`src/ui/lib/extensionSelection.ts`, derived from the same frozen file views the +(`packages/hunk/src/lib/commandKeys.ts`). Command handlers receive pane controls and a selection snapshot from +`packages/hunk/src/ui/lib/extensionSelection.ts`, derived from the same frozen file views the panes render plus a copied source address for the active current-line cursor. App reads it through a ref so the dispatch table stays stable while line navigation moves. `ctx.review.snapshot()` takes the complementary whole-review -path: `src/extensions/reviewSnapshot.ts` copies the active shared ReviewStore's +path: `packages/hunk/src/extensions/reviewSnapshot.ts` copies the active shared ReviewStore's document identities and complete saved-note collections, preserving core-owned anchors and reconciliation verdicts. App pairs that state with the producer's current generation under the same review capability lease, so retained controls @@ -221,7 +221,7 @@ return `null` after reload instead of reading replacement content. The extension projection is registered in `test/review-conformance/` as a real semantic consumer rather than rebuilding note placement in the command host. -`src/ui/lib/extensionNavigation.ts` mints the guarded navigation behind both +`packages/hunk/src/ui/lib/extensionNavigation.ts` mints the guarded navigation behind both `ctx.navigation` and a pane's `actions`, so a jump from either surface is validated, attributed, and reported the same way. It owns argument policy only — visible-file validation, hunk clamping, `revealLine`'s side and line-number @@ -243,12 +243,12 @@ modal keys also remain outside the table and therefore outside the event. `ctx.dialogs` is the one place extension code can interrupt the user, so its ordering and settlement live outside React in -`src/ui/lib/extensionDialogs.ts` — one FIFO queue per App instance, minting a +`packages/hunk/src/ui/lib/extensionDialogs.ts` — one FIFO queue per App instance, minting a per-extension `dialogs` object, normalizing (and sanitizing) extension-authored text into a request the host draws, and answering by request id so a duplicated Enter cannot spill onto whatever was queued behind. App subscribes with `useSyncExternalStore`, renders the current request through -`src/ui/components/chrome/ExtensionDialog.tsx` (confirm reuses `ConfirmDialog`; +`packages/hunk/src/ui/components/chrome/ExtensionDialog.tsx` (confirm reuses `ConfirmDialog`; select and input are `ModalFrame` surfaces), and unmount calls `shutdown()` so every pending and queued dialog resolves its cancel value instead of leaving a handler awaiting forever. Key precedence in `useAppKeyboardShortcuts` places @@ -260,7 +260,7 @@ frame carries an `ext ` attribution row — the toast marker — for every user-installed extension, because its title is extension-authored and a prompt must not be able to impersonate Hunk. The host derives the extension's trusted bundled origin from registry metadata and omits the redundant marker only for -Hunk-owned bundled UI. `src/ui/lib/modalGeometry.ts` clamps the frame before +Hunk-owned bundled UI. `packages/hunk/src/ui/lib/modalGeometry.ts` clamps the frame before extension text is wrapped or windowed, so measurement and rendering use the same terminal width; body/options yield rows to a pinned mouse-clickable action footer on short terminals. @@ -271,7 +271,7 @@ per-extension event-context provider, while `AppHost` publishes mounted lifecycle order (`startup`, then `changeset_loaded`; reloads add `session_reload`) only after the matching child commit. Headless or pre-mount delivery resolves dialogs to their cancel values and refuses navigation with a warning. -`src/ui/lib/extensionCapabilityLease.ts` binds retained pane, navigation, +`packages/hunk/src/ui/lib/extensionCapabilityLease.ts` binds retained pane, navigation, dialog, and workspace controls to one App, extension registry, and review generation. Soft reload or registry retirement therefore makes old host-mediated capabilities @@ -281,7 +281,7 @@ inert before shutdown begins. Session behavior requests are registry data too: presentation view changes ephemeral without teaching `App` about an extension id. -`src/ui/lib/extensionWorkspace.ts` owns the policy for `ctx.workspace`. Reads +`packages/hunk/src/ui/lib/extensionWorkspace.ts` owns the policy for `ctx.workspace`. Reads resolve reviewed file ids through the existing source fetcher, which retains ownership of caching and size limits. Missing or unreadable sources become `null`. @@ -292,27 +292,27 @@ through refs so soft reloads update the policy inputs. The host verifies the filesystem target before and after consent, writes it, then calls `refreshCurrentInput`. Consent uses the existing extension-dialog queue. -Commands declare chords, not matchers: `src/ui/lib/keymap.ts` folds every +Commands declare chords, not matchers: `packages/hunk/src/ui/lib/keymap.ts` folds every command's `defaultKeys` against the user's `[keybindings]` table (user config layer only) into one id-to-chords answer, from which matchers, key labels, and conflict probes are all derived — a user-bound chord is exclusive, so whatever held it by default gives it up. The chord grammar itself lives in -`src/extension-api/keys.ts` because it is published as `hunkdiff/extension` +`packages/hunk/src/extension-api/keys.ts` because it is published as `hunkdiff/extension` (`matchesKey`, `parseKeyChord`, `matchesKeyChord`) for extension components -that need internal keys; `src/lib/commandKeys.ts` re-exports it inward and +that need internal keys; `packages/hunk/src/lib/commandKeys.ts` re-exports it inward and keeps the host-only pieces. The table is also the only description of what each action is called and which key runs it, so the mouse surfaces read from it rather than restating it: the -dropdown menus (`src/ui/lib/appMenus.ts`) declare items as command ids plus +dropdown menus (`packages/hunk/src/ui/lib/appMenus.ts`) declare items as command ids plus menu-specific wording and checkbox state, and the controls help dialog -(`src/ui/lib/helpContent.ts`) declares curated rows the same way — both render +(`packages/hunk/src/ui/lib/helpContent.ts`) declares curated rows the same way — both render their key text from resolved `keyLabels` and run entries through `executeAppCommand`. A few commands ship with `defaultKeys: []` because they exist for a menu item; they never match a key but remain bindable by id. Command handlers receive guarded `ctx.commands` controls built by -`src/ui/lib/extensionCommandControls.ts`. They resolve the live App command table on every call, +`packages/hunk/src/ui/lib/extensionCommandControls.ts`. They resolve the live App command table on every call, then expose only built-ins carrying explicit public metadata. Counted movement reaches the same command callback once with a normalized delta; it is never implemented as repeated synchronous dispatch. Current-line alignment is also semantic: App raises an alignment request and `DiffPane` @@ -322,23 +322,23 @@ Extension commands remain private to prevent recursion and cross-extension execu The **Extensions** menu is generated from the registered extension commands, one item per command grouped by extension, and is absent entirely when there are none — which is why the visible menu list is derived from the menus record -(`buildMenuSpecs` in `src/ui/components/chrome/menu.ts`) rather than fixed. +(`buildMenuSpecs` in `packages/hunk/src/ui/components/chrome/menu.ts`) rather than fixed. ## VCS adapters -`src/core/vcs/index.ts` owns provider-neutral catalog ordering, lookup, -detection, and operation dispatch. `src/app/vcsCatalog.ts` composes bundled -registrations, while `src/app/sessionBootstrap.ts` extends that catalog with +`packages/hunk/src/core/vcs/index.ts` owns provider-neutral catalog ordering, lookup, +detection, and operation dispatch. `packages/hunk/src/app/vcsCatalog.ts` composes bundled +registrations, while `packages/hunk/src/app/sessionBootstrap.ts` extends that catalog with accepted user adapters and threads the same value through loading, reload, and watch. Detection is uniform across tiers: nearest checkout wins, priority breaks equal-distance ties, and an explicit `vcs` id owned by the catalog wins. Provider implementations — command construction, spawning, error translation, and exact-source reading — live entirely under -`src/extensions/default/vcs//`. `src/extensions/vcsPatchResult.ts` is +`packages/hunk-/src/`. `packages/hunk/src/extensions/vcsPatchResult.ts` is the one conversion boundary where a published `ExtensionVcsPatchResult` becomes Hunk's internal diff model, including structural `too-large` source -results. `src/core/process/projectRoot.ts` treats `.hunk` as a provider-independent +results. `packages/hunk/src/core/process/projectRoot.ts` treats `.hunk` as a provider-independent bootstrap marker and also consults the available catalog; startup performs a second root/config pass when a global, config-path, or CLI adapter recognizes a repository unavailable to the bundled catalog. @@ -346,7 +346,7 @@ repository unavailable to the bundled catalog. ## Public contract rules The authoring surface is the `hunkdiff/extension` export — a façade over -internal types, declared in `src/extension-api/types.ts`. That module must +internal types, declared in `packages/hunk/src/extension-api/types.ts`. That module must stay import-free: declaration emission ships every module the entry reaches, so an import there publishes Hunk internals (`scripts/check-pack.ts` fails the pack when it does, and typechecks every `docs/extensions.md` example as diff --git a/docs/extensions.md b/docs/extensions.md index 4cb9e9a80..a9ebb548b 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -73,6 +73,13 @@ Because the manifest is a real `package.json`, a folder extension may depend on npm packages: declare them, install them into the folder's own `node_modules`, and imports resolve from the entry file the way they do in any other package. +The package `name` is the stable activation identity shared by every entry. +`hunk.packageId` may override it when the install identity must remain independent +of an npm name. Package ids are at most 128 characters: an optional lowercase npm +scope plus `/`, followed by a lowercase name using letters, digits, `.`, `_`, or +`-`. Legacy folder/install names are trimmed, lowercased, and replace other +character runs with `-`, so an existing uppercase folder remains manageable. +Entry ids remain separate and continue to own configuration and registration namespaces. The `hunk` field may also state the minimum extension API version the folder needs: @@ -81,7 +88,11 @@ needs: "name": "my-ext", "version": "1.0.0", "description": "What the extension does", - "hunk": { "extensions": ["./src/index.ts"], "apiVersion": 3 } + "hunk": { + "packageId": "my-ext", + "extensions": ["./src/index.ts"], + "apiVersion": 3 + } } ``` @@ -121,9 +132,10 @@ same way, since one id cannot own two config tables. read, let alone executed. Use it when triaging a bug. `--extension` is explicit user intent: the file loads immediately, with no -trust prompt, even when the path points inside the repository under review. -Never pass a path you have not read — including one copy-pasted from a -repository's own README. +trust prompt, even when the path points inside the repository under review. A +persistently disabled package remains disabled when its directory, declared entry, +or a symlink to that entry is passed explicitly. Never pass a path you have not +read — including one copy-pasted from a repository's own README. ## Sharing and installing extensions @@ -146,13 +158,27 @@ precedence, no trust prompt — because installing one is the explicit consent: the install asks for confirmation (or `--yes`) after stating that extensions run with your full user permissions. Only install repositories you trust. -`hunk extension list` shows every managed install with its version, commit, and -source. `hunk extension update [name]` re-clones one install (or all of them) -from its recorded source — an install pinned with `@ref` stays at that ref -until you re-install with a different one. `hunk extension remove ` -deletes the install and its record. Managed installs never collide with -extensions you copied into `~/.config/hunk/extensions/` by hand, and the -installer refuses to overwrite an unmanaged directory of the same name. +`hunk extension list` shows every managed install with its stable package +identity, ordered entry ids, activation state, version, commit, and source. +`hunk extension disable ` disables every entry in that +package before import or repo trust checks; `hunk extension enable ...` +re-enables it. Activation preferences are stored separately from install +records, so update and reinstall cannot silently reset a user's safety choice. +A repository exposing several packages may be toggled by install name as one +unit, while a multi-entry package always toggles all its entries together. Hunk +refuses a selector that could mean both an install and a different package, or a +package id exposed by multiple installs, instead of guessing which code to toggle. + +`hunk extension update [name]` re-clones one install (or all of them) from its +recorded source — an install pinned with `@ref` stays at that ref until you +re-install with a different one. Unchanged package ids keep their individual +activation choices, and an unambiguous rename carries a denial forward. If an +update would add or ambiguously replace identities while an affected package is +disabled, Hunk refuses it rather than silently enabling new code; enable first, +update, then disable the intended new identities. `hunk extension remove ` deletes the +install and its record. Managed installs never collide with extensions you +copied into `~/.config/hunk/extensions/` by hand, and the installer refuses to +overwrite an unmanaged directory of the same name. ### Publishing an extension @@ -192,8 +218,9 @@ run without installing anything. ## Bundled extensions Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension, -and so is the **built-in file-navigation pane**. They live in -`src/extensions/default/`, are compiled into the binary, and register through +and so is the **built-in file-navigation pane**. The private provider sources live +in `packages/hunk-git`, `packages/hunk-jj`, and `packages/hunk-sapling`; they are +not independently installable or published. They are compiled into the binary and register through the same `hunk.registerVcsAdapter` and `hunk.registerPane` this guide documents. There is no private registration path. @@ -394,7 +421,7 @@ removes the patch on extension shutdown. Run it from this checkout with: ```bash -bun run src/main.tsx --extension ./examples/extensions/github-pr gh 123 +bun run start -- --extension ./examples/extensions/github-pr gh 123 ``` ### `hunk.configureSession(options)` @@ -968,7 +995,7 @@ whatever version Hunk pins — a wider surface than `hunkdiff/extension` itself. The built-in files pane uses the same calls, so changes that break this contract break Hunk first. Keep scroll handling small and behind your own helpers. -Its implementation lives in `src/extensions/default/ui/sidebar/` and serves as +Its implementation lives in `packages/hunk/src/extensions/default/ui/sidebar/` and serves as the reference for third-party panes. #### Pane state from events @@ -2065,16 +2092,18 @@ hunk diff --no-extensions # disable user extensions for this re ```toml # ~/.config/hunk/config.toml or .hunk/config.toml [extensions] -enabled = true # false disables loading for this layer +enabled = true # user false cannot be overridden by a repo paths = ["~/dev/hunk-ext/index.ts"] # extra entry files or directories [extension.my-extension] # opaque payload handed to that extension some_key = "some value" ``` -`[extensions] enabled` layers like every other option: a repo `.hunk/config.toml` -overrides your user config. `--no-extensions` is a hard off switch that no config -layer can re-enable. Both govern **user** extensions only — Hunk's bundled +A repo may disable `[extensions] enabled`, but it cannot override `enabled = false` +in your user config. `--no-extensions` is the strongest hard-off switch and no config +layer can re-enable it. A package disabled with `hunk extension disable` is filtered +before repo trust and imports, and repo configuration cannot re-enable it. These +controls govern **user** extensions only — Hunk's bundled Git, Jujutsu, and Sapling backends load either way. `[extensions] paths` from a repo config is trust-gated the same way `.hunk/extensions` is, because it is repo-controlled either way. diff --git a/docs/module-boundaries.md b/docs/module-boundaries.md index 68c4c9547..4cb9567b3 100644 --- a/docs/module-boundaries.md +++ b/docs/module-boundaries.md @@ -3,7 +3,7 @@ Defines the target import boundaries between Hunk's top-level source trees and records what the dependency graph actually looks like today. The boundaries are enforced by [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) over the production import -graph (`src/` plus `packages/`, tests excluded): +graph (`packages/`, including the app at `packages/hunk/src/`, tests excluded): - `bun run deps:check` — fails CI on any boundary violation not in the baseline. - `bun run deps:baseline` — regenerates `.dependency-cruiser-known-violations.json` after fixing @@ -20,28 +20,24 @@ tier-level complement, with real module resolution instead of regex import scann Tiers, bottom to top. A tier may import anything strictly below it and nothing above it: ```text -src/extension-api published contract; imports nothing -src/lib dependency-free helpers; may import extension-api only -src/core domain model (changesets, review, vcs catalog, config) -packages/* standalone publishable units (session broker, term-video); - never import src/; the per-app broker contract is in - docs/session-broker-sdk.md -src/extensions extension host + bundled extensions; consume core, never surfaces -src/session daemon/broker transport + protocol; consumes core and packages -src/app startup composition: CLI parsing plus the wiring of core, - extensions, and the session broker; no rendering -src/ui terminal surface; only the composition shell (App, AppHost, - runInteractiveApp), the named session adapter hooks - (useTerminalReview, useHunkSessionBridge), and their shared - navigation helper (ui/lib/reviewState) may import app/session -src/opentui published facade re-exporting ui/core pieces for `hunkdiff/opentui` -src/main.tsx CLI entry +packages/hunk/src/extension-api published `hunkdiff/extension` contract; imports nothing +packages/hunk-vcs private provider-neutral leaf utilities +packages/hunk-{git,jj,sapling} private bundled providers; public extension API only +packages/hunk/src/lib app leaf helpers +packages/hunk/src/core domain model (changesets, review, VCS catalog, config) +packages/session-broker* private standalone broker workspaces +packages/hunk/src/extensions extension host and bundled UI composition +packages/hunk/src/session daemon/broker transport and protocol +packages/hunk/src/app startup composition; no rendering +packages/hunk/src/ui terminal surface and named app/session adapters +packages/hunk/src/opentui published `hunkdiff/opentui` facade +packages/hunk/src/main.tsx CLI entry ``` Intentional exceptions, allowed by the rules: -- `src/opentui` imports `src/ui` internals: it is a packaging facade whose job is re-export. -- `src/hunk-review` imports `src/session/agent`: the skill document is generated from the agent +- `packages/hunk/src/opentui` imports UI internals: it is a packaging facade whose job is re-export. +- `packages/hunk/src/hunk-review` imports session agent modules: the skill document is generated from the agent surface by design. - Tests are excluded: they are colocated and free to reach across boundaries. @@ -54,7 +50,7 @@ accidental reach-in fails `bun run deps:check` instead of quietly becoming API. Two supporting rules keep the interiors honest: -- **`no-dead-modules`** flags any module under `src/` that no entry point reaches +- **`no-dead-modules`** flags any module under `packages/hunk/src/` that no entry point reaches (`main.tsx`, `highlightWorkerEntry.ts`, the `opentui` and `extension-api` facades, and the skill generator). It uses `reachable: false` rather than `orphan`, which only catches fully disconnected files and so misses dead code that still imports. A hit is deleted, or — when @@ -156,7 +152,7 @@ module that owns their behaviour, one home each: is the line a user note hangs on, and every consumer reaches it through note code. Deleting the re-exports made one hidden dependency visible: `core/review/annotations.ts` names -`AgentAnnotation`, which is declared in `src/extension-api/types.ts` because it is +`AgentAnnotation`, which is declared in `packages/hunk/src/extension-api/types.ts` because it is simultaneously an internal model type and part of the published contract. Routing that through `core/types.ts` had disguised it as a core-local import, and `scripts/source-boundaries.test.ts` ("keeps the review model contained in core") caught it the moment the disguise came off. The diff --git a/docs/opentui-component.md b/docs/opentui-component.md index 9ea0c52f7..5cc67b5ae 100644 --- a/docs/opentui-component.md +++ b/docs/opentui-component.md @@ -231,4 +231,4 @@ If you need direct access to Pierre's parser, `parsePatchFiles(...)` is still re - Runnable demo overview: [`examples/README.md`](../examples/README.md) - Component demos: [`examples/7-opentui-component/README.md`](../examples/7-opentui-component/README.md) -The in-repo demos import from `../../src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui`. +The in-repo demos import from `../../packages/hunk/src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui`. diff --git a/docs/source-architecture.md b/docs/source-architecture.md index 20cbc37ea..445b8ef89 100644 --- a/docs/source-architecture.md +++ b/docs/source-architecture.md @@ -7,33 +7,33 @@ Use it when adding a new module or deciding where an existing responsibility bel ## Ownership ```text -src/app/ executable composition: CLI parsing, startup plans, and shared session bootstrap -src/app/session/ mounted-review registration, bridge, and reload authorization -src/core/ review model, patch handling, VCS contracts, configuration, and +packages/hunk/src/app/ executable composition: CLI parsing, startup plans, and shared session bootstrap +packages/hunk/src/app/session/ mounted-review registration, bridge, and reload authorization +packages/hunk/src/core/ review model, patch handling, VCS contracts, configuration, and runtime primitives -src/core/changeset/ the changeset model and the pipeline that acquires one: loaders, +packages/hunk/src/core/changeset/ the changeset model and the pipeline that acquires one: loaders, per-file construction, sidecar/source reads, and hunk formatting -src/core/run/ how a run is asked for: command inputs, layered configuration, the +packages/hunk/src/core/run/ how a run is asked for: command inputs, layered configuration, the command catalog, user-facing errors, paths, and version -src/core/process/ the process and terminal a run lives in: TTY capabilities, the pager, +packages/hunk/src/core/process/ the process and terminal a run lives in: TTY capabilities, the pager, job control, shutdown, project-root discovery, persisted app state, and startup/update notices -src/core/theme/ bundled theme metadata, custom-theme rules, and terminal theme detection -src/core/watch/ input signatures, observation plans/backends, and refresh coordination -src/core/vcs/ provider-neutral VCS catalog, contracts, operation dispatch, and host support -src/extensions/ extension host, registry, trust, lifecycle, and bundled extensions -src/session/ shared session protocol, schemas, types, agent surface, and broker transport -src/session/client/ shared session-daemon HTTP and compatibility client support -src/session/agent/ agent-facing session CLI, command manifest, errors, and formatting -src/session/broker/ local daemon transport, launcher, Hunk broker state, wire parsing, projections -src/ui/ interactive review application, rendering, interaction, and chrome -src/extension-api/ public `hunkdiff/extension` declaration and runtime boundary -src/opentui/ public `hunkdiff/opentui` component boundary -src/lib/ small product-wide utilities with no feature ownership +packages/hunk/src/core/theme/ bundled theme metadata, custom-theme rules, and terminal theme detection +packages/hunk/src/core/watch/ input signatures, observation plans/backends, and refresh coordination +packages/hunk/src/core/vcs/ provider-neutral VCS catalog, contracts, operation dispatch, and host support +packages/hunk/src/extensions/ extension host, registry, trust, lifecycle, and bundled extensions +packages/hunk/src/session/ shared session protocol, schemas, types, agent surface, and broker transport +packages/hunk/src/session/client/ shared session-daemon HTTP and compatibility client support +packages/hunk/src/session/agent/ agent-facing session CLI, command manifest, errors, and formatting +packages/hunk/src/session/broker/ local daemon transport, launcher, Hunk broker state, wire parsing, projections +packages/hunk/src/ui/ interactive review application, rendering, interaction, and chrome +packages/hunk/src/extension-api/ public `hunkdiff/extension` declaration and runtime boundary +packages/hunk/src/opentui/ public `hunkdiff/opentui` component boundary +packages/hunk/src/lib/ small product-wide utilities with no feature ownership ``` -`src/app/` is intentionally small: it composes subsystems but does not become a second -application framework. `src/core/` remains the shared product layer, not a synonym for +`packages/hunk/src/app/` is intentionally small: it composes subsystems but does not become a second +application framework. `packages/hunk/src/core/` remains the shared product layer, not a synonym for "anything outside React". Put a module in a more specific existing subdirectory whenever one owns its behaviour. @@ -42,10 +42,10 @@ one owns its behaviour. - `app` may compose `core`, `extensions`, `session`, and `ui`. - `ui` may consume core models and the extension/session contracts; it owns terminal rendering. - `extensions` may consume provider-neutral core models and contracts, but bundled VCS provider - implementations must depend only on `hunkdiff/extension`, local modules, and `src/lib` utilities. + implementations must depend only on `hunkdiff/extension`, local modules, and `@hunk/vcs` subpaths. Renderer access remains limited to `extensions/default/ui/`, the bundled-sidebar boundary. - `core` must not import `ui` or `extensions`. Shared data needed by both belongs in core-owned - structural contracts or `src/lib`, never in a reverse dependency. + structural contracts or `packages/hunk/src/lib`, never in a reverse dependency. - `extension-api/types.ts` stays import-free. It is a published declaration boundary, enforced by the package checks. - `opentui` and `extension-api` are public entrypoint directories, not general internal buckets. @@ -75,4 +75,4 @@ This is an incremental migration, not a bulk rename: Current composition lives in `app/`: `app/vcsCatalog.ts` assembles bundled registrations into a provider-neutral catalog, and `app/sessionBootstrap.ts` extends that catalog with user adapters. -Provider commands, source readers, and tests live under `extensions/default/vcs//`. +Provider commands, source readers, and tests live in the private `packages/hunk-git`, `packages/hunk-jj`, and `packages/hunk-sapling` workspaces. diff --git a/examples/7-opentui-component/README.md b/examples/7-opentui-component/README.md index 2ab36d2ec..48a87ba89 100644 --- a/examples/7-opentui-component/README.md +++ b/examples/7-opentui-component/README.md @@ -19,4 +19,4 @@ bun run examples/7-opentui-component/from-patch.tsx - switching between split and stacked layouts with example shell controls - a scrollable terminal diff component that other OpenTUI apps can reuse -The in-repo demos import from `../../src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui` instead. +The in-repo demos import from `../../packages/hunk/src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui` instead. diff --git a/examples/7-opentui-component/from-files.tsx b/examples/7-opentui-component/from-files.tsx index 98e2fb63b..d6321097f 100644 --- a/examples/7-opentui-component/from-files.tsx +++ b/examples/7-opentui-component/from-files.tsx @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { parseDiffFromFile } from "../../src/opentui"; +import { parseDiffFromFile } from "../../packages/hunk/src/opentui"; import { readExampleFile, runExample } from "./support"; const path = "src/reviewSummary.ts"; diff --git a/examples/7-opentui-component/from-patch.tsx b/examples/7-opentui-component/from-patch.tsx index 56445c9bf..9fab70ef7 100644 --- a/examples/7-opentui-component/from-patch.tsx +++ b/examples/7-opentui-component/from-patch.tsx @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { parsePatchFiles } from "../../src/opentui"; +import { parsePatchFiles } from "../../packages/hunk/src/opentui"; import { readExampleFile, runExample } from "./support"; const patch = readExampleFile("change.patch"); diff --git a/examples/7-opentui-component/support.tsx b/examples/7-opentui-component/support.tsx index c01946dab..c1fe2c5e7 100644 --- a/examples/7-opentui-component/support.tsx +++ b/examples/7-opentui-component/support.tsx @@ -3,9 +3,9 @@ import path from "node:path"; import { createCliRenderer } from "@opentui/core"; import { createRoot, useTerminalDimensions } from "@opentui/react"; import { useState } from "react"; -import type { HunkDiffFile, HunkDiffLayout } from "../../src/opentui"; -import { HunkDiffView } from "../../src/opentui"; -import { fitText } from "../../src/ui/lib/text"; +import type { HunkDiffFile, HunkDiffLayout } from "../../packages/hunk/src/opentui"; +import { HunkDiffView } from "../../packages/hunk/src/opentui"; +import { fitText } from "../../packages/hunk/src/ui/lib/text"; interface ExampleProps { title: string; diff --git a/examples/8-opentui-primitives/README.md b/examples/8-opentui-primitives/README.md index 61374dae3..5a443929c 100644 --- a/examples/8-opentui-primitives/README.md +++ b/examples/8-opentui-primitives/README.md @@ -19,4 +19,4 @@ bun run examples/8-opentui-primitives/primitives-demo.tsx - Host-owned window borders/chrome around each primitive so you can inspect component boundaries - Host-owned state for selected file and split/stack layout -The in-repo demo imports from `../../src/opentui` so it runs from source. Published consumers should import from `hunkdiff/opentui` instead. +The in-repo demo imports from `../../packages/hunk/src/opentui` so it runs from source. Published consumers should import from `hunkdiff/opentui` instead. diff --git a/examples/8-opentui-primitives/primitives-demo.tsx b/examples/8-opentui-primitives/primitives-demo.tsx index 6098d4c32..2f193825d 100644 --- a/examples/8-opentui-primitives/primitives-demo.tsx +++ b/examples/8-opentui-primitives/primitives-demo.tsx @@ -10,8 +10,8 @@ import { HunkReviewStream, createHunkDiffFilesFromPatch, type HunkDiffLayout, -} from "../../src/opentui"; -import { fitText, padText } from "../../src/ui/lib/text"; +} from "../../packages/hunk/src/opentui"; +import { fitText, padText } from "../../packages/hunk/src/ui/lib/text"; const PATCH = `diff --git a/src/search.ts b/src/search.ts --- a/src/search.ts diff --git a/examples/extensions/cli-tools/README.md b/examples/extensions/cli-tools/README.md index af29dc753..f1a132b0a 100644 --- a/examples/extensions/cli-tools/README.md +++ b/examples/extensions/cli-tools/README.md @@ -5,8 +5,8 @@ Demonstrates a generic top-level command tree. The handler owns every token belo Run it directly from this checkout: ```bash -bun run src/main.tsx --extension ./examples/extensions/cli-tools cli-tools status -bun run src/main.tsx --extension ./examples/extensions/cli-tools cli-tools review +bun run start -- --extension ./examples/extensions/cli-tools cli-tools status +bun run start -- --extension ./examples/extensions/cli-tools cli-tools review ``` `status` writes to stdout and exits. `review` performs signal-aware asynchronous preprocessing, writes progress to stderr, then delegates to `hunk diff`. A delegating handler must not write stdout or read stdin because the built-in command or TUI takes ownership of both. diff --git a/examples/extensions/github-pr/README.md b/examples/extensions/github-pr/README.md index 94d477fe1..a6a53169a 100644 --- a/examples/extensions/github-pr/README.md +++ b/examples/extensions/github-pr/README.md @@ -13,7 +13,7 @@ The extension fetches the PR metadata and diff directly from GitHub's API, write Place `--extension` before the extension-owned command: ```bash -bun run src/main.tsx --extension ./examples/extensions/github-pr gh 123 +bun run start -- --extension ./examples/extensions/github-pr gh 123 ``` A bare number infers `owner/repo` from the current checkout's GitHub `origin`. Explicit forms work outside a checkout and do not invoke Git: diff --git a/examples/extensions/inline-edit/README.md b/examples/extensions/inline-edit/README.md index 83bf95cdb..a134c1a4e 100644 --- a/examples/extensions/inline-edit/README.md +++ b/examples/extensions/inline-edit/README.md @@ -16,7 +16,7 @@ It exists to demonstrate that Hunk's interactive extension surfaces compose, so ## Try it from this checkout ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/inline-edit +bun run start -- -- diff --extension ./examples/extensions/inline-edit ``` ## Install it globally diff --git a/examples/extensions/jsx-file-view-gallery/README.md b/examples/extensions/jsx-file-view-gallery/README.md index 174298bae..4a1a11166 100644 --- a/examples/extensions/jsx-file-view-gallery/README.md +++ b/examples/extensions/jsx-file-view-gallery/README.md @@ -7,7 +7,7 @@ Three opt-in presentations exercise the constrained React/OpenTUI row contract a Nested boxes, responsive meters, semantic color, and selected-hunk styling summarize a multi-hunk TypeScript refactor. It uses no source parser and works from public hunk/change metadata alone. ```bash -bun run src/main.tsx -- diff \ +bun run start -- -- diff \ --extension ./examples/extensions/jsx-file-view-gallery \ --mode stack \ examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/before.ts \ @@ -19,7 +19,7 @@ bun run src/main.tsx -- diff \ The extension lazily reads both exact documents, associates changed opaque three- or six-digit hexadecimal custom properties with each real diff hunk, and paints old/new terminal color swatches inside deterministic two-row rectangles. ```bash -bun run src/main.tsx -- diff \ +bun run start -- -- diff \ --extension ./examples/extensions/jsx-file-view-gallery \ --mode stack \ examples/extensions/jsx-file-view-gallery/fixtures/css-palette/before.css \ @@ -31,7 +31,7 @@ bun run src/main.tsx -- diff \ A conservative package-file parser highlights only the changed semantic-version segment: patch-only changes emphasize the patch number, minor upgrades emphasize the minor number, and major upgrades emphasize the full old/new strings. It retains positional bounds for every parsed hunk; invalid JSON or unavailable source falls back to raw diff. ```bash -bun run src/main.tsx -- diff \ +bun run start -- -- diff \ --extension ./examples/extensions/jsx-file-view-gallery \ --mode stack \ examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/before/package.json \ diff --git a/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts b/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts index 86518dd1c..f8a048379 100644 --- a/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts +++ b/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts @@ -73,7 +73,14 @@ try { const result = spawnSync( process.execPath, - [join(repoRoot, "src/main.tsx"), "diff", "--extension", galleryRoot, "--mode", "stack"], + [ + join(repoRoot, "packages/hunk/src/main.tsx"), + "diff", + "--extension", + galleryRoot, + "--mode", + "stack", + ], { cwd: demoRepo, stdio: "inherit", env: process.env }, ); if (result.error) throw result.error; diff --git a/examples/extensions/jsx-file-view/README.md b/examples/extensions/jsx-file-view/README.md index b90032124..61993e1c2 100644 --- a/examples/extensions/jsx-file-view/README.md +++ b/examples/extensions/jsx-file-view/README.md @@ -5,7 +5,7 @@ An opt-in proof of concept for fixed-height React/OpenTUI rows in alternate file Run it from this checkout against a multi-hunk working-tree change: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/jsx-file-view +bun run start -- -- diff --extension ./examples/extensions/jsx-file-view ``` Choose **Extensions → Toggle JSX hunk cards (POC)**. The row component uses a React state hook and OpenTUI `box`/`text` elements. Its registered F8 command/menu item is the supported keyboard path. A cooperatively delivered, un-dragged left-button mouse-up toggles local detail and stops propagation; wheel, drag, and unhandled input remain host-owned. The row is a non-focusable paint surface, with no portal, renderer, focus, or input-delivery guarantee. Each component is a closure over the hunk summary; Hunk passes it only bounded paint props, including a live semantic theme palette that does not participate in layout. The `spans` on every row are the host-rendered fallback, clipped to the same declared fixed height if the component fails. Hook state survives selected-hunk updates while mounted, but is intentionally lost when windowing unmounts the row or a new layout generation replaces it. diff --git a/examples/extensions/pane-layout/README.md b/examples/extensions/pane-layout/README.md index f11d28be3..c7776f008 100644 --- a/examples/extensions/pane-layout/README.md +++ b/examples/extensions/pane-layout/README.md @@ -5,7 +5,7 @@ Registers a resizable right pane and fixed two-row top and bottom panes. Run it from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/pane-layout +bun run start -- -- diff --extension ./examples/extensions/pane-layout ``` Press `ctrl+p` or use the **Extensions** menu. Drag the right divider to resize. diff --git a/examples/extensions/rendered-markdown/README.md b/examples/extensions/rendered-markdown/README.md index 8b6b9411d..07c14fba2 100644 --- a/examples/extensions/rendered-markdown/README.md +++ b/examples/extensions/rendered-markdown/README.md @@ -9,7 +9,7 @@ This example is **not bundled or loaded by Hunk**. Install it explicitly if you The repository's root install supplies the example's development dependency: ```bash -bun run src/main.tsx -- diff \ +bun run start -- -- diff \ --extension ./examples/extensions/rendered-markdown \ before.md after.md ``` diff --git a/examples/extensions/review-note-navigator/README.md b/examples/extensions/review-note-navigator/README.md index 79e302cea..3fcb3ed50 100644 --- a/examples/extensions/review-note-navigator/README.md +++ b/examples/extensions/review-note-navigator/README.md @@ -5,7 +5,7 @@ Lists every note currently saved in Hunk's shared ReviewStore, then navigates to Run it directly from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/review-note-navigator +bun run start -- -- diff --extension ./examples/extensions/review-note-navigator ``` Save one or more review notes, then run **Extensions → Navigate saved review note…** (`F8`). Each choice includes its reconciliation status, file, preferred line, side, and summary. diff --git a/examples/extensions/review-snapshot-export/README.md b/examples/extensions/review-snapshot-export/README.md index f831b6d75..28ab85455 100644 --- a/examples/extensions/review-snapshot-export/README.md +++ b/examples/extensions/review-snapshot-export/README.md @@ -5,7 +5,7 @@ Exports Hunk's authoritative saved review state as JSON. The example shows why ` Run it directly from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/review-snapshot-export +bun run start -- -- diff --extension ./examples/extensions/review-snapshot-export ``` Add one or more review notes, then run **Extensions → Export review snapshot…** (`F9`) and choose a new output path. Relative paths resolve from the review's working directory; the example refuses to overwrite an existing file. diff --git a/examples/extensions/review-snapshot-export/index.test.ts b/examples/extensions/review-snapshot-export/index.test.ts index e0e481d62..bf73c685d 100644 --- a/examples/extensions/review-snapshot-export/index.test.ts +++ b/examples/extensions/review-snapshot-export/index.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; import { resolveSnapshotExportPath, snapshotPositionMatches } from "./index"; -import type { ExtensionReviewSnapshot } from "../../../src/extension-api/types"; +import type { ExtensionReviewSnapshot } from "../../../packages/hunk/src/extension-api/types"; /** Build the minimal immutable snapshot position these helper tests compare. */ function createTestSnapshot( diff --git a/examples/extensions/review-triage/README.md b/examples/extensions/review-triage/README.md index 31d4f606b..ffe833eef 100644 --- a/examples/extensions/review-triage/README.md +++ b/examples/extensions/review-triage/README.md @@ -5,7 +5,7 @@ A session-local hunk review board for Hunk. It records which hunks you have visi Run it directly from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/review-triage +bun run start -- -- diff --extension ./examples/extensions/review-triage ``` Or copy the directory to your Hunk extensions directory and keep its `package.json`; its manifest makes the folder a single `review-triage` extension. diff --git a/examples/extensions/vim-navigation/README.md b/examples/extensions/vim-navigation/README.md index 074c0bc4d..59a230b9f 100644 --- a/examples/extensions/vim-navigation/README.md +++ b/examples/extensions/vim-navigation/README.md @@ -7,7 +7,7 @@ This example is **not bundled or loaded by Hunk**. Install it explicitly if you ## Try it from this checkout ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/vim-navigation +bun run start -- -- diff --extension ./examples/extensions/vim-navigation ``` Press `F6` or choose **Extensions → Toggle Vim navigation**. The persistent status badge shows when the mode owns review-level keys; click the badge, choose the host-owned exit menu item, or press `Esc` to leave. diff --git a/knip.json b/knip.json index ef1382aca..85da73f0b 100644 --- a/knip.json +++ b/knip.json @@ -3,9 +3,6 @@ "workspaces": { ".": { "entry": [ - "src/extension-api/index.ts", - "src/opentui/index.ts", - "src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts", "test/**/*.test.{ts,tsx}", "test/cli/fixtures/compiled-opentui-positive-control.ts", @@ -13,21 +10,50 @@ "scripts/probe-terminal-theme.ts", "scripts/test-large-untracked-render.tsx", "examples/extensions/**/index.{ts,tsx}", - "examples/extensions/**/run.ts" + "examples/extensions/**/run.ts", + "examples/**/*.test.ts", + "test/cli/fixtures/compiled-highlight-worker-control.ts", + "test/cli/install-vm/validate-release-result.ts", + "test/session-broker-node/*.ts", + "test/session-broker-runtime/*.ts" ], "project": [ - "src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx,mjs}", "test/**/*.{ts,tsx}", "benchmarks/**/*.ts", - "examples/extensions/**/*.{ts,tsx}", - "bin/**/*.cjs" + "examples/extensions/**/*.{ts,tsx}" ], "ignore": ["examples/extensions/**/fixtures/**"], "ignoreBinaries": ["nix"], - "ignoreDependencies": ["@changesets/changelog-github", "@shikijs/themes"], + "ignoreDependencies": ["@changesets/changelog-github", "@shikijs/themes", "playwright"], + "ignoreExportsUsedInFile": true + }, + "packages/hunk": { + "entry": [ + "src/highlightWorkerEntry.ts", + "src/extension-api/index.ts", + "src/opentui/index.ts", + "src/**/*.test.{ts,tsx}" + ], + "project": ["src/**/*.{ts,tsx}", "bin/**/*.cjs"], "ignoreExportsUsedInFile": true }, + "packages/hunk-git": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, + "packages/hunk-jj": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, + "packages/hunk-sapling": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, + "packages/hunk-vcs": { + "entry": ["src/**/*.test.ts"], + "project": ["src/**/*.ts"] + }, "packages/session-broker*": { "entry": ["src/**/*.test.ts"], "project": ["src/**/*.ts"] @@ -39,7 +65,16 @@ } }, "ignoreIssues": { - "src/app/review/capability.ts": ["exports"], - "src/extension-api/types.ts": ["duplicates"] + "packages/hunk/src/app/review/capability.ts": ["exports"], + "packages/hunk/src/extension-api/types.ts": ["duplicates"], + "packages/hunk-git/src/commands.ts": ["exports", "types"], + "packages/hunk-jj/src/commands.ts": ["exports", "types"], + "packages/hunk-sapling/src/commands.ts": ["exports", "types"], + "packages/hunk/src/core/install/latestRelease.ts": ["exports", "types"], + "packages/hunk/src/core/process/updateNotice.ts": ["exports", "types"], + "packages/hunk/src/extensions/types.ts": ["exports", "types"], + "packages/hunk/src/extensions/default/ui/sidebar/index.tsx": ["exports", "types"], + "packages/hunk/src/session/broker/appContract.ts": ["exports", "types"], + "packages/hunk/src/ui/diff/worker/index.ts": ["exports", "types"] } } diff --git a/nix/bun.lock.nix b/nix/bun.lock.nix index 7d5849c2c..b2067c4ce 100644 --- a/nix/bun.lock.nix +++ b/nix/bun.lock.nix @@ -53,11 +53,15 @@ url = "https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.1.tgz"; hash = "sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA=="; }; + "@hunk/git" = copyPathToStore ../packages/hunk-git; + "@hunk/jj" = copyPathToStore ../packages/hunk-jj; + "@hunk/sapling" = copyPathToStore ../packages/hunk-sapling; "@hunk/session-broker" = copyPathToStore ../packages/session-broker; "@hunk/session-broker-bun" = copyPathToStore ../packages/session-broker-bun; "@hunk/session-broker-core" = copyPathToStore ../packages/session-broker-core; "@hunk/session-broker-node" = copyPathToStore ../packages/session-broker-node; "@hunk/term-video" = copyPathToStore ../packages/term-video; + "@hunk/vcs" = copyPathToStore ../packages/hunk-vcs; "@napi-rs/wasm-runtime@1.2.2" = fetchurl { url = "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz"; hash = "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="; @@ -878,6 +882,7 @@ url = "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz"; hash = "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="; }; + "hunkdiff" = copyPathToStore ../packages/hunk; "ignore@7.0.6" = fetchurl { url = "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz"; hash = "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="; diff --git a/nix/package.nix b/nix/package.nix index a9ee5818c..31ad2a456 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -3,9 +3,23 @@ bun2nix, lib, makeWrapper, + stdenv, ... }: let - packageJson = lib.importJSON ../package.json; + packageJson = lib.importJSON ../packages/hunk/package.json; + # Match scripts/build-bin.ts: Bun's default x64 runtime requires AVX2/BMI2, + # while the baseline variants support the x86-64-v2 machines Hunk targets. + compileTarget = + if !stdenv.hostPlatform.isx86_64 + then null + else if stdenv.hostPlatform.isDarwin + then "bun-darwin-x64-baseline" + else if stdenv.hostPlatform.isLinux + then + if stdenv.hostPlatform.isMusl + then "bun-linux-x64-musl-baseline" + else "bun-linux-x64-baseline" + else null; in bun2nix.mkDerivation { pname = "hunkdiff"; @@ -26,7 +40,9 @@ in BUN_INSTALL=$PWD/.bun-install \ ${bun}/bin/bun build --compile \ --no-compile-autoload-bunfig \ - "./src/main.tsx" \ + ${lib.optionalString (compileTarget != null) "--target=${compileTarget}"} \ + "./packages/hunk/src/main.tsx" \ + "./packages/hunk/src/highlightWorkerEntry.ts" \ --outfile "hunk-bin" runHook postBuild ''; @@ -35,7 +51,7 @@ in runHook preInstall mkdir -p $out/bin cp -p ./hunk-bin $out/bin/hunk - cp -r ./skills $out/ + cp -r ./packages/hunk/skills $out/ wrapProgram $out/bin/hunk --set HUNK_INSTALL_SOURCE nix runHook postInstall ''; diff --git a/package.json b/package.json index 351997caf..65437d7f7 100644 --- a/package.json +++ b/package.json @@ -1,59 +1,15 @@ { - "name": "hunkdiff", - "version": "0.21.1", - "description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.", - "keywords": [ - "ai", - "code-review", - "diff", - "git", - "terminal", - "tui" - ], - "homepage": "https://hunk.dev", - "bugs": { - "url": "https://github.com/modem-dev/hunk/issues" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/modem-dev/hunk.git" - }, - "bin": { - "hunk": "./bin/hunk.cjs", - "hunkdiff": "./bin/hunk.cjs" - }, + "name": "@hunk/workspace", + "private": true, "workspaces": [ - ".", "packages/*" ], - "files": [ - "bin", - "dist/npm", - "skills/hunk-review", - "skills/hunk-extensions", - "README.md", - "LICENSE" - ], "type": "module", - "exports": { - "./extension": { - "types": "./dist/npm/extension/index.d.ts", - "import": "./dist/npm/extension/index.js" - }, - "./opentui": { - "types": "./dist/npm/opentui/index.d.ts", - "import": "./dist/npm/opentui/index.js" - }, - "./package.json": "./package.json" - }, - "publishConfig": { - "access": "public" - }, "scripts": { - "start": "bun run src/main.tsx", - "dev": "bun --watch src/main.tsx", + "start": "bun run packages/hunk/src/main.tsx", + "dev": "bun --watch packages/hunk/src/main.tsx", "build:npm": "bun run ./scripts/build-npm.ts", + "stage:source-npm": "bun run ./scripts/stage-source-npm-package.ts", "build:bin": "bun run ./scripts/build-bin.ts", "build:prebuilt:npm": "bun run build:npm && bun run build:bin && bun run ./scripts/stage-prebuilt-npm.ts", "build:prebuilt:artifact": "bun run build:bin && bun run ./scripts/build-prebuilt-artifact.ts", @@ -77,14 +33,14 @@ "lint": "oxlint . --deny-warnings", "lint:fix": "oxlint . --fix", "knip": "knip", - "deps:check": "depcruise src packages --config .dependency-cruiser.cjs --ignore-known", - "deps:baseline": "depcruise src packages --config .dependency-cruiser.cjs --output-type baseline --output-to .dependency-cruiser-known-violations.json", + "deps:check": "depcruise packages --config .dependency-cruiser.cjs --ignore-known", + "deps:baseline": "depcruise packages --config .dependency-cruiser.cjs --output-type baseline --output-to .dependency-cruiser-known-violations.json", "changeset": "bunx @changesets/cli@2.31.0", "changeset:status": "bunx @changesets/cli@2.31.0 status", "release:version": "bunx @changesets/cli@2.31.0 version", "prepare": "simple-git-hooks", "test": "bun run ./scripts/run-test-suite.ts", - "test:theme-contrast": "bun test src/ui/themes.test.ts --test-name-pattern contrast", + "test:theme-contrast": "bun test packages/hunk/src/ui/themes.test.ts --test-name-pattern contrast", "test:integration": "\"${npm_execpath:-bun}\" test ./test/pty", "test:session-broker-node": "bun run ./scripts/test-session-broker-node.ts", "test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 \"${npm_execpath:-bun}\" test ./test/smoke", @@ -94,7 +50,6 @@ "check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts", "smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts", "publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts", - "prepack": "bun run build:npm", "bench": "bun run benchmarks/run.ts", "bench:release": "bun run ./scripts/run-release-benchmark.ts", "bench:release:compare": "bun run ./scripts/compare-release-benchmarks.ts", @@ -121,16 +76,6 @@ "bench:competitors": "bun run benchmarks/competitors.ts", "nix:update-lock": "nix run .#update-bun-lock" }, - "dependencies": { - "bun": "^1.3.14", - "chokidar": "^4.0.3", - "commander": "^14.0.3", - "diff": "^8.0.3", - "get-east-asian-width": "^1.5.0", - "shell-quote": "1.9.0", - "string-width": "^8.2.1", - "zod": "~4.4.3" - }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", "@hunk/session-broker": "workspace:*", @@ -145,6 +90,7 @@ "@types/react": "^19.2.14", "@types/ws": "^8.18.1", "dependency-cruiser": "18.2.0", + "diff": "^8.0.3", "knip": "^6.32.0", "lint-staged": "^16.4.0", "marked": "17.0.1", @@ -152,19 +98,10 @@ "oxlint": "^1.56.0", "react": "^19.2.4", "simple-git-hooks": "^2.13.1", + "string-width": "^8.2.1", "tuistory": "^0.11.0", - "typescript": "^5.9.3" - }, - "peerDependencies": { - "@opentui/core": "^0.5.6", - "@opentui/react": "^0.5.6", - "@pierre/diffs": "1.3.5", - "react": "^19.2.4" - }, - "peerDependenciesMeta": { - "@pierre/diffs": { - "optional": true - } + "typescript": "^5.9.3", + "ws": "^8.18.3" }, "overrides": { "shell-quote": "1.9.0" @@ -178,7 +115,8 @@ "packageManager": "bun@1.3.14", "pi": { "skills": [ - "./skills" + "./skills", + "./packages/hunk/skills" ] } } diff --git a/packages/hunk-git/package.json b/packages/hunk-git/package.json new file mode 100644 index 000000000..a015f2883 --- /dev/null +++ b/packages/hunk-git/package.json @@ -0,0 +1,37 @@ +{ + "name": "@hunk/git", + "version": "0.0.0", + "private": true, + "description": "Private statically bundled Git provider for Hunk", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@hunk/vcs": "workspace:*" + }, + "devDependencies": { + "hunkdiff": "workspace:*" + }, + "peerDependencies": { + "hunkdiff": "^0.21.1" + }, + "engines": { + "bun": ">=1.3.14", + "node": ">=22" + }, + "hunk": { + "extensions": [ + "./src/index.ts" + ], + "apiVersion": 17 + } +} diff --git a/src/extensions/default/vcs/git/commands.test.ts b/packages/hunk-git/src/commands.test.ts similarity index 100% rename from src/extensions/default/vcs/git/commands.test.ts rename to packages/hunk-git/src/commands.test.ts diff --git a/src/extensions/default/vcs/git/commands.ts b/packages/hunk-git/src/commands.ts similarity index 99% rename from src/extensions/default/vcs/git/commands.ts rename to packages/hunk-git/src/commands.ts index 161554a8e..39acddfa8 100644 --- a/src/extensions/default/vcs/git/commands.ts +++ b/packages/hunk-git/src/commands.ts @@ -6,15 +6,15 @@ import { type ExtensionVcsShowInput, type ExtensionVcsStashShowInput, } from "hunkdiff/extension"; -import { LARGE_DIFF_FILE_MAX_BYTES, LARGE_DIFF_FILE_MAX_LINES } from "../../../../lib/largeFile"; -import { normalizePathForOS } from "../../../../lib/osPath"; -import { describeDiffRange, describeDiffTargets } from "../diffRange"; +import { LARGE_DIFF_FILE_MAX_BYTES, LARGE_DIFF_FILE_MAX_LINES } from "@hunk/vcs/large-file"; +import { normalizePathForOS } from "@hunk/vcs/os-path"; +import { describeDiffRange, describeDiffTargets } from "@hunk/vcs/diff-range"; /** * Every Git command Hunk runs, and the failures they translate into. * * This is the implementation layer behind the bundled Git backend - * (`src/extensions/default/vcs/git/`), so nothing here reaches into core, the + * (`packages/hunk-git`), so nothing here reaches into core, the * diff engine, or the adapter registry — user-facing failures are raised as the * published `HunkExtensionUserError`, which is exactly what a third-party * backend would throw. diff --git a/src/extensions/default/vcs/git/index.test.ts b/packages/hunk-git/src/index.test.ts similarity index 100% rename from src/extensions/default/vcs/git/index.test.ts rename to packages/hunk-git/src/index.test.ts diff --git a/src/extensions/default/vcs/git/index.ts b/packages/hunk-git/src/index.ts similarity index 95% rename from src/extensions/default/vcs/git/index.ts rename to packages/hunk-git/src/index.ts index d3a1b27ff..a4ad84b24 100644 --- a/src/extensions/default/vcs/git/index.ts +++ b/packages/hunk-git/src/index.ts @@ -20,7 +20,7 @@ import { type GitDiffEndpoints, } from "./commands"; import { gitEndpointSourceSpec, readGitFileSource } from "./source"; -import { describeDiffRange } from "../diffRange"; +import { describeDiffRange } from "@hunk/vcs/diff-range"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, @@ -39,8 +39,8 @@ import { * file sources, skipped-too-large placeholders, untracked files, watch plans, * rich failures — so it is deliberately written the way a third-party backend * would be: it sees only the published `hunkdiff/extension` contract plus - * implementation helpers owned by this extension directory and generic `src/lib` - * utilities. Nothing here reaches into core, the diff engine, or the + * implementation helpers owned by this package and provider-neutral + * `@hunk/vcs` subpaths. Nothing here reaches into core, the diff engine, or the * adapter registry. If something Git needs cannot be said in these types, the * published contract is missing it, and that is the point of shipping it this * way. @@ -445,6 +445,16 @@ export function createGitVcsAdapter({ export const GitVcsAdapter = createGitVcsAdapter(); -export default function (hunk: HunkExtensionAPI) { +/** Activates the statically bundled Git provider through Hunk's public API. */ +export default function gitExtension(hunk: HunkExtensionAPI) { hunk.registerVcsAdapter(GitVcsAdapter); } + +/** Identifies this private package and its ordered extension entries to the host. */ +export const bundledExtensionPackage = Object.freeze({ + packageId: "@hunk/git", + packageName: "@hunk/git", + packageVersion: "0.0.0", + reservedVcsIds: Object.freeze(["git"]), + entries: Object.freeze([{ id: "git", factory: gitExtension }]), +}); diff --git a/src/extensions/default/vcs/git/source.test.ts b/packages/hunk-git/src/source.test.ts similarity index 100% rename from src/extensions/default/vcs/git/source.test.ts rename to packages/hunk-git/src/source.test.ts diff --git a/src/extensions/default/vcs/git/source.ts b/packages/hunk-git/src/source.ts similarity index 99% rename from src/extensions/default/vcs/git/source.ts rename to packages/hunk-git/src/source.ts index b5a767291..e5634bd29 100644 --- a/src/extensions/default/vcs/git/source.ts +++ b/packages/hunk-git/src/source.ts @@ -9,7 +9,7 @@ import { readFileTextWithLimit, readStreamTextWithLimit, terminateSourceSubprocess, -} from "../../../../lib/sourceText"; +} from "@hunk/vcs/source-text"; import type { GitDiffEndpoint } from "./commands"; /** A provider-local signal converted to the public structural result at this boundary. */ diff --git a/packages/hunk-jj/package.json b/packages/hunk-jj/package.json new file mode 100644 index 000000000..a858269ac --- /dev/null +++ b/packages/hunk-jj/package.json @@ -0,0 +1,37 @@ +{ + "name": "@hunk/jj", + "version": "0.0.0", + "private": true, + "description": "Private statically bundled Jujutsu provider for Hunk", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "dependencies": { + "@hunk/vcs": "workspace:*" + }, + "devDependencies": { + "hunkdiff": "workspace:*" + }, + "peerDependencies": { + "hunkdiff": "^0.21.1" + }, + "engines": { + "bun": ">=1.3.14", + "node": ">=22" + }, + "hunk": { + "extensions": [ + "./src/index.ts" + ], + "apiVersion": 17 + } +} diff --git a/src/extensions/default/vcs/jujutsu/commands.test.ts b/packages/hunk-jj/src/commands.test.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/commands.test.ts rename to packages/hunk-jj/src/commands.test.ts diff --git a/src/extensions/default/vcs/jujutsu/commands.ts b/packages/hunk-jj/src/commands.ts similarity index 98% rename from src/extensions/default/vcs/jujutsu/commands.ts rename to packages/hunk-jj/src/commands.ts index 39c2272a4..c66d27f19 100644 --- a/src/extensions/default/vcs/jujutsu/commands.ts +++ b/packages/hunk-jj/src/commands.ts @@ -4,8 +4,8 @@ import { type ExtensionVcsRangeEndpoints, type ExtensionVcsShowInput, } from "hunkdiff/extension"; -import { normalizePathForOS } from "../../../../lib/osPath"; -import { describeDiffTargets } from "../diffRange"; +import { normalizePathForOS } from "@hunk/vcs/os-path"; +import { describeDiffTargets } from "@hunk/vcs/diff-range"; export type JjBackedInput = ExtensionVcsDiffInput | ExtensionVcsShowInput; diff --git a/src/extensions/default/vcs/jujutsu/index.test.ts b/packages/hunk-jj/src/index.test.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/index.test.ts rename to packages/hunk-jj/src/index.test.ts diff --git a/src/extensions/default/vcs/jujutsu/index.ts b/packages/hunk-jj/src/index.ts similarity index 93% rename from src/extensions/default/vcs/jujutsu/index.ts rename to packages/hunk-jj/src/index.ts index 48018f053..b4df5bcf5 100644 --- a/src/extensions/default/vcs/jujutsu/index.ts +++ b/packages/hunk-jj/src/index.ts @@ -11,7 +11,7 @@ import { type JjDiffEndpoints, } from "./commands"; import { readJjFileSource } from "./source"; -import { describeDiffRange } from "../diffRange"; +import { describeDiffRange } from "@hunk/vcs/diff-range"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, @@ -211,6 +211,16 @@ export function createJjVcsAdapter({ jjExecutable = "jj" }: Readonly=1.3.14", + "node": ">=22" + }, + "hunk": { + "extensions": [ + "./src/index.ts" + ], + "apiVersion": 17 + } +} diff --git a/src/extensions/default/vcs/sapling/commands.test.ts b/packages/hunk-sapling/src/commands.test.ts similarity index 100% rename from src/extensions/default/vcs/sapling/commands.test.ts rename to packages/hunk-sapling/src/commands.test.ts diff --git a/src/extensions/default/vcs/sapling/commands.ts b/packages/hunk-sapling/src/commands.ts similarity index 98% rename from src/extensions/default/vcs/sapling/commands.ts rename to packages/hunk-sapling/src/commands.ts index 91d162a27..ce6443c31 100644 --- a/src/extensions/default/vcs/sapling/commands.ts +++ b/packages/hunk-sapling/src/commands.ts @@ -5,8 +5,8 @@ import { type ExtensionVcsDiffInput, type ExtensionVcsShowInput, } from "hunkdiff/extension"; -import { normalizePathForOS } from "../../../../lib/osPath"; -import { describeDiffTargets } from "../diffRange"; +import { normalizePathForOS } from "@hunk/vcs/os-path"; +import { describeDiffTargets } from "@hunk/vcs/diff-range"; export type SlBackedInput = ExtensionVcsDiffInput | ExtensionVcsShowInput; diff --git a/src/extensions/default/vcs/sapling/index.test.ts b/packages/hunk-sapling/src/index.test.ts similarity index 100% rename from src/extensions/default/vcs/sapling/index.test.ts rename to packages/hunk-sapling/src/index.test.ts diff --git a/src/extensions/default/vcs/sapling/index.ts b/packages/hunk-sapling/src/index.ts similarity index 87% rename from src/extensions/default/vcs/sapling/index.ts rename to packages/hunk-sapling/src/index.ts index 37eacb1df..c0cd5f711 100644 --- a/src/extensions/default/vcs/sapling/index.ts +++ b/packages/hunk-sapling/src/index.ts @@ -8,7 +8,7 @@ import { resolveSlRepoRoot, runSlText, } from "./commands"; -import { describeDiffRange } from "../diffRange"; +import { describeDiffRange } from "@hunk/vcs/diff-range"; import { HUNK_VCS_DETECTION_BASELINE_PRIORITY, type ExtensionVcsAdapter, @@ -120,6 +120,16 @@ export const SaplingVcsAdapter = { }, } satisfies ExtensionVcsAdapter; -export default function (hunk: HunkExtensionAPI) { +/** Activates the statically bundled Sapling provider through Hunk's public API. */ +export default function saplingExtension(hunk: HunkExtensionAPI) { hunk.registerVcsAdapter(SaplingVcsAdapter); } + +/** Identifies this private package and its ordered extension entries to the host. */ +export const bundledExtensionPackage = Object.freeze({ + packageId: "@hunk/sapling", + packageName: "@hunk/sapling", + packageVersion: "0.0.0", + reservedVcsIds: Object.freeze(["sl"]), + entries: Object.freeze([{ id: "sl", factory: saplingExtension }]), +}); diff --git a/packages/hunk-vcs/package.json b/packages/hunk-vcs/package.json new file mode 100644 index 000000000..b5cca9596 --- /dev/null +++ b/packages/hunk-vcs/package.json @@ -0,0 +1,41 @@ +{ + "name": "@hunk/vcs", + "version": "0.0.0", + "private": true, + "description": "Private shared utilities for Hunk bundled VCS providers", + "license": "MIT", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./diff-range": { + "types": "./src/diffRange.ts", + "import": "./src/diffRange.ts" + }, + "./large-file": { + "types": "./src/largeFile.ts", + "import": "./src/largeFile.ts" + }, + "./os-path": { + "types": "./src/osPath.ts", + "import": "./src/osPath.ts" + }, + "./source-text": { + "types": "./src/sourceText.ts", + "import": "./src/sourceText.ts" + } + }, + "devDependencies": { + "hunkdiff": "workspace:*" + }, + "engines": { + "bun": ">=1.3.14", + "node": ">=22" + } +} diff --git a/src/extensions/default/vcs/diffRange.ts b/packages/hunk-vcs/src/diffRange.ts similarity index 100% rename from src/extensions/default/vcs/diffRange.ts rename to packages/hunk-vcs/src/diffRange.ts diff --git a/packages/hunk-vcs/src/index.ts b/packages/hunk-vcs/src/index.ts new file mode 100644 index 000000000..6b9508e3b --- /dev/null +++ b/packages/hunk-vcs/src/index.ts @@ -0,0 +1,5 @@ +/** Exports provider-neutral utilities shared by Hunk's bundled VCS packages. */ +export * from "./diffRange"; +export * from "./largeFile"; +export * from "./osPath"; +export * from "./sourceText"; diff --git a/src/lib/largeFile.ts b/packages/hunk-vcs/src/largeFile.ts similarity index 100% rename from src/lib/largeFile.ts rename to packages/hunk-vcs/src/largeFile.ts diff --git a/src/lib/osPath.test.ts b/packages/hunk-vcs/src/osPath.test.ts similarity index 100% rename from src/lib/osPath.test.ts rename to packages/hunk-vcs/src/osPath.test.ts diff --git a/src/lib/osPath.ts b/packages/hunk-vcs/src/osPath.ts similarity index 100% rename from src/lib/osPath.ts rename to packages/hunk-vcs/src/osPath.ts diff --git a/src/lib/sourceText.test.ts b/packages/hunk-vcs/src/sourceText.test.ts similarity index 100% rename from src/lib/sourceText.test.ts rename to packages/hunk-vcs/src/sourceText.test.ts diff --git a/src/lib/sourceText.ts b/packages/hunk-vcs/src/sourceText.ts similarity index 100% rename from src/lib/sourceText.ts rename to packages/hunk-vcs/src/sourceText.ts diff --git a/packages/hunk/LICENSE b/packages/hunk/LICENSE new file mode 100644 index 000000000..21f202055 --- /dev/null +++ b/packages/hunk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Modem Labs Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/hunk/README.md b/packages/hunk/README.md new file mode 100644 index 000000000..d327d7a3f --- /dev/null +++ b/packages/hunk/README.md @@ -0,0 +1,311 @@ +# hunk + +Hunk is a review-first terminal diff viewer for agent-authored changesets, built on [OpenTUI](https://github.com/anomalyco/opentui) and [Pierre diffs](https://www.npmjs.com/package/@pierre/diffs). + +**[hunk.dev](https://hunk.dev)** · [Documentation](https://hunk.dev/docs/) + +[![CI status](https://img.shields.io/github/actions/workflow/status/modem-dev/hunk/ci.yml?branch=main&style=for-the-badge&label=CI)](https://github.com/modem-dev/hunk/actions/workflows/ci.yml?branch=main) +[![Latest release](https://img.shields.io/github/v/release/modem-dev/hunk?style=for-the-badge)](https://github.com/modem-dev/hunk/releases) +[![MIT License](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE) +[![Join the Discord community](https://img.shields.io/badge/Discord-Join%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/WZFjaP6Gt8) + +- multi-file review stream with sidebar navigation +- inline AI and agent annotations beside the code +- split, stack, and responsive auto layouts +- watch mode for auto-reloading file and Git-backed reviews +- keyboard, mouse, pager, and Git difftool support + + + + + + +
+ image +
+ Split view with sidebar and inline AI notes +
+ image +
+ Stacked view and mouse-selectable menus +
+ +## Install + +The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise: + +```bash +curl -fsSL https://hunk.dev/install.sh | sh +``` + +Windows users can install with npm or mise. Other installation methods are also available: + +```bash +npm i -g hunkdiff # macOS, Linux, or Windows; requires Node.js 22+ +brew install hunk # macOS or Linux +mise use -g hunk # macOS, Linux, or Windows +``` + +> [!NOTE] +> If you previously installed hunk via `modem-dev/tap`, be sure to uninstall it first with `brew uninstall modem-dev/tap/hunk`. + +Windows requires mise 2026.8.6 or newer. Nix users can use the `default` package exported in `flake.nix`; see [nix/README.md](./nix/README.md) for details. Hunk also ships as a default tool in [Omarchy](https://omarchy.org), installed through mise. + +Requirements: + +- macOS, Linux, or Windows +- On x86-64, a CPU with SSE4.2 (Intel Nehalem 2008+, AMD Bulldozer 2011+); arm64 has no CPU feature floor +- Node.js 22+ for the npm install; the install script, Homebrew, mise, and Nix ship a standalone binary that does not require Node.js +- Git recommended for most workflows + +### Update Hunk + +Starting with Hunk 0.20, npm, Homebrew, and default install-script installs use Hunk’s canonical update command: + +```bash +hunk update # install the newest release +hunk update --check # check without installing +hunk update 0.20.0 # select an exact npm or default install-script release +``` + +On an older release, update once with the installer or package manager that installed Hunk, then use `hunk update` going forward. Custom `HUNK_INSTALL_DIR` installs must re-run the installer with the same directory; mise, Nix, and source installs use their owning tools instead. + +## Quick start + +```bash +hunk # show help +hunk --version # print the installed version +``` + +### Working with Git + +Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI instead of plain text. + +```bash +hunk diff # review current repo changes, including untracked files +hunk --fast # experimentally offload eligible syntax highlighting +hunk diff --watch # auto-reload as the working tree changes +hunk show # review the latest commit +hunk show HEAD~1 # review an earlier commit +``` + +### Working with Jujutsu and Sapling + +Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). + +### Working with raw files and patches + +```bash +hunk diff --files before.ts after.ts # compare two files directly +hunk diff --files before.ts after.ts --watch # auto-reload when either file changes +git diff --no-color | hunk patch - # review a patch from stdin +``` + +Watch mode remains continuous. Direct-file and Git-backed reviews normally use filesystem observation to refresh promptly, with periodic polling retained as a fallback for missed events or unavailable watchers. Jujutsu and Sapling reviews currently use polling rather than filesystem observation. + +### Working with agents + +1. Open Hunk in another terminal with `hunk diff` or `hunk show`. +2. Tell your agent to add the skill file returned by `hunk skill path`. +3. Ask your agent to use the skill against the live Hunk session. + +A good generic prompt is: + +```text +Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path. +``` + +For the full live-session and `--agent-context` workflow guide, see [docs/agent-workflows.md](docs/agent-workflows.md). Experimental rich STML note bodies require starting the review with `--experimental`; plain agent notes remain the default. + +## Feature comparison + +| Capability | [hunk](https://github.com/modem-dev/hunk) | [lumen](https://github.com/jnsahaj/lumen) | [difftastic](https://github.com/Wilfred/difftastic) | [delta](https://github.com/dandavison/delta) | [diff-so-fancy](https://github.com/so-fancy/diff-so-fancy) | [diff](https://www.gnu.org/software/diffutils/) | +| ---------------------------------- | ----------------------------------------- | ----------------------------------------- | --------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------- | +| Review-first interactive UI | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Multi-file review stream + sidebar | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Inline agent / AI annotations | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Responsive auto split/stack layout | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Mouse support inside the viewer | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Runtime view toggles | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Syntax highlighting | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Structural diffing | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | +| Pager-compatible mode | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | + +Hunk is optimized for reviewing a full changeset interactively. + +## Advanced + +### Config + +You can persist preferences to a config file: + +- `~/.config/hunk/config.toml` +- `.hunk/config.toml` + +Example: + +```toml +theme = "github-dark-default" # any built-in theme id, auto, or custom +mode = "auto" # auto, split, stack +vcs = "git" # git, jj, sl +watch = false +exclude_untracked = false +line_numbers = true +tab_width = 4 # tab stops, 1-16 +file_gap = 1 # rows between files, including the ─ rule; 0 hides it +hunk_gap = 0 # blank rows before later hunks +wrap_lines = false +menu_bar = true +sidebar = "auto" # "auto", true, false +agent_notes = false +prompt_save_view_preferences = true +transparent_background = false +``` + +Choose a built-in theme, `auto`, or a custom theme with `theme`. See +[docs/themes.md](docs/themes.md) for automatic selection, custom theme tables, +syntax scopes, and legacy syntax-table migration. + +`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only. +`tab_width` controls source-code tab stops and can be overridden with `-x4` or `--tab-width 4`. +`file_gap` is separator height between files, including the `─` rule; `hunk_gap` is blank rows before later hunks. +`prompt_save_view_preferences = false` disables the quit prompt for saving changed view preferences. +`transparent_background` can also be written as `transparentBackground`. + +### Keybindings + +Every keyboard shortcut is a named command, and a `[keybindings]` table in your +user config remaps command ids to the keys you want them on — several keys per +command, exclusive claims over defaults, and `false` to unbind. See +[docs/keybindings.md](docs/keybindings.md) for the rules, the chord grammar, +and the full table of built-in commands and their default keys. + +### Git integration + +Set Hunk as your Git pager so `git diff` and `git show` open in Hunk automatically: + +> [!NOTE] +> Untracked files are auto-included only for Hunk's own `hunk diff` working-tree loader. If you open `git diff` through `hunk pager`, Git still decides the patch contents, so untracked files will not appear there. + +```bash +git config --global core.pager "hunk pager" +``` + +Or in your Git config: + +```ini +[core] + pager = hunk pager +``` + +If you want to keep Git's default pager and add opt-in aliases instead: + +```bash +git config --global alias.hdiff "-c core.pager=\"hunk pager\" diff" +git config --global alias.hshow "-c core.pager=\"hunk pager\" show" +``` + +### Jujutsu pager integration + +To use Hunk as jj's pager, run `jj config edit --user` and update: + +```toml +[ui] +pager = ["hunk", "pager"] +diff-formatter = ":git" +``` + +### Sapling pager integration + +To use Hunk as Sapling's pager, run `sl config -u` and update: + +```ini +[pager] +pager = hunk pager +``` + +### Extensions (experimental) + +The extension API is experimental and may change in breaking ways between +minor releases while it stabilizes; breaking changes are called out in +release notes. + +Hunk loads plain TypeScript extensions from `~/.config/hunk/extensions/`, from a +repository's `.hunk/extensions/` (after you explicitly trust that repository), +and from `--extension ` for development. `--no-extensions` turns those off +for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded. + +An extension can add generic top-level CLI workflows, contribute themes and +file-extension → language mappings, add a VCS backend, rewrite the changeset +before review (collapse lockfiles, reorder files by review priority), replace +the file-navigation sidebar with its own React component, react to lifecycle +events, and show transient messages: + +```ts +// ~/.config/hunk/extensions/collapse-lockfiles.ts +import type { HunkExtensionAPI } from "hunkdiff/extension"; + +export default function (hunk: HunkExtensionAPI) { + hunk.transformChangeset((changeset, ctx) => { + const files = changeset.files.filter((file) => !file.path.endsWith(".lock")); + ctx.notify(`Collapsed ${changeset.files.length - files.length} lockfiles`); + return { ...changeset, files }; + }); +} +``` + +Extensions shared as git repositories install straight from their host, and a +`hunk-extension` GitHub topic marks community ones: + +```bash +hunk extension install acme/hunk-word-diff@v1.2.0 # or git:host/path, a URL, a local path +hunk extension list # then update [name] / remove +``` + +Browse community extensions at +[github.com/topics/hunk-extension](https://github.com/topics/hunk-extension); +publish yours by pushing the extension to a repository root and adding that +topic. + +See [docs/extensions.md](docs/extensions.md) for the full API, the trust model, +publishing guidance, and the `[extensions]` / `[extension.]` config reference. +Installable examples include a dependency-free +[`hunk gh 123` GitHub PR workflow](examples/extensions/github-pr/), +[review triage](examples/extensions/review-triage/), +[authoritative review snapshot export](examples/extensions/review-snapshot-export/), an optional +[rendered Markdown file view](examples/extensions/rendered-markdown/), and a +[Vim navigation mode](examples/extensions/vim-navigation/) built from public semantic commands. + +### OpenTUI component + +Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/opentui` for embedding the same diff renderer in your own OpenTUI app. + +See [docs/opentui-component.md](docs/opentui-component.md) for install, API, and runnable examples. + +## Examples + +Ready-to-run demo diffs live in [`examples/`](examples/README.md). + +Each example includes the exact command to run from the repository root. + +## Contributing + +💬 _Chat with users/contributors on the [Modem Discord server](https://discord.gg/WZFjaP6Gt8)_ + +For source setup, tests, packaging checks, and repo architecture, see [CONTRIBUTING.md](CONTRIBUTING.md). + +## Sponsor + +Sponsored by [Modem](https://modem.dev?utm_source=github&utm_medium=oss&utm_campaign=oss_hunk&utm_content=readme_footer). + + + + + + Modem + + + +## License + +[MIT](LICENSE) diff --git a/bin/hunk.cjs b/packages/hunk/bin/hunk.cjs similarity index 100% rename from bin/hunk.cjs rename to packages/hunk/bin/hunk.cjs diff --git a/packages/hunk/package.json b/packages/hunk/package.json new file mode 100644 index 000000000..7f3b59537 --- /dev/null +++ b/packages/hunk/package.json @@ -0,0 +1,93 @@ +{ + "name": "hunkdiff", + "version": "0.21.1", + "description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.", + "keywords": [ + "ai", + "code-review", + "diff", + "git", + "terminal", + "tui" + ], + "homepage": "https://hunk.dev", + "bugs": { + "url": "https://github.com/modem-dev/hunk/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/modem-dev/hunk.git" + }, + "bin": { + "hunk": "./bin/hunk.cjs", + "hunkdiff": "./bin/hunk.cjs" + }, + "files": [ + "bin", + "dist/npm", + "skills/hunk-review", + "skills/hunk-extensions", + "README.md", + "LICENSE" + ], + "type": "module", + "exports": { + "./extension": { + "types": "./dist/npm/extension/index.d.ts", + "import": "./dist/npm/extension/index.js" + }, + "./opentui": { + "types": "./dist/npm/opentui/index.d.ts", + "import": "./dist/npm/opentui/index.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "bun run ../../scripts/build-npm.ts" + }, + "dependencies": { + "bun": "^1.3.14", + "chokidar": "^4.0.3", + "commander": "^14.0.3", + "diff": "^8.0.3", + "get-east-asian-width": "^1.5.0", + "shell-quote": "1.9.0", + "string-width": "^8.2.1", + "zod": "~4.4.3" + }, + "devDependencies": { + "@hunk/git": "workspace:*", + "@hunk/jj": "workspace:*", + "@hunk/sapling": "workspace:*", + "@hunk/session-broker": "workspace:*", + "@hunk/session-broker-bun": "workspace:*", + "@hunk/session-broker-core": "workspace:*", + "@hunk/vcs": "workspace:*", + "@opentui/core": "^0.5.6", + "@opentui/react": "^0.5.6", + "@pierre/diffs": "1.3.5", + "@shikijs/themes": "3.23.0", + "@types/bun": "1.3.14", + "@types/react": "^19.2.14", + "react": "^19.2.4", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@opentui/core": "^0.5.6", + "@opentui/react": "^0.5.6", + "@pierre/diffs": "1.3.5", + "react": "^19.2.4" + }, + "peerDependenciesMeta": { + "@pierre/diffs": { + "optional": true + } + }, + "engines": { + "node": ">=22" + } +} diff --git a/skills/hunk-extensions/SKILL.md b/packages/hunk/skills/hunk-extensions/SKILL.md similarity index 93% rename from skills/hunk-extensions/SKILL.md rename to packages/hunk/skills/hunk-extensions/SKILL.md index 813027c6b..162490fe3 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/packages/hunk/skills/hunk-extensions/SKILL.md @@ -24,13 +24,13 @@ material before writing code. ## Sources of truth — read before writing -| Source | What it answers | -| --------------------------------------- | ------------------------------------------------------------ | -| `docs/extensions.md` | The authoring guide. Every call, every rule. Start here. | -| `src/extension-api/types.ts` | The contract — exact field names, optionality, doc comments. | -| `examples/extensions/*` | Working extensions. Copy these patterns rather than invent. | -| `docs/extension-architecture.md` | Hunk's internals. Needed only when changing the host. | -| `docs/keybindings.md`, `docs/themes.md` | Chord grammar and theme token rules that extensions inherit. | +| Source | What it answers | +| ------------------------------------------ | ------------------------------------------------------------ | +| `docs/extensions.md` | The authoring guide. Every call, every rule. Start here. | +| `packages/hunk/src/extension-api/types.ts` | The contract — exact field names, optionality, doc comments. | +| `examples/extensions/*` | Working extensions. Copy these patterns rather than invent. | +| `docs/extension-architecture.md` | Hunk's internals. Needed only when changing the host. | +| `docs/keybindings.md`, `docs/themes.md` | Chord grammar and theme token rules that extensions inherit. | Outside a Hunk checkout the guide is split across (discovery, trust, config) and its @@ -312,12 +312,13 @@ Practical checks, in order of cost: Only when the work is in the `hunk` repo rather than in a user extension: -- Shipped VCS backends and the built-in files pane are **bundled extensions** in - `src/extensions/default/`, registering through the same public API. That +- Shipped VCS backends are private bundled workspaces in `packages/hunk-git`, + `packages/hunk-jj`, and `packages/hunk-sapling`; the built-in panes remain under + `packages/hunk/src/extensions/default/ui`. All register through the same public API. That dogfooding is deliberate — if the public contract cannot express something, - that is a real gap, not a reason for a private path. `default/vcs/` loads from - VCS adapter resolution and must stay renderer-free. -- `src/extension-api/types.ts` must stay **import-free**; declaration emission + that is a real gap, not a reason for a private path. `bundledPackages.ts` loads the + private VCS workspaces during adapter resolution, and those workspaces must stay renderer-free. +- `packages/hunk/src/extension-api/types.ts` must stay **import-free**; declaration emission publishes whatever it reaches, and `scripts/check-pack.ts` fails the pack otherwise. Shapes shared with internal code are declared there and re-exported inward. diff --git a/skills/hunk-review/SKILL.md b/packages/hunk/skills/hunk-review/SKILL.md similarity index 100% rename from skills/hunk-review/SKILL.md rename to packages/hunk/skills/hunk-review/SKILL.md diff --git a/src/app/cli.test.ts b/packages/hunk/src/app/cli.test.ts similarity index 99% rename from src/app/cli.test.ts rename to packages/hunk/src/app/cli.test.ts index dd9333691..72b1a7813 100644 --- a/src/app/cli.test.ts +++ b/packages/hunk/src/app/cli.test.ts @@ -2222,6 +2222,16 @@ describe("parseCli extension management commands", () => { kind: "extension-manage", action: "list", }); + expect(await parseCli(["bun", "hunk", "extension", "disable", "@acme/tools"])).toEqual({ + kind: "extension-manage", + action: "disable", + name: "@acme/tools", + }); + expect(await parseCli(["bun", "hunk", "extension", "enable", "tools"])).toEqual({ + kind: "extension-manage", + action: "enable", + name: "tools", + }); expect(await parseCli(["bun", "hunk", "extension", "update"])).toEqual({ kind: "extension-manage", action: "update", @@ -2268,7 +2278,7 @@ describe("parseCli extension management commands", () => { } expect(parseCli(["bun", "hunk", "extension", "publish"])).rejects.toThrow( - /Supported extension subcommands/, + "Supported extension subcommands are install, list, enable, disable, update, and remove.", ); }); diff --git a/src/app/cli.ts b/packages/hunk/src/app/cli.ts similarity index 97% rename from src/app/cli.ts rename to packages/hunk/src/app/cli.ts index 4c6d4483b..6626e5bdb 100644 --- a/src/app/cli.ts +++ b/packages/hunk/src/app/cli.ts @@ -263,6 +263,18 @@ export const CLI_REFERENCE_COMMANDS = { synopsis: ["hunk extension list"], aliases: ["hunk ext list"], }, + "extension-enable": { + path: "extension enable", + summary: "enable every entry in one managed extension package", + synopsis: ["hunk extension enable "], + aliases: ["hunk ext enable"], + }, + "extension-disable": { + path: "extension disable", + summary: "disable every entry in one managed extension package", + synopsis: ["hunk extension disable "], + aliases: ["hunk ext disable"], + }, "extension-update": { path: "extension update", summary: "re-clone managed extension installs from their recorded sources", @@ -1723,6 +1735,8 @@ const EXTENSION_MANAGE_HELP = [ "Usage:", " hunk extension install [--yes]", " hunk extension list", + " hunk extension enable ", + " hunk extension disable ", " hunk extension update [name]", " hunk extension remove ", "", @@ -1732,7 +1746,9 @@ const EXTENSION_MANAGE_HELP = [ " sources are /[@ref], git:/[@ref], a git URL,", " or a local path. Installed extensions run with your full user", " permissions — only install repositories you trust.", - "list show every managed install with its version, commit, and source", + "list show managed installs, package identities, entries, and activation", + "enable activate all entries in a managed package for new sessions", + "disable skip all entries in a managed package before import or trust checks", "update re-clone one managed install (or all of them) from its source", "remove delete one managed install and its record", "", @@ -1786,6 +1802,21 @@ async function parseExtensionCommand( return { kind: "extension-manage", action: "list" }; } + if (subcommand === "enable" || subcommand === "disable") { + const command = createCliReferenceCommand( + subcommand === "enable" ? "extension-enable" : "extension-disable", + ).argument(""); + let parsedName = ""; + command.action((name: string) => { + parsedName = name; + }); + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + await parseStandaloneCommand(command, rest); + return { kind: "extension-manage", action: subcommand, name: parsedName }; + } + if (subcommand === "update") { const command = createCliReferenceCommand("extension-update").argument("[name]"); @@ -1818,7 +1849,9 @@ async function parseExtensionCommand( return { kind: "extension-manage", action: "remove", name: parsedName }; } - throw new Error("Supported extension subcommands are install, list, update, and remove."); + throw new Error( + "Supported extension subcommands are install, list, enable, disable, update, and remove.", + ); } /** Parse `hunk update` as the standalone self-update command. */ diff --git a/src/app/delegatedReview.test.ts b/packages/hunk/src/app/delegatedReview.test.ts similarity index 100% rename from src/app/delegatedReview.test.ts rename to packages/hunk/src/app/delegatedReview.test.ts diff --git a/src/app/delegatedReview.ts b/packages/hunk/src/app/delegatedReview.ts similarity index 100% rename from src/app/delegatedReview.ts rename to packages/hunk/src/app/delegatedReview.ts diff --git a/src/app/extensionBootstrap.test.ts b/packages/hunk/src/app/extensionBootstrap.test.ts similarity index 100% rename from src/app/extensionBootstrap.test.ts rename to packages/hunk/src/app/extensionBootstrap.test.ts diff --git a/src/app/extensionBootstrap.ts b/packages/hunk/src/app/extensionBootstrap.ts similarity index 100% rename from src/app/extensionBootstrap.ts rename to packages/hunk/src/app/extensionBootstrap.ts diff --git a/src/app/extensionCliBootstrap.test.ts b/packages/hunk/src/app/extensionCliBootstrap.test.ts similarity index 100% rename from src/app/extensionCliBootstrap.test.ts rename to packages/hunk/src/app/extensionCliBootstrap.test.ts diff --git a/src/app/extensionCliBootstrap.ts b/packages/hunk/src/app/extensionCliBootstrap.ts similarity index 100% rename from src/app/extensionCliBootstrap.ts rename to packages/hunk/src/app/extensionCliBootstrap.ts diff --git a/src/app/review/capability.ts b/packages/hunk/src/app/review/capability.ts similarity index 100% rename from src/app/review/capability.ts rename to packages/hunk/src/app/review/capability.ts diff --git a/src/app/review/producer.test.ts b/packages/hunk/src/app/review/producer.test.ts similarity index 99% rename from src/app/review/producer.test.ts rename to packages/hunk/src/app/review/producer.test.ts index aa05a8886..87c6329b2 100644 --- a/src/app/review/producer.test.ts +++ b/packages/hunk/src/app/review/producer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; import { SourceTextTooLargeError } from "../../core/changeset/fileSource"; import { parseReviewGeneration } from "../../core/review/generationOrder"; import { diff --git a/src/app/review/producer.ts b/packages/hunk/src/app/review/producer.ts similarity index 100% rename from src/app/review/producer.ts rename to packages/hunk/src/app/review/producer.ts diff --git a/src/app/review/publication.ts b/packages/hunk/src/app/review/publication.ts similarity index 100% rename from src/app/review/publication.ts rename to packages/hunk/src/app/review/publication.ts diff --git a/src/app/review/resourceStore.ts b/packages/hunk/src/app/review/resourceStore.ts similarity index 100% rename from src/app/review/resourceStore.ts rename to packages/hunk/src/app/review/resourceStore.ts diff --git a/src/app/session/bridge.test.ts b/packages/hunk/src/app/session/bridge.test.ts similarity index 100% rename from src/app/session/bridge.test.ts rename to packages/hunk/src/app/session/bridge.test.ts diff --git a/src/app/session/bridge.ts b/packages/hunk/src/app/session/bridge.ts similarity index 100% rename from src/app/session/bridge.ts rename to packages/hunk/src/app/session/bridge.ts diff --git a/src/app/session/registration.test.ts b/packages/hunk/src/app/session/registration.test.ts similarity index 99% rename from src/app/session/registration.test.ts rename to packages/hunk/src/app/session/registration.test.ts index 1e119b635..c7c681f59 100644 --- a/src/app/session/registration.test.ts +++ b/packages/hunk/src/app/session/registration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { reviewProcessCapability } from "../review/capability"; import { buildReviewPublication } from "../review/publication"; import type { AppBootstrap } from "../../core/bootstrap"; diff --git a/src/app/session/registration.ts b/packages/hunk/src/app/session/registration.ts similarity index 100% rename from src/app/session/registration.ts rename to packages/hunk/src/app/session/registration.ts diff --git a/src/app/session/reloadBounds.test.ts b/packages/hunk/src/app/session/reloadBounds.test.ts similarity index 100% rename from src/app/session/reloadBounds.test.ts rename to packages/hunk/src/app/session/reloadBounds.test.ts diff --git a/src/app/session/reloadBounds.ts b/packages/hunk/src/app/session/reloadBounds.ts similarity index 100% rename from src/app/session/reloadBounds.ts rename to packages/hunk/src/app/session/reloadBounds.ts diff --git a/src/app/session/reviewCommands.test.ts b/packages/hunk/src/app/session/reviewCommands.test.ts similarity index 99% rename from src/app/session/reviewCommands.test.ts rename to packages/hunk/src/app/session/reviewCommands.test.ts index e76d5afa9..4ec409263 100644 --- a/src/app/session/reviewCommands.test.ts +++ b/packages/hunk/src/app/session/reviewCommands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { ReviewProducer } from "../review/producer"; import { reviewGapId } from "../../core/review/expansion"; import { reviewResourceId } from "../../core/review/resources"; diff --git a/src/app/session/reviewCommands.ts b/packages/hunk/src/app/session/reviewCommands.ts similarity index 100% rename from src/app/session/reviewCommands.ts rename to packages/hunk/src/app/session/reviewCommands.ts diff --git a/src/app/sessionBootstrap.test.ts b/packages/hunk/src/app/sessionBootstrap.test.ts similarity index 100% rename from src/app/sessionBootstrap.test.ts rename to packages/hunk/src/app/sessionBootstrap.test.ts diff --git a/src/app/sessionBootstrap.ts b/packages/hunk/src/app/sessionBootstrap.ts similarity index 100% rename from src/app/sessionBootstrap.ts rename to packages/hunk/src/app/sessionBootstrap.ts diff --git a/src/app/sessionSelector.test.ts b/packages/hunk/src/app/sessionSelector.test.ts similarity index 100% rename from src/app/sessionSelector.test.ts rename to packages/hunk/src/app/sessionSelector.test.ts diff --git a/src/app/sessionSelector.ts b/packages/hunk/src/app/sessionSelector.ts similarity index 100% rename from src/app/sessionSelector.ts rename to packages/hunk/src/app/sessionSelector.ts diff --git a/src/app/startup.test.ts b/packages/hunk/src/app/startup.test.ts similarity index 100% rename from src/app/startup.test.ts rename to packages/hunk/src/app/startup.test.ts diff --git a/src/app/startup.ts b/packages/hunk/src/app/startup.ts similarity index 99% rename from src/app/startup.ts rename to packages/hunk/src/app/startup.ts index bea624496..7fb0a305b 100644 --- a/src/app/startup.ts +++ b/packages/hunk/src/app/startup.ts @@ -569,7 +569,7 @@ export async function prepareStartupPlan( // Bundled extensions load with the VCS adapters, well before this point, so a // failure there is reported here rather than lost. It should be unreachable — // these factories are Hunk's own — but the isolation contract is the contract. - const { loadBundledExtensions } = await import("../extensions/default/vcs"); + const { loadBundledExtensions } = await import("../extensions/bundledPackages"); const bundledNotices = startupExtensions.createExtensionLoadNotices( loadBundledExtensions().issues, ); diff --git a/src/app/startup.vcsExtensions.test.ts b/packages/hunk/src/app/startup.vcsExtensions.test.ts similarity index 100% rename from src/app/startup.vcsExtensions.test.ts rename to packages/hunk/src/app/startup.vcsExtensions.test.ts diff --git a/src/app/types.ts b/packages/hunk/src/app/types.ts similarity index 100% rename from src/app/types.ts rename to packages/hunk/src/app/types.ts diff --git a/packages/hunk/src/app/vcsCatalog.test.ts b/packages/hunk/src/app/vcsCatalog.test.ts new file mode 100644 index 000000000..d0cfefabe --- /dev/null +++ b/packages/hunk/src/app/vcsCatalog.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { createVcsCatalog, extendVcsCatalog, getDefaultVcsAdapter } from "../core/vcs"; +import type { VcsAdapter } from "../core/vcs/types"; +import { + loadBundledExtensionPackages, + reservedVcsIdsForBundledPackages, + type BundledExtensionPackage, +} from "../extensions/bundledPackages"; +import { getBundledVcsCatalog } from "./vcsCatalog"; + +describe("app VCS catalog composition", () => { + test("owns bundled ordering, fallback, and reserved ids at the app boundary", () => { + const catalog = getBundledVcsCatalog(); + + expect(catalog.defaultAdapterId).toBe("git"); + expect(catalog.adapters.map((adapter) => adapter.id)).toEqual(["jj", "sl", "git"]); + expect(catalog.reservedIds).toEqual(new Set(["jj", "sl", "git"])); + }); + + test("reserves descriptor VCS ids when a bundled factory fails", () => { + const failingGitPackage: BundledExtensionPackage = { + packageId: "@hunk/git-test", + packageName: "@hunk/git-test", + packageVersion: "0.0.0", + reservedVcsIds: ["git"], + entries: [ + { + id: "git", + factory: () => { + throw new Error("broken fixture"); + }, + }, + ], + }; + const loaded = loadBundledExtensionPackages([failingGitPackage]); + const catalog = createVcsCatalog( + loaded.registry.vcsAdapters.map((entry) => entry.adapter), + "git", + reservedVcsIdsForBundledPackages([failingGitPackage]), + ); + const userGit = { id: "git" } as VcsAdapter; + + expect(loaded.issues).toHaveLength(1); + expect(extendVcsCatalog(catalog, [userGit]).adapters).toEqual([]); + expect(() => getDefaultVcsAdapter(catalog)).toThrow("default git backend failed to load"); + }); +}); diff --git a/src/app/vcsCatalog.ts b/packages/hunk/src/app/vcsCatalog.ts similarity index 64% rename from src/app/vcsCatalog.ts rename to packages/hunk/src/app/vcsCatalog.ts index 05324bb20..a5f17ee81 100644 --- a/src/app/vcsCatalog.ts +++ b/packages/hunk/src/app/vcsCatalog.ts @@ -1,6 +1,6 @@ import { createVcsCatalog } from "../core/vcs"; import type { VcsCatalog } from "../core/vcs/types"; -import { getBundledVcsAdapters } from "../extensions/default/vcs"; +import { getBundledReservedVcsIds, getBundledVcsAdapters } from "../extensions/bundledPackages"; /** Product fallback provider selected when config names no backend. */ const DEFAULT_VCS_ID = "git"; @@ -9,6 +9,10 @@ let bundledCatalog: VcsCatalog | undefined; /** Compose Hunk's statically bundled adapters at the app boundary. */ export function getBundledVcsCatalog(): VcsCatalog { - bundledCatalog ??= createVcsCatalog(getBundledVcsAdapters(), DEFAULT_VCS_ID); + bundledCatalog ??= createVcsCatalog( + getBundledVcsAdapters(), + DEFAULT_VCS_ID, + getBundledReservedVcsIds(), + ); return bundledCatalog; } diff --git a/src/core/bootstrap.ts b/packages/hunk/src/core/bootstrap.ts similarity index 100% rename from src/core/bootstrap.ts rename to packages/hunk/src/core/bootstrap.ts diff --git a/src/core/changeset/binary.test.ts b/packages/hunk/src/core/changeset/binary.test.ts similarity index 100% rename from src/core/changeset/binary.test.ts rename to packages/hunk/src/core/changeset/binary.test.ts diff --git a/src/core/changeset/binary.ts b/packages/hunk/src/core/changeset/binary.ts similarity index 100% rename from src/core/changeset/binary.ts rename to packages/hunk/src/core/changeset/binary.ts diff --git a/src/core/changeset/diffFile.test.ts b/packages/hunk/src/core/changeset/diffFile.test.ts similarity index 100% rename from src/core/changeset/diffFile.test.ts rename to packages/hunk/src/core/changeset/diffFile.test.ts diff --git a/src/core/changeset/diffFile.ts b/packages/hunk/src/core/changeset/diffFile.ts similarity index 100% rename from src/core/changeset/diffFile.ts rename to packages/hunk/src/core/changeset/diffFile.ts diff --git a/src/core/changeset/diffPaths.ts b/packages/hunk/src/core/changeset/diffPaths.ts similarity index 100% rename from src/core/changeset/diffPaths.ts rename to packages/hunk/src/core/changeset/diffPaths.ts diff --git a/src/core/changeset/fileLanguage.test.ts b/packages/hunk/src/core/changeset/fileLanguage.test.ts similarity index 100% rename from src/core/changeset/fileLanguage.test.ts rename to packages/hunk/src/core/changeset/fileLanguage.test.ts diff --git a/src/core/changeset/fileLanguage.ts b/packages/hunk/src/core/changeset/fileLanguage.ts similarity index 100% rename from src/core/changeset/fileLanguage.ts rename to packages/hunk/src/core/changeset/fileLanguage.ts diff --git a/src/core/changeset/fileLanguageLookup.ts b/packages/hunk/src/core/changeset/fileLanguageLookup.ts similarity index 100% rename from src/core/changeset/fileLanguageLookup.ts rename to packages/hunk/src/core/changeset/fileLanguageLookup.ts diff --git a/src/core/changeset/fileSource.test.ts b/packages/hunk/src/core/changeset/fileSource.test.ts similarity index 100% rename from src/core/changeset/fileSource.test.ts rename to packages/hunk/src/core/changeset/fileSource.test.ts diff --git a/src/core/changeset/fileSource.ts b/packages/hunk/src/core/changeset/fileSource.ts similarity index 96% rename from src/core/changeset/fileSource.ts rename to packages/hunk/src/core/changeset/fileSource.ts index b654fdfd8..8434f4dd5 100644 --- a/src/core/changeset/fileSource.ts +++ b/packages/hunk/src/core/changeset/fileSource.ts @@ -1,6 +1,6 @@ -import { DEFAULT_SOURCE_TEXT_MAX_BYTES, readFileTextWithLimit } from "../../lib/sourceText"; +import { DEFAULT_SOURCE_TEXT_MAX_BYTES, readFileTextWithLimit } from "@hunk/vcs/source-text"; -export { DEFAULT_SOURCE_TEXT_MAX_BYTES } from "../../lib/sourceText"; +export { DEFAULT_SOURCE_TEXT_MAX_BYTES } from "@hunk/vcs/source-text"; /** * Generic full-file source fetcher primitives used by input loaders and VCS adapters. diff --git a/src/core/changeset/fromPatch.ts b/packages/hunk/src/core/changeset/fromPatch.ts similarity index 100% rename from src/core/changeset/fromPatch.ts rename to packages/hunk/src/core/changeset/fromPatch.ts diff --git a/src/core/changeset/hunkHeader.test.ts b/packages/hunk/src/core/changeset/hunkHeader.test.ts similarity index 100% rename from src/core/changeset/hunkHeader.test.ts rename to packages/hunk/src/core/changeset/hunkHeader.test.ts diff --git a/src/core/changeset/hunkHeader.ts b/packages/hunk/src/core/changeset/hunkHeader.ts similarity index 100% rename from src/core/changeset/hunkHeader.ts rename to packages/hunk/src/core/changeset/hunkHeader.ts diff --git a/src/core/changeset/hunkSummary.test.ts b/packages/hunk/src/core/changeset/hunkSummary.test.ts similarity index 95% rename from src/core/changeset/hunkSummary.test.ts rename to packages/hunk/src/core/changeset/hunkSummary.test.ts index 322985aea..7240d3743 100644 --- a/src/core/changeset/hunkSummary.test.ts +++ b/packages/hunk/src/core/changeset/hunkSummary.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { Hunk } from "@pierre/diffs"; -import { createJsxFileViewLayout } from "../../../examples/extensions/jsx-file-view"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createJsxFileViewLayout } from "../../../../../examples/extensions/jsx-file-view"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { createFileViewInput } from "../../ui/fileViews/host"; import { validateFileViewLayout } from "../../ui/fileViews/layout"; import { formatHunkHeader } from "./hunkHeader"; diff --git a/src/core/changeset/hunkSummary.ts b/packages/hunk/src/core/changeset/hunkSummary.ts similarity index 100% rename from src/core/changeset/hunkSummary.ts rename to packages/hunk/src/core/changeset/hunkSummary.ts diff --git a/src/core/changeset/loaders.ordering.test.ts b/packages/hunk/src/core/changeset/loaders.ordering.test.ts similarity index 96% rename from src/core/changeset/loaders.ordering.test.ts rename to packages/hunk/src/core/changeset/loaders.ordering.test.ts index 3143077b4..9651255d4 100644 --- a/src/core/changeset/loaders.ordering.test.ts +++ b/packages/hunk/src/core/changeset/loaders.ordering.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { SidecarContext } from "./model"; import { orderDiffFiles } from "./loaders"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; function sidecar(...paths: string[]): SidecarContext { return { diff --git a/src/core/changeset/loaders.test.ts b/packages/hunk/src/core/changeset/loaders.test.ts similarity index 99% rename from src/core/changeset/loaders.test.ts rename to packages/hunk/src/core/changeset/loaders.test.ts index 9950f19da..12fe64153 100644 --- a/src/core/changeset/loaders.test.ts +++ b/packages/hunk/src/core/changeset/loaders.test.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import { replaceExtensionFileLanguages } from "./fileLanguage"; import { SourceTextTooLargeError } from "./fileSource"; import { getBundledVcsCatalog } from "../../app/vcsCatalog"; -import { createGitVcsAdapter } from "../../extensions/default/vcs/git"; +import { createGitVcsAdapter } from "@hunk/git"; import { toInternalVcsAdapter } from "../../extensions/runExtension"; import { createVcsCatalog } from "../vcs"; import { loadAppBootstrap as loadCoreAppBootstrap, type LoadAppBootstrapOptions } from "./loaders"; diff --git a/src/core/changeset/loaders.ts b/packages/hunk/src/core/changeset/loaders.ts similarity index 100% rename from src/core/changeset/loaders.ts rename to packages/hunk/src/core/changeset/loaders.ts diff --git a/src/core/changeset/model.ts b/packages/hunk/src/core/changeset/model.ts similarity index 100% rename from src/core/changeset/model.ts rename to packages/hunk/src/core/changeset/model.ts diff --git a/src/core/changeset/sidecar.test.ts b/packages/hunk/src/core/changeset/sidecar.test.ts similarity index 100% rename from src/core/changeset/sidecar.test.ts rename to packages/hunk/src/core/changeset/sidecar.test.ts diff --git a/src/core/changeset/sidecar.ts b/packages/hunk/src/core/changeset/sidecar.ts similarity index 100% rename from src/core/changeset/sidecar.ts rename to packages/hunk/src/core/changeset/sidecar.ts diff --git a/src/core/install/installSource.test.ts b/packages/hunk/src/core/install/installSource.test.ts similarity index 100% rename from src/core/install/installSource.test.ts rename to packages/hunk/src/core/install/installSource.test.ts diff --git a/src/core/install/installSource.ts b/packages/hunk/src/core/install/installSource.ts similarity index 100% rename from src/core/install/installSource.ts rename to packages/hunk/src/core/install/installSource.ts diff --git a/src/core/install/latestRelease.test.ts b/packages/hunk/src/core/install/latestRelease.test.ts similarity index 100% rename from src/core/install/latestRelease.test.ts rename to packages/hunk/src/core/install/latestRelease.test.ts diff --git a/src/core/install/latestRelease.ts b/packages/hunk/src/core/install/latestRelease.ts similarity index 100% rename from src/core/install/latestRelease.ts rename to packages/hunk/src/core/install/latestRelease.ts diff --git a/src/core/install/selfUpdate.test.ts b/packages/hunk/src/core/install/selfUpdate.test.ts similarity index 100% rename from src/core/install/selfUpdate.test.ts rename to packages/hunk/src/core/install/selfUpdate.test.ts diff --git a/src/core/install/selfUpdate.ts b/packages/hunk/src/core/install/selfUpdate.ts similarity index 100% rename from src/core/install/selfUpdate.ts rename to packages/hunk/src/core/install/selfUpdate.ts diff --git a/src/core/liveComments.test.ts b/packages/hunk/src/core/liveComments.test.ts similarity index 98% rename from src/core/liveComments.test.ts rename to packages/hunk/src/core/liveComments.test.ts index c1a3a789b..3e1b1a129 100644 --- a/src/core/liveComments.test.ts +++ b/packages/hunk/src/core/liveComments.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../test/helpers/diff-helpers"; import { buildLiveComment, findDiffFileByPath, diff --git a/src/core/liveComments.ts b/packages/hunk/src/core/liveComments.ts similarity index 100% rename from src/core/liveComments.ts rename to packages/hunk/src/core/liveComments.ts diff --git a/src/core/patch/chunks.test.ts b/packages/hunk/src/core/patch/chunks.test.ts similarity index 100% rename from src/core/patch/chunks.test.ts rename to packages/hunk/src/core/patch/chunks.test.ts diff --git a/src/core/patch/chunks.ts b/packages/hunk/src/core/patch/chunks.ts similarity index 100% rename from src/core/patch/chunks.ts rename to packages/hunk/src/core/patch/chunks.ts diff --git a/src/core/patch/gitFormat.test.ts b/packages/hunk/src/core/patch/gitFormat.test.ts similarity index 100% rename from src/core/patch/gitFormat.test.ts rename to packages/hunk/src/core/patch/gitFormat.test.ts diff --git a/src/core/patch/gitFormat.ts b/packages/hunk/src/core/patch/gitFormat.ts similarity index 100% rename from src/core/patch/gitFormat.ts rename to packages/hunk/src/core/patch/gitFormat.ts diff --git a/src/core/patch/gitLog.test.ts b/packages/hunk/src/core/patch/gitLog.test.ts similarity index 100% rename from src/core/patch/gitLog.test.ts rename to packages/hunk/src/core/patch/gitLog.test.ts diff --git a/src/core/patch/gitLog.ts b/packages/hunk/src/core/patch/gitLog.ts similarity index 100% rename from src/core/patch/gitLog.ts rename to packages/hunk/src/core/patch/gitLog.ts diff --git a/src/core/patch/sanitize.test.ts b/packages/hunk/src/core/patch/sanitize.test.ts similarity index 100% rename from src/core/patch/sanitize.test.ts rename to packages/hunk/src/core/patch/sanitize.test.ts diff --git a/src/core/patch/sanitize.ts b/packages/hunk/src/core/patch/sanitize.ts similarity index 100% rename from src/core/patch/sanitize.ts rename to packages/hunk/src/core/patch/sanitize.ts diff --git a/src/core/patch/singleFile.ts b/packages/hunk/src/core/patch/singleFile.ts similarity index 100% rename from src/core/patch/singleFile.ts rename to packages/hunk/src/core/patch/singleFile.ts diff --git a/src/core/process/appStateFile.test.ts b/packages/hunk/src/core/process/appStateFile.test.ts similarity index 100% rename from src/core/process/appStateFile.test.ts rename to packages/hunk/src/core/process/appStateFile.test.ts diff --git a/src/core/process/appStateFile.ts b/packages/hunk/src/core/process/appStateFile.ts similarity index 100% rename from src/core/process/appStateFile.ts rename to packages/hunk/src/core/process/appStateFile.ts diff --git a/src/core/process/jobControl.test.ts b/packages/hunk/src/core/process/jobControl.test.ts similarity index 100% rename from src/core/process/jobControl.test.ts rename to packages/hunk/src/core/process/jobControl.test.ts diff --git a/src/core/process/jobControl.ts b/packages/hunk/src/core/process/jobControl.ts similarity index 100% rename from src/core/process/jobControl.ts rename to packages/hunk/src/core/process/jobControl.ts diff --git a/src/core/process/pager.test.ts b/packages/hunk/src/core/process/pager.test.ts similarity index 100% rename from src/core/process/pager.test.ts rename to packages/hunk/src/core/process/pager.test.ts diff --git a/src/core/process/pager.ts b/packages/hunk/src/core/process/pager.ts similarity index 100% rename from src/core/process/pager.ts rename to packages/hunk/src/core/process/pager.ts diff --git a/src/core/process/projectRoot.test.ts b/packages/hunk/src/core/process/projectRoot.test.ts similarity index 100% rename from src/core/process/projectRoot.test.ts rename to packages/hunk/src/core/process/projectRoot.test.ts diff --git a/src/core/process/projectRoot.ts b/packages/hunk/src/core/process/projectRoot.ts similarity index 100% rename from src/core/process/projectRoot.ts rename to packages/hunk/src/core/process/projectRoot.ts diff --git a/src/core/process/shutdown.test.ts b/packages/hunk/src/core/process/shutdown.test.ts similarity index 100% rename from src/core/process/shutdown.test.ts rename to packages/hunk/src/core/process/shutdown.test.ts diff --git a/src/core/process/shutdown.ts b/packages/hunk/src/core/process/shutdown.ts similarity index 100% rename from src/core/process/shutdown.ts rename to packages/hunk/src/core/process/shutdown.ts diff --git a/src/core/process/startupNotice.ts b/packages/hunk/src/core/process/startupNotice.ts similarity index 100% rename from src/core/process/startupNotice.ts rename to packages/hunk/src/core/process/startupNotice.ts diff --git a/src/core/process/stdout.test.ts b/packages/hunk/src/core/process/stdout.test.ts similarity index 100% rename from src/core/process/stdout.test.ts rename to packages/hunk/src/core/process/stdout.test.ts diff --git a/src/core/process/stdout.ts b/packages/hunk/src/core/process/stdout.ts similarity index 100% rename from src/core/process/stdout.ts rename to packages/hunk/src/core/process/stdout.ts diff --git a/src/core/process/terminal.test.ts b/packages/hunk/src/core/process/terminal.test.ts similarity index 100% rename from src/core/process/terminal.test.ts rename to packages/hunk/src/core/process/terminal.test.ts diff --git a/src/core/process/terminal.ts b/packages/hunk/src/core/process/terminal.ts similarity index 100% rename from src/core/process/terminal.ts rename to packages/hunk/src/core/process/terminal.ts diff --git a/src/core/process/updateNotice.test.ts b/packages/hunk/src/core/process/updateNotice.test.ts similarity index 100% rename from src/core/process/updateNotice.test.ts rename to packages/hunk/src/core/process/updateNotice.test.ts diff --git a/src/core/process/updateNotice.ts b/packages/hunk/src/core/process/updateNotice.ts similarity index 100% rename from src/core/process/updateNotice.ts rename to packages/hunk/src/core/process/updateNotice.ts diff --git a/src/core/review/actions.ts b/packages/hunk/src/core/review/actions.ts similarity index 100% rename from src/core/review/actions.ts rename to packages/hunk/src/core/review/actions.ts diff --git a/src/core/review/anchors.test.ts b/packages/hunk/src/core/review/anchors.test.ts similarity index 100% rename from src/core/review/anchors.test.ts rename to packages/hunk/src/core/review/anchors.test.ts diff --git a/src/core/review/anchors.ts b/packages/hunk/src/core/review/anchors.ts similarity index 100% rename from src/core/review/anchors.ts rename to packages/hunk/src/core/review/anchors.ts diff --git a/src/core/review/annotations.ts b/packages/hunk/src/core/review/annotations.ts similarity index 100% rename from src/core/review/annotations.ts rename to packages/hunk/src/core/review/annotations.ts diff --git a/src/core/review/canonicalFile.test.ts b/packages/hunk/src/core/review/canonicalFile.test.ts similarity index 97% rename from src/core/review/canonicalFile.test.ts rename to packages/hunk/src/core/review/canonicalFile.test.ts index d526b21d0..d40530db6 100644 --- a/src/core/review/canonicalFile.test.ts +++ b/packages/hunk/src/core/review/canonicalFile.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestReviewFile } from "../../../test/helpers/review-store-helpers"; +import { createTestReviewFile } from "../../../../../test/helpers/review-store-helpers"; import { buildReviewContentManifestFile } from "./contentManifest"; import { assertCanonicalFileMatchesManifest, diff --git a/src/core/review/canonicalFile.ts b/packages/hunk/src/core/review/canonicalFile.ts similarity index 100% rename from src/core/review/canonicalFile.ts rename to packages/hunk/src/core/review/canonicalFile.ts diff --git a/src/core/review/contentManifest.test.ts b/packages/hunk/src/core/review/contentManifest.test.ts similarity index 96% rename from src/core/review/contentManifest.test.ts rename to packages/hunk/src/core/review/contentManifest.test.ts index e6433367e..3a9291052 100644 --- a/src/core/review/contentManifest.test.ts +++ b/packages/hunk/src/core/review/contentManifest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; import { buildReviewContentManifest } from "./contentManifest"; import { projectReviewDocument } from "./document"; diff --git a/src/core/review/contentManifest.ts b/packages/hunk/src/core/review/contentManifest.ts similarity index 100% rename from src/core/review/contentManifest.ts rename to packages/hunk/src/core/review/contentManifest.ts diff --git a/src/core/review/document.test.ts b/packages/hunk/src/core/review/document.test.ts similarity index 99% rename from src/core/review/document.test.ts rename to packages/hunk/src/core/review/document.test.ts index 003afd4d9..a78cab72b 100644 --- a/src/core/review/document.test.ts +++ b/packages/hunk/src/core/review/document.test.ts @@ -3,7 +3,7 @@ import { createTestDiffFile, createTestSourceFetcher, lines, -} from "../../../test/helpers/diff-helpers"; +} from "../../../../../test/helpers/diff-helpers"; import { projectReviewDocument, reviewEmptyDiffReason } from "./document"; import type { DiffFile } from "../changeset/model"; diff --git a/src/core/review/document.ts b/packages/hunk/src/core/review/document.ts similarity index 100% rename from src/core/review/document.ts rename to packages/hunk/src/core/review/document.ts diff --git a/src/core/review/expansion.test.ts b/packages/hunk/src/core/review/expansion.test.ts similarity index 99% rename from src/core/review/expansion.test.ts rename to packages/hunk/src/core/review/expansion.test.ts index 4f262bd9d..c238d8a9d 100644 --- a/src/core/review/expansion.test.ts +++ b/packages/hunk/src/core/review/expansion.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestReviewFile } from "../../../test/helpers/review-store-helpers"; +import { createTestReviewFile } from "../../../../../test/helpers/review-store-helpers"; import { parseReviewGapId, resolveReviewExpandedLine, diff --git a/src/core/review/expansion.ts b/packages/hunk/src/core/review/expansion.ts similarity index 100% rename from src/core/review/expansion.ts rename to packages/hunk/src/core/review/expansion.ts diff --git a/src/core/review/generationOrder.test.ts b/packages/hunk/src/core/review/generationOrder.test.ts similarity index 100% rename from src/core/review/generationOrder.test.ts rename to packages/hunk/src/core/review/generationOrder.test.ts diff --git a/src/core/review/generationOrder.ts b/packages/hunk/src/core/review/generationOrder.ts similarity index 100% rename from src/core/review/generationOrder.ts rename to packages/hunk/src/core/review/generationOrder.ts diff --git a/src/core/review/geometry.test.ts b/packages/hunk/src/core/review/geometry.test.ts similarity index 100% rename from src/core/review/geometry.test.ts rename to packages/hunk/src/core/review/geometry.test.ts diff --git a/src/core/review/geometry.ts b/packages/hunk/src/core/review/geometry.ts similarity index 100% rename from src/core/review/geometry.ts rename to packages/hunk/src/core/review/geometry.ts diff --git a/src/core/review/identity.test.ts b/packages/hunk/src/core/review/identity.test.ts similarity index 100% rename from src/core/review/identity.test.ts rename to packages/hunk/src/core/review/identity.test.ts diff --git a/src/core/review/identity.ts b/packages/hunk/src/core/review/identity.ts similarity index 100% rename from src/core/review/identity.ts rename to packages/hunk/src/core/review/identity.ts diff --git a/src/core/review/intents.test.ts b/packages/hunk/src/core/review/intents.test.ts similarity index 99% rename from src/core/review/intents.test.ts rename to packages/hunk/src/core/review/intents.test.ts index 05737999c..3ff752b75 100644 --- a/src/core/review/intents.test.ts +++ b/packages/hunk/src/core/review/intents.test.ts @@ -3,7 +3,7 @@ import { createTestReviewDocument, createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { applyReviewIntent, isBlankReviewNoteBody, diff --git a/src/core/review/intents.ts b/packages/hunk/src/core/review/intents.ts similarity index 100% rename from src/core/review/intents.ts rename to packages/hunk/src/core/review/intents.ts diff --git a/src/core/review/navigation.test.ts b/packages/hunk/src/core/review/navigation.test.ts similarity index 100% rename from src/core/review/navigation.test.ts rename to packages/hunk/src/core/review/navigation.test.ts diff --git a/src/core/review/navigation.ts b/packages/hunk/src/core/review/navigation.ts similarity index 100% rename from src/core/review/navigation.ts rename to packages/hunk/src/core/review/navigation.ts diff --git a/src/core/review/noteSize.test.ts b/packages/hunk/src/core/review/noteSize.test.ts similarity index 100% rename from src/core/review/noteSize.test.ts rename to packages/hunk/src/core/review/noteSize.test.ts diff --git a/src/core/review/noteSize.ts b/packages/hunk/src/core/review/noteSize.ts similarity index 100% rename from src/core/review/noteSize.ts rename to packages/hunk/src/core/review/noteSize.ts diff --git a/src/core/review/reducer.test.ts b/packages/hunk/src/core/review/reducer.test.ts similarity index 99% rename from src/core/review/reducer.test.ts rename to packages/hunk/src/core/review/reducer.test.ts index 37439f151..15f2b9ce4 100644 --- a/src/core/review/reducer.test.ts +++ b/packages/hunk/src/core/review/reducer.test.ts @@ -3,7 +3,7 @@ import { createTestReviewDocument, createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { reduceReviewState } from "./reducer"; import type { ReviewState } from "./state"; diff --git a/src/core/review/reducer.ts b/packages/hunk/src/core/review/reducer.ts similarity index 100% rename from src/core/review/reducer.ts rename to packages/hunk/src/core/review/reducer.ts diff --git a/src/core/review/resourceAssembly.test.ts b/packages/hunk/src/core/review/resourceAssembly.test.ts similarity index 100% rename from src/core/review/resourceAssembly.test.ts rename to packages/hunk/src/core/review/resourceAssembly.test.ts diff --git a/src/core/review/resourceAssembly.ts b/packages/hunk/src/core/review/resourceAssembly.ts similarity index 100% rename from src/core/review/resourceAssembly.ts rename to packages/hunk/src/core/review/resourceAssembly.ts diff --git a/src/core/review/resources.test.ts b/packages/hunk/src/core/review/resources.test.ts similarity index 100% rename from src/core/review/resources.test.ts rename to packages/hunk/src/core/review/resources.test.ts diff --git a/src/core/review/resources.ts b/packages/hunk/src/core/review/resources.ts similarity index 100% rename from src/core/review/resources.ts rename to packages/hunk/src/core/review/resources.ts diff --git a/src/core/review/selectors.test.ts b/packages/hunk/src/core/review/selectors.test.ts similarity index 98% rename from src/core/review/selectors.test.ts rename to packages/hunk/src/core/review/selectors.test.ts index dd6abd164..cda5d45ef 100644 --- a/src/core/review/selectors.test.ts +++ b/packages/hunk/src/core/review/selectors.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; -import { createTestReviewFile } from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; +import { createTestReviewFile } from "../../../../../test/helpers/review-store-helpers"; import { reduceReviewState } from "./reducer"; import { isReviewGapExpanded, diff --git a/src/core/review/selectors.ts b/packages/hunk/src/core/review/selectors.ts similarity index 100% rename from src/core/review/selectors.ts rename to packages/hunk/src/core/review/selectors.ts diff --git a/src/core/review/state.test.ts b/packages/hunk/src/core/review/state.test.ts similarity index 96% rename from src/core/review/state.test.ts rename to packages/hunk/src/core/review/state.test.ts index c09f7247f..ed7f05112 100644 --- a/src/core/review/state.test.ts +++ b/packages/hunk/src/core/review/state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestReviewDocument } from "../../../test/helpers/review-store-helpers"; +import { createTestReviewDocument } from "../../../../../test/helpers/review-store-helpers"; import { createInitialReviewState, isRenderableStoredReviewNote, diff --git a/src/core/review/state.ts b/packages/hunk/src/core/review/state.ts similarity index 100% rename from src/core/review/state.ts rename to packages/hunk/src/core/review/state.ts diff --git a/src/core/review/stml.test.ts b/packages/hunk/src/core/review/stml.test.ts similarity index 100% rename from src/core/review/stml.test.ts rename to packages/hunk/src/core/review/stml.test.ts diff --git a/src/core/review/stml.ts b/packages/hunk/src/core/review/stml.ts similarity index 100% rename from src/core/review/stml.ts rename to packages/hunk/src/core/review/stml.ts diff --git a/src/core/review/store.test.ts b/packages/hunk/src/core/review/store.test.ts similarity index 97% rename from src/core/review/store.test.ts rename to packages/hunk/src/core/review/store.test.ts index 19a70242d..0e518c708 100644 --- a/src/core/review/store.test.ts +++ b/packages/hunk/src/core/review/store.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewDocument, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { createReviewStore } from "./store"; describe("createReviewStore", () => { diff --git a/src/core/review/store.ts b/packages/hunk/src/core/review/store.ts similarity index 100% rename from src/core/review/store.ts rename to packages/hunk/src/core/review/store.ts diff --git a/src/core/review/types.ts b/packages/hunk/src/core/review/types.ts similarity index 100% rename from src/core/review/types.ts rename to packages/hunk/src/core/review/types.ts diff --git a/src/core/review/validation.test.ts b/packages/hunk/src/core/review/validation.test.ts similarity index 100% rename from src/core/review/validation.test.ts rename to packages/hunk/src/core/review/validation.test.ts diff --git a/src/core/review/validation.ts b/packages/hunk/src/core/review/validation.ts similarity index 100% rename from src/core/review/validation.ts rename to packages/hunk/src/core/review/validation.ts diff --git a/src/core/reviewDescriptor.test.ts b/packages/hunk/src/core/reviewDescriptor.test.ts similarity index 100% rename from src/core/reviewDescriptor.test.ts rename to packages/hunk/src/core/reviewDescriptor.test.ts diff --git a/src/core/reviewDescriptor.ts b/packages/hunk/src/core/reviewDescriptor.ts similarity index 100% rename from src/core/reviewDescriptor.ts rename to packages/hunk/src/core/reviewDescriptor.ts diff --git a/src/core/reviewDigest.ts b/packages/hunk/src/core/reviewDigest.ts similarity index 100% rename from src/core/reviewDigest.ts rename to packages/hunk/src/core/reviewDigest.ts diff --git a/src/core/run/cliCommandNames.ts b/packages/hunk/src/core/run/cliCommandNames.ts similarity index 100% rename from src/core/run/cliCommandNames.ts rename to packages/hunk/src/core/run/cliCommandNames.ts diff --git a/src/core/run/commandCatalog.test.ts b/packages/hunk/src/core/run/commandCatalog.test.ts similarity index 99% rename from src/core/run/commandCatalog.test.ts rename to packages/hunk/src/core/run/commandCatalog.test.ts index 2b04000d4..a0be55309 100644 --- a/src/core/run/commandCatalog.test.ts +++ b/packages/hunk/src/core/run/commandCatalog.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { APP_COMMAND_CATALOG, appCommandCatalogEntry, diff --git a/src/core/run/commandCatalog.ts b/packages/hunk/src/core/run/commandCatalog.ts similarity index 100% rename from src/core/run/commandCatalog.ts rename to packages/hunk/src/core/run/commandCatalog.ts diff --git a/src/core/run/commandInputs.ts b/packages/hunk/src/core/run/commandInputs.ts similarity index 97% rename from src/core/run/commandInputs.ts rename to packages/hunk/src/core/run/commandInputs.ts index 8dca48432..020143bcd 100644 --- a/src/core/run/commandInputs.ts +++ b/packages/hunk/src/core/run/commandInputs.ts @@ -319,6 +319,13 @@ export interface ExtensionRemoveCommandInput { name: string; } +export interface ExtensionActivationCommandInput { + kind: "extension-manage"; + action: "enable" | "disable"; + /** Managed install name or stable package id. */ + name: string; +} + export interface ExtensionCliInvocationInput { kind: "extension-cli"; /** Unknown top-level token claimed by a loaded extension at startup. */ @@ -346,7 +353,8 @@ export type ExtensionManageCommandInput = | ExtensionInstallCommandInput | ExtensionListCommandInput | ExtensionUpdateCommandInput - | ExtensionRemoveCommandInput; + | ExtensionRemoveCommandInput + | ExtensionActivationCommandInput; export type ParsedCliInput = | CliInput diff --git a/src/core/run/config.test.ts b/packages/hunk/src/core/run/config.test.ts similarity index 99% rename from src/core/run/config.test.ts rename to packages/hunk/src/core/run/config.test.ts index 9b5f312e7..d5766baec 100644 --- a/src/core/run/config.test.ts +++ b/packages/hunk/src/core/run/config.test.ts @@ -1275,7 +1275,7 @@ describe("extension configuration", () => { ).toBe(true); }); - test("lets repo config disable extensions and --no-extensions win over both layers", () => { + test("keeps user and CLI extension denials monotonic across repo config", () => { const home = createTempDir("hunk-config-home-"); const repo = createTempDir("hunk-config-repo-"); createRepo(repo); @@ -1305,6 +1305,15 @@ describe("extension configuration", () => { resolveConfiguredCliInput(createPatchPagerInput(), { cwd: repo, env: { HOME: home } }) .extensions.enabled, ).toBe(true); + + writeFileSync( + join(home, ".config", "hunk", "config.toml"), + ["[extensions]", "enabled = false"].join("\n"), + ); + expect( + resolveConfiguredCliInput(createPatchPagerInput(), { cwd: repo, env: { HOME: home } }) + .extensions.enabled, + ).toBe(false); expect( resolveConfiguredCliInput(createPatchPagerInput({ extensions: false }), { cwd: repo, diff --git a/src/core/run/config.ts b/packages/hunk/src/core/run/config.ts similarity index 99% rename from src/core/run/config.ts rename to packages/hunk/src/core/run/config.ts index 68ebc02b2..af36d9213 100644 --- a/src/core/run/config.ts +++ b/packages/hunk/src/core/run/config.ts @@ -933,14 +933,17 @@ function mergeExtensionConfigs( return merged; } -/** Merge user and repo extension layers while honoring the invocation hard-off switch. */ +/** Merge user and repo extension layers while keeping user and invocation denials monotonic. */ function resolveExtensionsConfig( userLayer: ExtensionsLayer, repoLayer: ExtensionsLayer, extensionsEnabled: boolean | undefined, ): ExtensionsConfig { return { - enabled: extensionsEnabled === false ? false : (repoLayer.enabled ?? userLayer.enabled ?? true), + enabled: + extensionsEnabled === false || userLayer.enabled === false + ? false + : (repoLayer.enabled ?? userLayer.enabled ?? true), paths: userLayer.paths, repoPaths: repoLayer.paths, extensionConfigs: mergeExtensionConfigs(userLayer.extensionConfigs, repoLayer.extensionConfigs), diff --git a/src/core/run/errors.test.ts b/packages/hunk/src/core/run/errors.test.ts similarity index 100% rename from src/core/run/errors.test.ts rename to packages/hunk/src/core/run/errors.test.ts diff --git a/src/core/run/errors.ts b/packages/hunk/src/core/run/errors.ts similarity index 100% rename from src/core/run/errors.ts rename to packages/hunk/src/core/run/errors.ts diff --git a/src/core/run/experimental.test.ts b/packages/hunk/src/core/run/experimental.test.ts similarity index 92% rename from src/core/run/experimental.test.ts rename to packages/hunk/src/core/run/experimental.test.ts index d16c51ac3..59a12b5e5 100644 --- a/src/core/run/experimental.test.ts +++ b/packages/hunk/src/core/run/experimental.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { createTestAgentFileContext, createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { + createTestAgentFileContext, + createTestDiffFile, +} from "../../../../../test/helpers/diff-helpers"; import { resolveExperimentalDiffFiles, resolveExperimentalFeatures } from "./experimental"; describe("experimental review features", () => { diff --git a/src/core/run/experimental.ts b/packages/hunk/src/core/run/experimental.ts similarity index 100% rename from src/core/run/experimental.ts rename to packages/hunk/src/core/run/experimental.ts diff --git a/src/core/run/inputReload.ts b/packages/hunk/src/core/run/inputReload.ts similarity index 100% rename from src/core/run/inputReload.ts rename to packages/hunk/src/core/run/inputReload.ts diff --git a/src/core/run/paths.test.ts b/packages/hunk/src/core/run/paths.test.ts similarity index 100% rename from src/core/run/paths.test.ts rename to packages/hunk/src/core/run/paths.test.ts diff --git a/src/core/run/paths.ts b/packages/hunk/src/core/run/paths.ts similarity index 100% rename from src/core/run/paths.ts rename to packages/hunk/src/core/run/paths.ts diff --git a/src/core/run/reviewGap.test.ts b/packages/hunk/src/core/run/reviewGap.test.ts similarity index 100% rename from src/core/run/reviewGap.test.ts rename to packages/hunk/src/core/run/reviewGap.test.ts diff --git a/src/core/run/reviewGap.ts b/packages/hunk/src/core/run/reviewGap.ts similarity index 100% rename from src/core/run/reviewGap.ts rename to packages/hunk/src/core/run/reviewGap.ts diff --git a/src/core/run/tabWidth.ts b/packages/hunk/src/core/run/tabWidth.ts similarity index 100% rename from src/core/run/tabWidth.ts rename to packages/hunk/src/core/run/tabWidth.ts diff --git a/src/core/run/version.ts b/packages/hunk/src/core/run/version.ts similarity index 100% rename from src/core/run/version.ts rename to packages/hunk/src/core/run/version.ts diff --git a/src/core/theme/catalog.test.ts b/packages/hunk/src/core/theme/catalog.test.ts similarity index 100% rename from src/core/theme/catalog.test.ts rename to packages/hunk/src/core/theme/catalog.test.ts diff --git a/src/core/theme/catalog.ts b/packages/hunk/src/core/theme/catalog.ts similarity index 100% rename from src/core/theme/catalog.ts rename to packages/hunk/src/core/theme/catalog.ts diff --git a/src/core/theme/customThemes.test.ts b/packages/hunk/src/core/theme/customThemes.test.ts similarity index 100% rename from src/core/theme/customThemes.test.ts rename to packages/hunk/src/core/theme/customThemes.test.ts diff --git a/src/core/theme/customThemes.ts b/packages/hunk/src/core/theme/customThemes.ts similarity index 100% rename from src/core/theme/customThemes.ts rename to packages/hunk/src/core/theme/customThemes.ts diff --git a/src/core/theme/detection.test.ts b/packages/hunk/src/core/theme/detection.test.ts similarity index 100% rename from src/core/theme/detection.test.ts rename to packages/hunk/src/core/theme/detection.test.ts diff --git a/src/core/theme/detection.ts b/packages/hunk/src/core/theme/detection.ts similarity index 100% rename from src/core/theme/detection.ts rename to packages/hunk/src/core/theme/detection.ts diff --git a/src/core/theme/legacySyntaxScopes.test.ts b/packages/hunk/src/core/theme/legacySyntaxScopes.test.ts similarity index 100% rename from src/core/theme/legacySyntaxScopes.test.ts rename to packages/hunk/src/core/theme/legacySyntaxScopes.test.ts diff --git a/src/core/theme/legacySyntaxScopes.ts b/packages/hunk/src/core/theme/legacySyntaxScopes.ts similarity index 100% rename from src/core/theme/legacySyntaxScopes.ts rename to packages/hunk/src/core/theme/legacySyntaxScopes.ts diff --git a/src/core/vcs/index.test.ts b/packages/hunk/src/core/vcs/index.test.ts similarity index 100% rename from src/core/vcs/index.test.ts rename to packages/hunk/src/core/vcs/index.test.ts diff --git a/src/core/vcs/index.ts b/packages/hunk/src/core/vcs/index.ts similarity index 100% rename from src/core/vcs/index.ts rename to packages/hunk/src/core/vcs/index.ts diff --git a/src/core/vcs/types.ts b/packages/hunk/src/core/vcs/types.ts similarity index 100% rename from src/core/vcs/types.ts rename to packages/hunk/src/core/vcs/types.ts diff --git a/src/core/vcs/untracked.test.ts b/packages/hunk/src/core/vcs/untracked.test.ts similarity index 100% rename from src/core/vcs/untracked.test.ts rename to packages/hunk/src/core/vcs/untracked.test.ts diff --git a/src/core/vcs/untracked.ts b/packages/hunk/src/core/vcs/untracked.ts similarity index 97% rename from src/core/vcs/untracked.ts rename to packages/hunk/src/core/vcs/untracked.ts index ea5187aa7..c3235c12a 100644 --- a/src/core/vcs/untracked.ts +++ b/packages/hunk/src/core/vcs/untracked.ts @@ -3,10 +3,10 @@ import { join } from "node:path"; import { createSkippedBinaryMetadata, isProbablyBinaryFile } from "../changeset/binary"; import { buildDiffFile, createSkippedLargeMetadata } from "../changeset/diffFile"; import { createFileSourceFetcher } from "../changeset/fileSource"; -import { inspectLargeUntrackedFile } from "../../lib/largeFile"; +import { inspectLargeUntrackedFile } from "@hunk/vcs/large-file"; import { escapeUntrackedPatchPath } from "../../lib/patchPath"; import { parseSingleFilePatch } from "../patch/singleFile"; -import type { LargeFileCheck } from "../../lib/largeFile"; +import type { LargeFileCheck } from "@hunk/vcs/large-file"; /** * Host-side synthesis of untracked files into reviewable diffs. diff --git a/src/core/watch/controller.test.ts b/packages/hunk/src/core/watch/controller.test.ts similarity index 100% rename from src/core/watch/controller.test.ts rename to packages/hunk/src/core/watch/controller.test.ts diff --git a/src/core/watch/controller.ts b/packages/hunk/src/core/watch/controller.ts similarity index 100% rename from src/core/watch/controller.ts rename to packages/hunk/src/core/watch/controller.ts diff --git a/src/core/watch/observer.fs.test.ts b/packages/hunk/src/core/watch/observer.fs.test.ts similarity index 100% rename from src/core/watch/observer.fs.test.ts rename to packages/hunk/src/core/watch/observer.fs.test.ts diff --git a/src/core/watch/observer.test.ts b/packages/hunk/src/core/watch/observer.test.ts similarity index 98% rename from src/core/watch/observer.test.ts rename to packages/hunk/src/core/watch/observer.test.ts index 8372b040f..f909062b4 100644 --- a/src/core/watch/observer.test.ts +++ b/packages/hunk/src/core/watch/observer.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; -import { createWatchTestClock } from "../../../test/helpers/watchTest"; +import { createWatchTestClock } from "../../../../../test/helpers/watchTest"; import { createWatchController, WATCH_EVENT_SOURCE_STARTUP_TIMEOUT_CODE } from "./controller"; import { createNativeTreeWatcher, diff --git a/src/core/watch/observer.ts b/packages/hunk/src/core/watch/observer.ts similarity index 100% rename from src/core/watch/observer.ts rename to packages/hunk/src/core/watch/observer.ts diff --git a/src/core/watch/plan.test.ts b/packages/hunk/src/core/watch/plan.test.ts similarity index 100% rename from src/core/watch/plan.test.ts rename to packages/hunk/src/core/watch/plan.test.ts diff --git a/src/core/watch/plan.ts b/packages/hunk/src/core/watch/plan.ts similarity index 98% rename from src/core/watch/plan.ts rename to packages/hunk/src/core/watch/plan.ts index 902dd869c..5a013588b 100644 --- a/src/core/watch/plan.ts +++ b/packages/hunk/src/core/watch/plan.ts @@ -6,7 +6,7 @@ import type { ExtensionVcsWatchTarget, ExtensionVcsWatchTargetSource, } from "../../extension-api/types"; -import { normalizePathForOS } from "../../lib/osPath"; +import { normalizePathForOS } from "@hunk/vcs/os-path"; import type { CliInput } from "../run/commandInputs"; import { createVcsWatchPlan, getConfiguredVcsAdapter, operationFromInput } from "../vcs"; import type { VcsCatalog } from "../vcs/types"; diff --git a/src/core/watch/runtime.test.ts b/packages/hunk/src/core/watch/runtime.test.ts similarity index 100% rename from src/core/watch/runtime.test.ts rename to packages/hunk/src/core/watch/runtime.test.ts diff --git a/src/core/watch/runtime.ts b/packages/hunk/src/core/watch/runtime.ts similarity index 100% rename from src/core/watch/runtime.ts rename to packages/hunk/src/core/watch/runtime.ts diff --git a/src/core/watch/signature.test.ts b/packages/hunk/src/core/watch/signature.test.ts similarity index 100% rename from src/core/watch/signature.test.ts rename to packages/hunk/src/core/watch/signature.test.ts diff --git a/src/core/watch/signature.ts b/packages/hunk/src/core/watch/signature.ts similarity index 100% rename from src/core/watch/signature.ts rename to packages/hunk/src/core/watch/signature.ts diff --git a/src/extension-api/index.ts b/packages/hunk/src/extension-api/index.ts similarity index 100% rename from src/extension-api/index.ts rename to packages/hunk/src/extension-api/index.ts diff --git a/src/extension-api/keys.test.ts b/packages/hunk/src/extension-api/keys.test.ts similarity index 100% rename from src/extension-api/keys.test.ts rename to packages/hunk/src/extension-api/keys.test.ts diff --git a/src/extension-api/keys.ts b/packages/hunk/src/extension-api/keys.ts similarity index 100% rename from src/extension-api/keys.ts rename to packages/hunk/src/extension-api/keys.ts diff --git a/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts similarity index 100% rename from src/extension-api/types.ts rename to packages/hunk/src/extension-api/types.ts diff --git a/src/extensions/apply.test.ts b/packages/hunk/src/extensions/apply.test.ts similarity index 99% rename from src/extensions/apply.test.ts rename to packages/hunk/src/extensions/apply.test.ts index bff56e08e..74178273a 100644 --- a/src/extensions/apply.test.ts +++ b/packages/hunk/src/extensions/apply.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { HUNK_CORE_VCS_DETECTION_PRIORITY, HUNK_DEFAULT_VCS_DETECTION_PRIORITY, diff --git a/src/extensions/apply.ts b/packages/hunk/src/extensions/apply.ts similarity index 100% rename from src/extensions/apply.ts rename to packages/hunk/src/extensions/apply.ts diff --git a/packages/hunk/src/extensions/bundledPackages.test.ts b/packages/hunk/src/extensions/bundledPackages.test.ts new file mode 100644 index 000000000..a7d494e5b --- /dev/null +++ b/packages/hunk/src/extensions/bundledPackages.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { + getBundledVcsAdapters, + loadBundledExtensionPackages, + loadBundledExtensions, + type BundledExtensionPackage, +} from "./bundledPackages"; +import type { ExtensionFactory } from "./types"; + +/** Build one test package around a deliberately untyped factory. */ +function testPackage(factory: ExtensionFactory): BundledExtensionPackage { + return { + packageId: "test-package", + packageName: "test-package", + packageVersion: "1.0.0", + reservedVcsIds: [], + entries: [ + { + id: "test-entry", + factory: factory as BundledExtensionPackage["entries"][number]["factory"], + }, + ], + }; +} + +describe("bundled extension package loading", () => { + test("rejects async factories before returning the registry", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const factory: ExtensionFactory = async (hunk) => { + await gate; + hunk.registerVcsAdapter({ + id: "late", + name: "Late", + detect: () => null, + }); + }; + + const loaded = loadBundledExtensionPackages([testPackage(factory)]); + expect(loaded.registry.extensions).toEqual([]); + expect(loaded.registry.vcsAdapters).toEqual([]); + expect(loaded.issues[0]?.message).toContain("must be synchronous"); + + release(); + await gate; + await Bun.sleep(0); + expect(loaded.registry.extensions).toEqual([]); + expect(loaded.registry.vcsAdapters).toEqual([]); + }); + + test("revokes late custom events from rejected async factories", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const factory: ExtensionFactory = async (hunk) => { + await gate; + hunk.events.emit("late-event", { unsafe: true }); + }; + + const loaded = loadBundledExtensionPackages([testPackage(factory)]); + release(); + await gate; + await Bun.sleep(0); + expect(loaded.registry.pendingCustomEvents).toEqual([]); + }); +}); + +describe("bundled extension tier", () => { + test("loads every shipped VCS backend through the public registration API", () => { + const { registry, issues } = loadBundledExtensions(); + + expect(issues).toEqual([]); + expect(registry.extensions.map((extension) => extension.id)).toEqual(["jj", "sl", "git"]); + expect(registry.extensions.every((extension) => extension.origin === "bundled")).toBe(true); + expect(registry.extensions.map((extension) => extension.package?.id)).toEqual([ + "@hunk/jj", + "@hunk/sapling", + "@hunk/git", + ]); + expect(registry.vcsAdapters.map((entry) => [entry.extensionId, entry.adapter.id])).toEqual([ + ["jj", "jj"], + ["sl", "sl"], + ["git", "git"], + ]); + }); + + test("normalizes bundled adapters through the same host conversion as user adapters", () => { + for (const adapter of getBundledVcsAdapters()) { + expect(adapter.operations).toBeDefined(); + expect(adapter.operations["working-tree-diff"]).toBeDefined(); + expect(adapter.operations["revision-show"]).toBeDefined(); + } + }); + + test("keeps stash review to Git and stable adapter identities across resolution", () => { + const first = loadBundledExtensions(); + const byId = new Map(getBundledVcsAdapters().map((adapter) => [adapter.id, adapter])); + + expect(byId.get("git")?.operations["stash-show"]).toBeDefined(); + expect(byId.get("jj")?.operations["stash-show"]).toBeUndefined(); + expect(byId.get("sl")?.operations["stash-show"]).toBeUndefined(); + expect(loadBundledExtensions()).toBe(first); + expect(getBundledVcsAdapters()[0]).toBe(first.registry.vcsAdapters[0]?.adapter); + expect(byId.get("jj")!.detectionPriority).toBeGreaterThan(byId.get("sl")!.detectionPriority!); + expect(byId.get("sl")!.detectionPriority).toBeGreaterThan( + byId.get("git")!.detectionPriority ?? 0, + ); + }); +}); diff --git a/packages/hunk/src/extensions/bundledPackages.ts b/packages/hunk/src/extensions/bundledPackages.ts new file mode 100644 index 000000000..08732c612 --- /dev/null +++ b/packages/hunk/src/extensions/bundledPackages.ts @@ -0,0 +1,94 @@ +import { bundledExtensionPackage as gitPackage } from "@hunk/git"; +import { bundledExtensionPackage as jjPackage } from "@hunk/jj"; +import { bundledExtensionPackage as saplingPackage } from "@hunk/sapling"; +import { runExtensionFactory } from "./runExtension"; +import { + createEmptyExtensionRegistry, + type ExtensionFactory, + type ExtensionLoadIssue, + type ExtensionMetadata, + type ExtensionRegistry, +} from "./types"; + +/** Describes one statically linked package and all extension entries it activates. */ +type BundledExtensionFactory = (hunk: Parameters[0]) => void; + +export interface BundledExtensionPackage { + packageId: string; + packageName: string; + packageVersion: string; + /** VCS namespaces this package owns even when activation fails. */ + reservedVcsIds: readonly string[]; + entries: readonly { id: string; factory: BundledExtensionFactory }[]; +} + +/** Packages activate in provider precedence order; Git remains the mandatory final fallback. */ +const BUNDLED_PACKAGES: readonly BundledExtensionPackage[] = [ + jjPackage, + saplingPackage, + gitPackage, +]; + +/** Everything the bundled package tier contributed, plus isolated factory failures. */ +export interface BundledExtensionLoad { + registry: ExtensionRegistry; + issues: readonly ExtensionLoadIssue[]; +} + +let bundledLoad: BundledExtensionLoad | undefined; + +/** Build stable metadata for one package entry without exposing a filesystem path. */ +function bundledMetadata(extensionPackage: BundledExtensionPackage, id: string): ExtensionMetadata { + return { + id, + sourcePath: `package:${extensionPackage.packageName}`, + origin: "bundled", + package: { + id: extensionPackage.packageId, + name: extensionPackage.packageName, + version: extensionPackage.packageVersion, + }, + }; +} + +/** Activate a package set through the existing factory runner, isolating each entry failure. */ +export function loadBundledExtensionPackages( + packages: readonly BundledExtensionPackage[], +): BundledExtensionLoad { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + for (const extensionPackage of packages) { + for (const entry of extensionPackage.entries) { + runExtensionFactory({ + metadata: bundledMetadata(extensionPackage, entry.id), + registry, + issues, + factory: entry.factory, + synchronous: true, + }); + } + } + + return { registry, issues }; +} + +/** Activate all statically bundled packages once through the existing factory runner. */ +export function loadBundledExtensions(): BundledExtensionLoad { + bundledLoad ??= loadBundledExtensionPackages(BUNDLED_PACKAGES); + return bundledLoad; +} + +/** Return VCS ids reserved by descriptors independently of factory success. */ +export function reservedVcsIdsForBundledPackages(packages: readonly BundledExtensionPackage[]) { + return packages.flatMap((extensionPackage) => extensionPackage.reservedVcsIds); +} + +/** Return every statically bundled VCS id. */ +export function getBundledReservedVcsIds() { + return reservedVcsIdsForBundledPackages(BUNDLED_PACKAGES); +} + +/** Return bundled VCS adapters in package and entry activation order. */ +export function getBundledVcsAdapters() { + return loadBundledExtensions().registry.vcsAdapters.map((entry) => entry.adapter); +} diff --git a/src/extensions/cliCommandRuntime.test.ts b/packages/hunk/src/extensions/cliCommandRuntime.test.ts similarity index 100% rename from src/extensions/cliCommandRuntime.test.ts rename to packages/hunk/src/extensions/cliCommandRuntime.test.ts diff --git a/src/extensions/cliCommandRuntime.ts b/packages/hunk/src/extensions/cliCommandRuntime.ts similarity index 100% rename from src/extensions/cliCommandRuntime.ts rename to packages/hunk/src/extensions/cliCommandRuntime.ts diff --git a/src/extensions/cliCommands.test.ts b/packages/hunk/src/extensions/cliCommands.test.ts similarity index 100% rename from src/extensions/cliCommands.test.ts rename to packages/hunk/src/extensions/cliCommands.test.ts diff --git a/src/extensions/cliCommands.ts b/packages/hunk/src/extensions/cliCommands.ts similarity index 100% rename from src/extensions/cliCommands.ts rename to packages/hunk/src/extensions/cliCommands.ts diff --git a/src/extensions/default/ui/index.test.ts b/packages/hunk/src/extensions/default/ui/index.test.ts similarity index 100% rename from src/extensions/default/ui/index.test.ts rename to packages/hunk/src/extensions/default/ui/index.test.ts diff --git a/src/extensions/default/ui/index.ts b/packages/hunk/src/extensions/default/ui/index.ts similarity index 100% rename from src/extensions/default/ui/index.ts rename to packages/hunk/src/extensions/default/ui/index.ts diff --git a/src/extensions/default/ui/reviewInfo/index.test.tsx b/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx similarity index 97% rename from src/extensions/default/ui/reviewInfo/index.test.tsx rename to packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx index 7728fb99f..228793e8a 100644 --- a/src/extensions/default/ui/reviewInfo/index.test.tsx +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { capturedTestColorToHex } from "../../../../../test/helpers/test-color-helpers"; +import { capturedTestColorToHex } from "../../../../../../../test/helpers/test-color-helpers"; import type { ExtensionPaneProps } from "../../../../extension-api/types"; import { resolveTheme } from "../../../../ui/themes"; import { ReviewInfoPane } from "."; diff --git a/src/extensions/default/ui/reviewInfo/index.tsx b/packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx similarity index 100% rename from src/extensions/default/ui/reviewInfo/index.tsx rename to packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx diff --git a/src/extensions/default/ui/reviewInfo/presentation.test.ts b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts similarity index 100% rename from src/extensions/default/ui/reviewInfo/presentation.test.ts rename to packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts diff --git a/src/extensions/default/ui/reviewInfo/presentation.ts b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts similarity index 100% rename from src/extensions/default/ui/reviewInfo/presentation.ts rename to packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts diff --git a/src/extensions/default/ui/sidebar/FileSidebars.tsx b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx similarity index 100% rename from src/extensions/default/ui/sidebar/FileSidebars.tsx rename to packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx diff --git a/src/extensions/default/ui/sidebar/index.test.tsx b/packages/hunk/src/extensions/default/ui/sidebar/index.test.tsx similarity index 100% rename from src/extensions/default/ui/sidebar/index.test.tsx rename to packages/hunk/src/extensions/default/ui/sidebar/index.test.tsx diff --git a/src/extensions/default/ui/sidebar/index.tsx b/packages/hunk/src/extensions/default/ui/sidebar/index.tsx similarity index 100% rename from src/extensions/default/ui/sidebar/index.tsx rename to packages/hunk/src/extensions/default/ui/sidebar/index.tsx diff --git a/src/extensions/discovery.test.ts b/packages/hunk/src/extensions/discovery.test.ts similarity index 64% rename from src/extensions/discovery.test.ts rename to packages/hunk/src/extensions/discovery.test.ts index 479ef52c8..c9ecfbeea 100644 --- a/src/extensions/discovery.test.ts +++ b/packages/hunk/src/extensions/discovery.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { discoverExtensions } from "./discovery"; @@ -12,6 +12,11 @@ function createTempDir(prefix: string) { return dir; } +/** Compare the historical entry fields when package identity is asserted separately. */ +function withoutPackageIdentity(candidates: ReturnType) { + return candidates.map(({ package: _package, ...candidate }) => candidate); +} + /** Write one extension entry file, creating parent directories as needed. */ function writeExtensionFile(...segments: string[]) { const path = join(...segments); @@ -81,7 +86,7 @@ describe("extension discovery", () => { env: {}, }); - expect(candidates).toEqual([ + expect(withoutPackageIdentity(candidates)).toEqual([ { id: "alpha", path: standaloneTsx, origin: "global" }, { id: "beta", path: standaloneJsx, origin: "global" }, { id: "gamma", path: folderTsxIndex, origin: "global" }, @@ -101,7 +106,9 @@ describe("extension discovery", () => { env: {}, }); - expect(candidates).toEqual([{ id: "dual", path: typescriptIndex, origin: "flag" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "dual", path: typescriptIndex, origin: "flag" }, + ]); }); test("bootstraps repo-local extensions from .hunk without a bundled VCS marker", () => { @@ -116,7 +123,9 @@ describe("extension discovery", () => { env: {}, }); - expect(candidates).toEqual([{ id: "custom-vcs", path: repoPath, origin: "repo" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "custom-vcs", path: repoPath, origin: "repo" }, + ]); }); test("orders flag, user config, global, then repo-local sources", () => { @@ -136,7 +145,7 @@ describe("extension discovery", () => { env: {}, }); - expect(candidates).toEqual([ + expect(withoutPackageIdentity(candidates)).toEqual([ { id: "flagged", path: flagPath, origin: "flag" }, { id: "from-config", path: configPath, origin: "config" }, { id: "installed", path: globalPath, origin: "global" }, @@ -156,7 +165,9 @@ describe("extension discovery", () => { env: {}, }); - expect(candidates).toEqual([{ id: "policy", path: repoConfigPath, origin: "repo" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "policy", path: repoConfigPath, origin: "repo" }, + ]); }); test("expands explicit directory paths and keeps explicit file paths", () => { @@ -189,7 +200,9 @@ describe("extension discovery", () => { env: {}, }); - expect(candidates).toEqual([{ id: "my-ext", path: folderIndex, origin: "config" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "my-ext", path: folderIndex, origin: "config" }, + ]); }); test("prefers index.ts over index.js for an explicit folder-extension path", () => { @@ -205,7 +218,9 @@ describe("extension discovery", () => { env: {}, }); - expect(candidates).toEqual([{ id: "dual", path: typescriptIndex, origin: "flag" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "dual", path: typescriptIndex, origin: "flag" }, + ]); }); test("scans an explicit directory without an index as a container of extensions", () => { @@ -221,7 +236,7 @@ describe("extension discovery", () => { env: {}, }); - expect(candidates).toEqual([ + expect(withoutPackageIdentity(candidates)).toEqual([ { id: "alpha", path: first, origin: "flag" }, { id: "beta", path: second, origin: "flag" }, ]); @@ -239,7 +254,9 @@ describe("extension discovery", () => { env: {}, }); - expect(candidates).toEqual([{ id: "absent", path: missing, origin: "flag" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "absent", path: missing, origin: "flag" }, + ]); }); test("dedupes one path across groups and keeps the first origin", () => { @@ -254,7 +271,9 @@ describe("extension discovery", () => { env: {}, }); - expect(candidates).toEqual([{ id: "shared", path: repoPath, origin: "flag" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "shared", path: repoPath, origin: "flag" }, + ]); }); test("falls back to the XDG global extensions directory", () => { @@ -267,7 +286,9 @@ describe("extension discovery", () => { env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, }); - expect(candidates).toEqual([{ id: "themed", path: globalPath, origin: "global" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "themed", path: globalPath, origin: "global" }, + ]); }); }); @@ -289,7 +310,9 @@ describe("folder extension manifests", () => { // A single declared entry still answers to the folder's name, so // `[extension.manifest-ext]` keeps working. - expect(candidates).toEqual([{ id: "manifest-ext", path: entry, origin: "global" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "manifest-ext", path: entry, origin: "global" }, + ]); }); test("resolves a manifest for an explicit folder path", () => { @@ -306,7 +329,190 @@ describe("folder extension manifests", () => { env: {}, }); - expect(candidates).toEqual([{ id: "manifest-ext", path: entry, origin: "config" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "manifest-ext", path: entry, origin: "config" }, + ]); + }); + + test("attributes a direct declared entry to its owning package before activation", () => { + const root = createTempDir("hunk-ext-direct-owner-"); + const folder = join(root, "checkout-name"); + const entry = writeExtensionFile(folder, "src", "main.ts"); + writeExtensionManifest( + folder, + JSON.stringify({ hunk: { packageId: "owned-package", extensions: ["./src/main.ts"] } }), + ); + + expect( + discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [entry], + disabledPackageIds: new Set(["owned-package"]), + env: {}, + }), + ).toEqual([]); + + const enabled = discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + configPaths: [entry], + env: {}, + }); + expect(enabled[0]?.package).toEqual({ id: "owned-package", root: folder }); + }); + + test("does not borrow an ancestor identity past a nearer manifest", () => { + const root = createTempDir("hunk-ext-nearest-owner-"); + const outer = join(root, "outer"); + const inner = join(outer, "inner"); + const entry = writeExtensionFile(inner, "main.ts"); + writeExtensionFile(outer, "outer.ts"); + writeExtensionManifest( + outer, + JSON.stringify({ hunk: { packageId: "outer-package", extensions: ["./outer.ts"] } }), + ); + writeExtensionFile(inner, "declared.ts"); + writeExtensionManifest( + inner, + JSON.stringify({ hunk: { packageId: "inner-package", extensions: ["./declared.ts"] } }), + ); + + const candidates = discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [entry], + env: {}, + }); + expect(candidates[0]?.package).toEqual({ id: "main", root: inner }); + }); + + test.skipIf(process.platform === "win32")( + "canonicalizes a symlinked direct entry to its manifest package denial", + () => { + const root = createTempDir("hunk-ext-symlink-owner-"); + const folder = join(root, "owned"); + const entry = writeExtensionFile(folder, "main.ts"); + writeExtensionManifest( + folder, + JSON.stringify({ hunk: { packageId: "owned-package", extensions: ["./main.ts"] } }), + ); + const alias = join(root, "alias.ts"); + symlinkSync(entry, alias); + + const enabled = discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [alias], + env: {}, + }); + expect(enabled[0]?.path).toBe(realpathSync(entry)); + expect(enabled[0]?.package?.id).toBe("owned-package"); + expect( + discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [alias], + disabledPackageIds: new Set(["owned-package"]), + env: {}, + }), + ).toEqual([]); + }, + ); + + test.skipIf(process.platform === "win32")( + "keeps a manifest-declared external symlink under the same package denial", + () => { + const root = createTempDir("hunk-ext-external-symlink-owner-"); + const folder = join(root, "owned"); + const externalEntry = writeExtensionFile(root, "external", "main.ts"); + mkdirSync(folder, { recursive: true }); + const declaredSymlink = join(folder, "linked.ts"); + symlinkSync(externalEntry, declaredSymlink); + writeExtensionManifest( + folder, + JSON.stringify({ + hunk: { packageId: "owned-package", extensions: ["./linked.ts"] }, + }), + ); + + const direct = discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [declaredSymlink], + env: {}, + }); + const folderDiscovery = discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [folder], + env: {}, + }); + expect(direct).toHaveLength(1); + expect(direct[0]?.path).toBe(realpathSync(externalEntry)); + expect(direct[0]?.package).toEqual({ + id: "owned-package", + root: realpathSync(folder), + }); + expect(folderDiscovery).toEqual(direct); + + const deniedOptions = { + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + disabledPackageIds: new Set(["owned-package"]), + env: {}, + }; + expect(discoverExtensions({ ...deniedOptions, flagPaths: [declaredSymlink] })).toEqual([]); + expect(discoverExtensions({ ...deniedOptions, flagPaths: [folder] })).toEqual([]); + }, + ); + + test("canonicalizes uppercase and invalid fallback package identities", () => { + const root = createTempDir("hunk-ext-fallback-identity-"); + const upper = join(root, "Upper_Ext"); + const invalid = join(root, "invalid folder!"); + writeExtensionFile(upper, "index.ts"); + writeExtensionFile(invalid, "index.ts"); + + const candidates = discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [upper, invalid], + env: {}, + }); + expect(candidates.map((candidate) => candidate.package?.id).sort()).toEqual([ + "invalid-folder-", + "upper_ext", + ]); + }); + + test("rejects one package id claimed by distinct roots", () => { + const root = createTempDir("hunk-ext-package-collision-"); + const first = join(root, "first"); + const second = join(root, "second"); + writeExtensionFile(first, "index.ts"); + writeExtensionFile(second, "index.ts"); + writeExtensionManifest(first, JSON.stringify({ hunk: { packageId: "shared" } })); + writeExtensionManifest(second, JSON.stringify({ hunk: { packageId: "shared" } })); + + expect( + discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [first, second], + env: {}, + }), + ).toEqual([]); }); test("keeps manifest order and per-file ids when a manifest declares several entries", () => { @@ -326,10 +532,51 @@ describe("folder extension manifests", () => { env: {}, }); - expect(candidates).toEqual([ + expect(withoutPackageIdentity(candidates)).toEqual([ { id: "beta", path: beta, origin: "flag" }, { id: "alpha", path: alpha, origin: "flag" }, ]); + expect(candidates.map((candidate) => candidate.package)).toEqual([ + { id: "multi-ext", root: folder }, + { id: "multi-ext", root: folder }, + ]); + }); + + test("uses explicit manifest package identity and filters all entries before precedence", () => { + const root = createTempDir("hunk-ext-package-identity-"); + const folder = join(root, "checkout-name"); + writeExtensionFile(folder, "alpha.ts"); + writeExtensionFile(folder, "beta.ts"); + writeExtensionManifest( + folder, + JSON.stringify({ + name: "@acme/review-tools", + version: "2.3.0", + hunk: { packageId: "acme-review", extensions: ["./alpha.ts", "./beta.ts"] }, + }), + ); + + const enabled = discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [folder], + env: {}, + }); + expect(enabled.map((candidate) => candidate.package)).toEqual([ + { id: "acme-review", name: "@acme/review-tools", version: "2.3.0", root: folder }, + { id: "acme-review", name: "@acme/review-tools", version: "2.3.0", root: folder }, + ]); + expect( + discoverExtensions({ + cwd: root, + repoRoot: undefined, + globalExtensionsDir: undefined, + flagPaths: [folder], + disabledPackageIds: new Set(["acme-review"]), + env: {}, + }), + ).toEqual([]); }); test("disambiguates duplicate ids within a multi-entry manifest", () => { @@ -351,7 +598,7 @@ describe("folder extension manifests", () => { env: {}, }); - expect(candidates).toEqual([ + expect(withoutPackageIdentity(candidates)).toEqual([ { id: "alpha", path: typescriptEntry, origin: "flag" }, { id: "alpha-3", path: javascriptEntry, origin: "flag" }, { id: "alpha-2", path: reservedSuffixEntry, origin: "flag" }, @@ -372,7 +619,9 @@ describe("folder extension manifests", () => { env: {}, }); - expect(candidates).toEqual([{ id: "broken-manifest", path: index, origin: "flag" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "broken-manifest", path: index, origin: "flag" }, + ]); }); test("falls back to the index entry for a package.json without a hunk field", () => { @@ -389,7 +638,9 @@ describe("folder extension manifests", () => { env: {}, }); - expect(candidates).toEqual([{ id: "plain-package", path: index, origin: "flag" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "plain-package", path: index, origin: "flag" }, + ]); }); test("keeps a manifest entry pointing at a missing file so the host can report it", () => { @@ -405,7 +656,7 @@ describe("folder extension manifests", () => { env: {}, }); - expect(candidates).toEqual([ + expect(withoutPackageIdentity(candidates)).toEqual([ { id: "missing-entry", path: join(folder, "src", "main.ts"), origin: "flag" }, ]); }); @@ -424,7 +675,7 @@ describe("tilde paths", () => { env: {}, }); - expect(candidates).toEqual([ + expect(withoutPackageIdentity(candidates)).toEqual([ { id: "hunk-ext", path: join(homedir(), "dev", "hunk-ext", "index.ts"), origin: "config" }, ]); }); @@ -488,7 +739,7 @@ describe("manifest api version requirements", () => { env: {}, }); - expect(candidates).toEqual([ + expect(withoutPackageIdentity(candidates)).toEqual([ { id: "api-ext", path: entry, origin: "flag", requiresApiVersion: 9 }, ]); }); @@ -507,7 +758,7 @@ describe("manifest api version requirements", () => { env: {}, }); - expect(candidates).toEqual([ + expect(withoutPackageIdentity(candidates)).toEqual([ { id: "api-index-ext", path: index, origin: "flag", requiresApiVersion: 2 }, ]); }); @@ -529,7 +780,9 @@ describe("manifest api version requirements", () => { env: {}, }); - expect(candidates).toEqual([{ id: "bad-api-ext", path: entry, origin: "flag" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "bad-api-ext", path: entry, origin: "flag" }, + ]); }); }); @@ -548,6 +801,8 @@ describe("managed install root scanning", () => { globalExtensionsDir: globalDir, }); - expect(candidates).toEqual([{ id: "real-ext", path: real, origin: "global" }]); + expect(withoutPackageIdentity(candidates)).toEqual([ + { id: "real-ext", path: real, origin: "global" }, + ]); }); }); diff --git a/src/extensions/discovery.ts b/packages/hunk/src/extensions/discovery.ts similarity index 71% rename from src/extensions/discovery.ts rename to packages/hunk/src/extensions/discovery.ts index 7806ec6ab..0d67302be 100644 --- a/src/extensions/discovery.ts +++ b/packages/hunk/src/extensions/discovery.ts @@ -1,8 +1,12 @@ import fs from "node:fs"; import { homedir } from "node:os"; -import { basename, isAbsolute, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { INSTALLED_EXTENSIONS_DIR_NAME, resolveGlobalExtensionsDir } from "../core/run/paths"; import { findProjectRootCandidate } from "../core/process/projectRoot"; +import { + normalizeExtensionPackageId, + normalizeFallbackExtensionPackageId, +} from "./packageIdentity"; import { deriveExtensionId, type ExtensionCandidate, type ExtensionOrigin } from "./types"; /** Entry-file suffixes Hunk will import directly, in preference order. */ @@ -22,13 +26,33 @@ interface DiscoveredExtensionEntry { id: string; path: string; sortKey: string; + package: { id: string; root: string; name?: string; version?: string }; /** Minimum extension API version the folder's manifest declared, if any. */ requiresApiVersion?: number; } -/** Describe one standalone entry file, which sorts and is named by its own path. */ +/** Canonicalize an entry path while preserving a useful missing-path diagnostic. */ +function canonicalExtensionPath(path: string) { + try { + return fs.realpathSync.native(path); + } catch { + return resolve(path); + } +} + +/** Describe one standalone entry file as its own legacy package. */ function toStandaloneEntry(path: string): DiscoveredExtensionEntry { - return { id: deriveExtensionId(path), path, sortKey: path }; + const canonicalPath = canonicalExtensionPath(path); + const id = deriveExtensionId(canonicalPath); + return { + id, + path: canonicalPath, + sortKey: canonicalPath, + package: { + id: normalizeFallbackExtensionPackageId(id), + root: dirname(canonicalPath), + }, + }; } export interface DiscoverExtensionsOptions { @@ -44,9 +68,20 @@ export interface DiscoverExtensionsOptions { repoConfigPaths?: readonly string[]; /** Override the scanned global directory; discovery falls back to the XDG location. */ globalExtensionsDir?: string; + /** Package ids disabled by user-owned activation preferences. */ + disabledPackageIds?: ReadonlySet; } /** Return whether one path exists and is a directory. */ +/** Canonicalize an existing package root so symlink aliases keep one identity. */ +function canonicalPackageRoot(path: string) { + try { + return fs.realpathSync.native(path); + } catch { + return resolve(path); + } +} + function isDirectory(path: string) { try { return fs.statSync(path).isDirectory(); @@ -81,10 +116,16 @@ function findFolderExtensionIndex(dir: string) { /** What one folder extension's `package.json` manifest declares. */ interface ExtensionManifest { - /** Absolute entry paths from `hunk.extensions`, or nothing when undeclared. */ + /** Canonical absolute entry paths from `hunk.extensions`, or nothing when undeclared. */ entryPaths?: string[]; + /** Lexical absolute entry paths preserve ownership when a declaration is a symlink. */ + lexicalEntryPaths?: string[]; /** Minimum extension API version from `hunk.apiVersion`, or nothing when undeclared. */ requiresApiVersion?: number; + /** Stable package identity; `hunk.packageId` overrides the npm package name. */ + packageId?: string; + packageName?: string; + packageVersion?: string; } /** @@ -122,11 +163,12 @@ function readExtensionManifest(dir: string): ExtensionManifest | undefined { const declared = (section as Record).extensions; // Non-string items are skipped rather than fatal; one bad array item should // not cost the folder the entries it declared correctly. - const entryPaths = Array.isArray(declared) + const lexicalEntryPaths = Array.isArray(declared) ? declared .filter((entry): entry is string => typeof entry === "string") .map((entry) => resolve(dir, entry)) : undefined; + const entryPaths = lexicalEntryPaths?.map(canonicalExtensionPath); // A malformed apiVersion is ignored rather than fatal, matching the "no // manifest" posture for every other malformed field. @@ -137,8 +179,20 @@ function readExtensionManifest(dir: string): ExtensionManifest | undefined { declaredApiVersion > 0 ? declaredApiVersion : undefined; + const packageName = (manifest as Record).name; + const packageVersion = (manifest as Record).version; + const declaredPackageId = normalizeExtensionPackageId( + (section as Record).packageId, + ); - return { entryPaths, requiresApiVersion }; + return { + entryPaths, + lexicalEntryPaths, + requiresApiVersion, + ...(declaredPackageId ? { packageId: declaredPackageId } : {}), + ...(typeof packageName === "string" && packageName.length > 0 ? { packageName } : {}), + ...(typeof packageVersion === "string" && packageVersion.length > 0 ? { packageVersion } : {}), + }; } /** Assign deterministic, distinct ids to every entry in one manifest. */ @@ -182,6 +236,7 @@ function deriveManifestEntryIds(paths: readonly string[]) { * Returns an empty list when the folder is not an extension at all. */ function resolveFolderExtensionEntries(dir: string): DiscoveredExtensionEntry[] { + dir = canonicalPackageRoot(dir); const manifest = readExtensionManifest(dir); const manifestPaths = manifest?.entryPaths; /** Attach the manifest's api requirement so the host can gate before importing. */ @@ -190,8 +245,18 @@ function resolveFolderExtensionEntries(dir: string): DiscoveredExtensionEntry[] ? { ...entry, requiresApiVersion: manifest.requiresApiVersion } : entry; + const folderName = basename(dir); + const packageIdentity = { + id: + manifest?.packageId ?? + normalizeExtensionPackageId(manifest?.packageName) ?? + normalizeFallbackExtensionPackageId(manifest?.packageName ?? folderName), + root: dir, + ...(manifest?.packageName ? { name: manifest.packageName } : {}), + ...(manifest?.packageVersion ? { version: manifest.packageVersion } : {}), + }; + if (manifestPaths && manifestPaths.length > 0) { - const folderName = basename(dir); const manifestIds = deriveManifestEntryIds(manifestPaths); return manifestPaths.map((path, index) => withApiVersion({ @@ -201,6 +266,7 @@ function resolveFolderExtensionEntries(dir: string): DiscoveredExtensionEntry[] : (manifestIds[index] ?? deriveExtensionId(path)), path, sortKey: dir, + package: packageIdentity, }), ); } @@ -208,7 +274,15 @@ function resolveFolderExtensionEntries(dir: string): DiscoveredExtensionEntry[] // The apiVersion requirement still applies to the index fallback: a manifest // may state compatibility without redeclaring the entry file. const folderIndex = findFolderExtensionIndex(dir); - return folderIndex ? [withApiVersion(toStandaloneEntry(folderIndex))] : []; + return folderIndex + ? [ + withApiVersion({ + ...toStandaloneEntry(folderIndex), + package: packageIdentity, + sortKey: dir, + }), + ] + : []; } /** @@ -251,6 +325,35 @@ export function resolveExtensionContainerEntries(dir: string): DiscoveredExtensi return folderEntries.length > 0 ? folderEntries : scanExtensionsDir(dir); } +/** Describe the stable identities and ordered entries exposed by one package/container. */ +export function describeExtensionPackages(dir: string) { + const packages = new Map< + string, + { id: string; name?: string; version?: string; entries: string[]; root: string } + >(); + for (const entry of resolveExtensionContainerEntries(dir)) { + const root = canonicalPackageRoot(entry.package.root); + const current = packages.get(entry.package.id); + if (current) { + if (current.root !== root) { + throw new Error( + `Extension package id "${entry.package.id}" is declared by distinct package roots.`, + ); + } + current.entries.push(entry.id); + continue; + } + packages.set(entry.package.id, { + id: entry.package.id, + ...(entry.package.name ? { name: entry.package.name } : {}), + ...(entry.package.version ? { version: entry.package.version } : {}), + entries: [entry.id], + root, + }); + } + return [...packages.values()].map(({ root: _root, ...entry }) => entry); +} + /** * Report whether one directory deliberately publishes Hunk extension entries. * @@ -324,6 +427,35 @@ export function expandHomePath(path: string) { return path; } +/** Resolve a direct entry through the nearest manifest that declares its lexical or canonical path. */ +function resolveExplicitEntryOwner(lexicalPath: string, canonicalPath: string) { + const searches = [ + { path: lexicalPath, declaredPath: lexicalPath, field: "lexicalEntryPaths" as const }, + { path: canonicalPath, declaredPath: canonicalPath, field: "entryPaths" as const }, + ]; + + for (const search of searches) { + let ownerDir = dirname(search.path); + while (true) { + const manifest = readExtensionManifest(ownerDir); + if (manifest) { + const declaredIndex = manifest[search.field]?.indexOf(search.declaredPath) ?? -1; + if (declaredIndex >= 0) { + return resolveFolderExtensionEntries(ownerDir)[declaredIndex]; + } + // The nearest Hunk manifest owns the boundary. A file it does not + // declare cannot borrow an ancestor package identity. + break; + } + const parent = dirname(ownerDir); + if (parent === ownerDir) break; + ownerDir = parent; + } + } + + return undefined; +} + /** * Expand one explicit path into entry files. * @@ -337,12 +469,16 @@ export function expandHomePath(path: string) { */ function expandExplicitPath(path: string, cwd: string): DiscoveredExtensionEntry[] { const homeExpanded = expandHomePath(path); - const resolvedPath = isAbsolute(homeExpanded) - ? resolve(homeExpanded) - : resolve(cwd, homeExpanded); + const lexicalPath = isAbsolute(homeExpanded) ? resolve(homeExpanded) : resolve(cwd, homeExpanded); + const resolvedPath = canonicalExtensionPath(lexicalPath); if (!isDirectory(resolvedPath)) { - return [toStandaloneEntry(resolvedPath)]; + // Inspect lexical ancestry before the symlink target's ancestry: a manifest + // may deliberately declare an entry symlink whose target lives elsewhere. + // Both paths still resolve to canonical roots/targets for deduplication and + // package-id collision checks. + const owned = resolveExplicitEntryOwner(lexicalPath, resolvedPath); + return owned ? [owned] : [toStandaloneEntry(resolvedPath)]; } return resolveExtensionContainerEntries(resolvedPath); @@ -400,6 +536,17 @@ export function discoverExtensions(options: DiscoverExtensionsOptions = {}): Ext const candidates: ExtensionCandidate[] = []; const seenPaths = new Set(); + const packageRoots = new Map>(); + for (const group of groups) { + for (const entry of group.entries) { + const roots = packageRoots.get(entry.package.id) ?? new Set(); + roots.add(canonicalPackageRoot(entry.package.root)); + packageRoots.set(entry.package.id, roots); + } + } + const conflictingPackageIds = new Set( + [...packageRoots].filter(([, roots]) => roots.size > 1).map(([id]) => id), + ); for (const group of groups) { // Sorting by `sortKey` rather than by path keeps every entry of one folder @@ -408,7 +555,13 @@ export function discoverExtensions(options: DiscoverExtensionsOptions = {}): Ext const sorted = [...group.entries].sort((a, b) => a.sortKey.localeCompare(b.sortKey)); for (const entry of sorted) { - if (seenPaths.has(entry.path)) { + // Activation is resolved before path/id precedence and before trust/import, + // so disabled code cannot shadow an enabled package or prompt for trust. + if ( + conflictingPackageIds.has(entry.package.id) || + options.disabledPackageIds?.has(entry.package.id) || + seenPaths.has(entry.path) + ) { continue; } @@ -417,6 +570,7 @@ export function discoverExtensions(options: DiscoverExtensionsOptions = {}): Ext id: entry.id, path: entry.path, origin: group.origin, + package: entry.package, // Attached only when declared so candidate equality stays byte-stable // for the common manifest-less case. ...(entry.requiresApiVersion !== undefined diff --git a/src/extensions/events.test.ts b/packages/hunk/src/extensions/events.test.ts similarity index 99% rename from src/extensions/events.test.ts rename to packages/hunk/src/extensions/events.test.ts index f0ae2bad4..e2aed8649 100644 --- a/src/extensions/events.test.ts +++ b/packages/hunk/src/extensions/events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { bindExtensionEventBus, emitExtensionCustomEvent, diff --git a/src/extensions/events.ts b/packages/hunk/src/extensions/events.ts similarity index 100% rename from src/extensions/events.ts rename to packages/hunk/src/extensions/events.ts diff --git a/src/extensions/extensionIds.ts b/packages/hunk/src/extensions/extensionIds.ts similarity index 100% rename from src/extensions/extensionIds.ts rename to packages/hunk/src/extensions/extensionIds.ts diff --git a/src/extensions/host.test.ts b/packages/hunk/src/extensions/host.test.ts similarity index 95% rename from src/extensions/host.test.ts rename to packages/hunk/src/extensions/host.test.ts index 456bcf230..523572b7d 100644 --- a/src/extensions/host.test.ts +++ b/packages/hunk/src/extensions/host.test.ts @@ -150,7 +150,12 @@ export default function (hunk: HunkExtensionAPI) { expect(result.issues).toEqual([]); expect(result.loaded).toEqual([ - { id: "kitchen-sink", sourcePath: candidate.path, origin: "flag" }, + { + id: "kitchen-sink", + sourcePath: candidate.path, + origin: "flag", + package: { id: "kitchen-sink" }, + }, ]); expect(result.registry.themes).toEqual([ { @@ -228,7 +233,12 @@ export default function (hunk: { registerFileLanguage: (e: string, l: string) => // The id comes from the folder, and the sibling import resolved at load time. expect(result.issues).toEqual([]); expect(result.loaded).toEqual([ - { id: "folder-ext", sourcePath: candidate.path, origin: "config" }, + { + id: "folder-ext", + sourcePath: candidate.path, + origin: "config", + package: { id: "folder-ext" }, + }, ]); expect(result.registry.fileLanguages).toEqual([ { @@ -281,9 +291,23 @@ export default function (hunk: { registerFileLanguage: (e: string, l: string) => }); const result = await loadExtensions({ candidates, cwd: root }); - expect(candidates).toEqual([{ id: "dep-ext", path: entryPath, origin: "flag" }]); + expect(candidates).toEqual([ + { + id: "dep-ext", + path: entryPath, + origin: "flag", + package: { id: "dep-ext", name: "dep-ext", root: folder }, + }, + ]); expect(result.issues).toEqual([]); - expect(result.loaded).toEqual([{ id: "dep-ext", sourcePath: entryPath, origin: "flag" }]); + expect(result.loaded).toEqual([ + { + id: "dep-ext", + sourcePath: entryPath, + origin: "flag", + package: { id: "dep-ext", name: "dep-ext", root: folder }, + }, + ]); expect(result.registry.fileLanguages).toEqual([ { extensionId: "dep-ext", @@ -319,10 +343,22 @@ export default function (hunk: { registerSidebarView: (view: unknown) => void }) }); const result = await loadExtensions({ candidates, cwd: globalDir }); - expect(candidates).toEqual([{ id: "flat-sidebar", path: entryPath, origin: "global" }]); + expect(candidates).toEqual([ + { + id: "flat-sidebar", + path: entryPath, + origin: "global", + package: { id: "flat-sidebar", root: globalDir }, + }, + ]); expect(result.issues).toEqual([]); expect(result.loaded).toEqual([ - { id: "flat-sidebar", sourcePath: entryPath, origin: "global" }, + { + id: "flat-sidebar", + sourcePath: entryPath, + origin: "global", + package: { id: "flat-sidebar", root: globalDir }, + }, ]); expect( result.registry.panes.map((entry) => ({ @@ -505,7 +541,14 @@ export default function (hunk: { registerSidebarView: (view: unknown) => void }) // Sharing an id would mean sharing a config table, command ids, and view // keys, so discovery order decides and the loser is reported. - expect(result.loaded).toEqual([{ id: "notes", sourcePath: winner.path, origin: "config" }]); + expect(result.loaded).toEqual([ + { + id: "notes", + sourcePath: winner.path, + origin: "config", + package: { id: "notes" }, + }, + ]); expect(result.registry.logs).toEqual([{ extensionId: "notes", message: "first" }]); expect(result.issues).toHaveLength(1); expect(result.issues[0]?.origin).toBe("global"); diff --git a/src/extensions/host.ts b/packages/hunk/src/extensions/host.ts similarity index 99% rename from src/extensions/host.ts rename to packages/hunk/src/extensions/host.ts index b2197b5bf..e805d5df0 100644 --- a/src/extensions/host.ts +++ b/packages/hunk/src/extensions/host.ts @@ -249,6 +249,7 @@ export async function loadExtensions(options: LoadExtensionsOptions): Promise { + await Promise.all(roots.splice(0).map((root) => removeTestDirectory(root))); +}); + +describe("extension package activation preferences", () => { + test("preserves exact state and carries one denial through an unambiguous rename", () => { + const current = { a: false, b: true }; + expect(planExtensionPackageActivationMigration(["a", "b"], ["a", "b"], current)).toEqual({ + activations: current, + changed: false, + }); + expect(planExtensionPackageActivationMigration(["a"], ["renamed"], current)).toEqual({ + activations: { ...current, renamed: false }, + changed: true, + }); + }); + + test("allows topology changes when no previous package is explicitly denied", () => { + const cases: ExtensionPackageActivationMap[] = [{}, { a: true, b: true }]; + for (const current of cases) { + expect(planExtensionPackageActivationMigration(["a"], ["next-a", "next-b"], current)).toEqual( + { activations: current, changed: false }, + ); + expect(planExtensionPackageActivationMigration(["a", "b"], ["next"], current)).toEqual({ + activations: current, + changed: false, + }); + expect( + planExtensionPackageActivationMigration(["a", "b"], ["next-a", "next-b"], current), + ).toEqual({ activations: current, changed: false }); + expect(planExtensionPackageActivationMigration(["b"], ["b", "next"], current)).toEqual({ + activations: current, + changed: false, + }); + } + }); + + test("rejects additions and ambiguous topology while a previous package is denied", () => { + const current = { a: false, b: true }; + expect( + planExtensionPackageActivationMigration(["a"], ["next-a", "next-b"], current), + ).toBeUndefined(); + expect(planExtensionPackageActivationMigration(["a", "b"], ["next"], current)).toBeUndefined(); + expect( + planExtensionPackageActivationMigration(["a", "b"], ["next-a", "next-b"], current), + ).toBeUndefined(); + expect( + planExtensionPackageActivationMigration(["a", "b"], ["a", "b", "next"], current), + ).toBeUndefined(); + expect(planExtensionPackageActivationMigration(["a", "b"], ["a"], current)).toEqual({ + activations: current, + changed: false, + }); + }); + + test("stores activation separately and defaults unknown packages to enabled", () => { + const env = testEnv(); + expect(readExtensionPackageActivations(env)).toEqual({}); + + expect(setExtensionPackageActivation("@acme/review-tools", false, env)).toBe(true); + expect(readExtensionPackageActivations(env)).toEqual({ "@acme/review-tools": false }); + expect(readDisabledExtensionPackageIds(env)).toEqual(new Set(["@acme/review-tools"])); + + setExtensionPackageActivation("@acme/review-tools", true, env); + expect(readDisabledExtensionPackageIds(env)).toEqual(new Set()); + }); +}); diff --git a/packages/hunk/src/extensions/manage/activation.ts b/packages/hunk/src/extensions/manage/activation.ts new file mode 100644 index 000000000..52be31dab --- /dev/null +++ b/packages/hunk/src/extensions/manage/activation.ts @@ -0,0 +1,123 @@ +import { join } from "node:path"; +import { readAppStateRecord, writeAppStateRecord } from "../../core/process/appStateFile"; +import { resolveGlobalExtensionsDir } from "../../core/run/paths"; +import { normalizeExtensionPackageId } from "../packageIdentity"; + +/** User-owned activation choices live separately from mutable install metadata. */ +export type ExtensionPackageActivationMap = Record; + +const ACTIVATION_FILE_NAME = "activation.json"; + +/** Resolve the activation preference file without requiring the managed install directory. */ +export function resolveExtensionActivationPath(env: NodeJS.ProcessEnv = process.env) { + const root = resolveGlobalExtensionsDir(env); + return root ? join(root, ACTIVATION_FILE_NAME) : undefined; +} + +/** Read explicit package choices; absent and damaged files mean enabled-by-default. */ +export function readExtensionPackageActivations( + env: NodeJS.ProcessEnv = process.env, +): ExtensionPackageActivationMap { + const path = resolveExtensionActivationPath(env); + if (!path) return {}; + const stored = readAppStateRecord(path).packages; + if (typeof stored !== "object" || stored === null || Array.isArray(stored)) return {}; + + return Object.fromEntries( + Object.entries(stored as Record).filter( + (entry): entry is [string, boolean] => + normalizeExtensionPackageId(entry[0]) !== undefined && typeof entry[1] === "boolean", + ), + ); +} + +/** Replace package choices exactly; update rollback uses this to restore a snapshot. */ +export function writeExtensionPackageActivations( + packages: ExtensionPackageActivationMap, + env: NodeJS.ProcessEnv = process.env, +) { + const path = resolveExtensionActivationPath(env); + if (!path) return false; + const normalized = Object.fromEntries( + Object.entries(packages).filter( + ([id, enabled]) => + normalizeExtensionPackageId(id) !== undefined && typeof enabled === "boolean", + ), + ); + writeAppStateRecord(path, { packages: normalized }); + return true; +} + +/** Persist package choices together while preserving preferences for other packages. */ +export function setExtensionPackageActivations( + updates: ExtensionPackageActivationMap, + env: NodeJS.ProcessEnv = process.env, +) { + return writeExtensionPackageActivations( + { ...readExtensionPackageActivations(env), ...updates }, + env, + ); +} + +/** Persist one package choice while preserving preferences for other packages. */ +export function setExtensionPackageActivation( + packageId: string, + enabled: boolean, + env: NodeJS.ProcessEnv = process.env, +) { + const normalized = normalizeExtensionPackageId(packageId); + if (!normalized) return false; + return setExtensionPackageActivations({ [normalized]: enabled }, env); +} + +/** Activation changes an update may apply without guessing about package ownership. */ +export interface ExtensionActivationMigration { + activations: ExtensionPackageActivationMap; + changed: boolean; +} + +/** Plan activation preservation without broadening an explicit package denial. */ +export function planExtensionPackageActivationMigration( + previousPackageIds: readonly string[], + nextPackageIds: readonly string[], + activations: ExtensionPackageActivationMap, +): ExtensionActivationMigration | undefined { + const previous = [...new Set(previousPackageIds)]; + const next = [...new Set(nextPackageIds)]; + const nextSet = new Set(next); + const previousSet = new Set(previous); + const removed = previous.filter((id) => !nextSet.has(id)); + const added = next.filter((id) => !previousSet.has(id)); + const deniedPrevious = previous.filter((id) => activations[id] === false); + const migrated = { ...activations }; + + // With no denial to preserve, package topology may evolve normally. Explicit + // enables and unchanged identities retain their independent stored choices. + if (deniedPrevious.length === 0) { + return { activations: migrated, changed: false }; + } + + // One removed identity becoming one new identity is the only unambiguous way + // to carry a denial forward. Additions beside a denied package and wider + // identity rewrites could silently introduce enabled code, so require the + // user to resolve those changes explicitly before updating. + if (removed.length === 1 && added.length === 1 && deniedPrevious[0] === removed[0]) { + migrated[added[0]!] = false; + } else if (added.length > 0) { + return undefined; + } + + return { + activations: migrated, + changed: JSON.stringify(migrated) !== JSON.stringify(activations), + }; +} + +/** Return package ids explicitly disabled by the user. */ +export function readDisabledExtensionPackageIds(env: NodeJS.ProcessEnv = process.env) { + return new Set( + Object.entries(readExtensionPackageActivations(env)) + .filter(([, enabled]) => !enabled) + .map(([packageId]) => packageId), + ); +} diff --git a/packages/hunk/src/extensions/manage/cli.ts b/packages/hunk/src/extensions/manage/cli.ts new file mode 100644 index 000000000..0e13ad2de --- /dev/null +++ b/packages/hunk/src/extensions/manage/cli.ts @@ -0,0 +1,221 @@ +import { HunkUserError } from "../../core/run/errors"; +import { resolveInstalledExtensionsRoot } from "../../core/run/paths"; +import type { ExtensionManageCommandInput } from "../../core/run/commandInputs"; +import { + installExtension, + listExtensions, + removeExtension, + updateExtension, + type ExtensionManageContext, +} from "./install"; +import { parseExtensionInstallSource } from "./source"; +import { sanitizeExtensionPackageDisplay } from "../packageIdentity"; +import { readExtensionPackageActivations, setExtensionPackageActivation } from "./activation"; + +/** + * The I/O one `hunk extension` command runs against. + * + * Everything the runner touches outside the managed install root arrives + * through this seam, so tests can drive install confirmations and read output + * without owning a terminal. + */ +export interface ExtensionManageIo { + stdout: (text: string) => void; + stderr: (text: string) => void; + /** Ask one yes/no question on a real terminal; absent when there is none. */ + confirm?: (question: string) => Promise; + env?: NodeJS.ProcessEnv; +} + +/** Shorten one commit sha for display. */ +function shortCommit(commit: string) { + return sanitizeExtensionPackageDisplay(commit).slice(0, 7); +} + +/** Phrase one recorded source with its pinned ref, when it has one. */ +function describeSource(source: string, ref: string | undefined) { + const safeSource = sanitizeExtensionPackageDisplay(source); + return ref !== undefined ? `${safeSource} @ ${sanitizeExtensionPackageDisplay(ref)}` : safeSource; +} + +/** Resolve the managed install root or explain why there is none. */ +function requireInstalledRoot(env: NodeJS.ProcessEnv) { + const installedRoot = resolveInstalledExtensionsRoot(env); + if (!installedRoot) { + throw new HunkUserError( + "Could not resolve the extension install directory because HOME/XDG_CONFIG_HOME is unset.", + ); + } + + return installedRoot; +} + +/** Resolve an activation selector without guessing between install and package identities. */ +function resolveActivationPackageIds(entries: ReturnType, selector: string) { + const install = entries.find((entry) => entry.name === selector); + const packageMatches = entries.flatMap((entry) => + entry.packages.filter((extensionPackage) => extensionPackage.id === selector).map(() => entry), + ); + + if (packageMatches.length > 1) { + throw new HunkUserError(`Package id "${selector}" is exposed more than once.`, [ + `Remove or rename the duplicate packages before changing activation: ${[ + ...new Set(packageMatches.map((entry) => entry.name)), + ].join(", ")}.`, + ]); + } + + if (install && packageMatches.length === 1) { + const installPackageIds = [...new Set(install.packages.map((entry) => entry.id))]; + const sameSingleTarget = + packageMatches[0] === install && + installPackageIds.length === 1 && + installPackageIds[0] === selector; + if (!sameSingleTarget) { + throw new HunkUserError( + `"${selector}" matches both a managed install name and a package id.`, + ["Rename one identity before changing activation so Hunk does not disable the wrong code."], + ); + } + } + + if (install) { + return [...new Set(install.packages.map((entry) => entry.id))]; + } + if (packageMatches.length === 1) { + return [selector]; + } + return []; +} + +/** + * Run one `hunk extension` command and return its exit code. + * + * Install is the only interactive step: extensions execute with the user's + * full permissions, so a fresh install requires either a terminal confirmation + * or an explicit `--yes`. Everything else operates on what is already + * recorded and just prints what it did. + */ +export async function runExtensionManageCommand( + input: ExtensionManageCommandInput, + io: ExtensionManageIo, +): Promise { + const env = io.env ?? process.env; + const context: ExtensionManageContext = { + installedRoot: requireInstalledRoot(env), + env, + log: (line) => io.stderr(`${line}\n`), + }; + + if (input.action === "install") { + const source = parseExtensionInstallSource(input.source); + + if (!input.yes) { + if (!io.confirm) { + throw new HunkUserError( + "Installing an extension needs a confirmation, and there is no terminal to ask on.", + [`Re-run with --yes after reviewing ${source.cloneUrl}.`], + ); + } + + io.stdout( + `Install ${describeSource(source.cloneUrl, source.ref)}?\n` + + "Extensions run with your full user permissions. Only install repositories you trust.\n", + ); + if (!(await io.confirm("Proceed? [y/N] "))) { + io.stdout("Install cancelled.\n"); + return 1; + } + } + + const outcome = installExtension(context, source); + io.stdout( + `Installed ${sanitizeExtensionPackageDisplay(outcome.name)}${outcome.version ? ` v${sanitizeExtensionPackageDisplay(outcome.version)}` : ""} at ${shortCommit(outcome.commit)} into ${outcome.directory}.\n`, + ); + if (outcome.dependencyWarning) { + io.stderr(`warning: ${outcome.dependencyWarning}\n`); + } + io.stdout("New Hunk sessions will load it automatically.\n"); + return 0; + } + + if (input.action === "list") { + const entries = listExtensions(context); + if (entries.length === 0) { + io.stdout( + "No managed extension installs.\nInstall one with `hunk extension install /`.\n", + ); + return 0; + } + + const activations = readExtensionPackageActivations(env); + for (const entry of entries) { + const version = entry.version + ? `v${sanitizeExtensionPackageDisplay(entry.version)}` + : shortCommit(entry.record.commit); + const missing = entry.present ? "" : " (missing on disk — reinstall or remove)"; + // One managed repository may expose several packages, and each package may + // activate several entries. Keep all three identities visible in one row. + const packageSummary = entry.packages + .map((extensionPackage) => { + const state = activations[extensionPackage.id] === false ? "disabled" : "enabled"; + const entries = extensionPackage.entries.length + ? ` [${extensionPackage.entries.map(sanitizeExtensionPackageDisplay).join(", ")}]` + : ""; + return `${sanitizeExtensionPackageDisplay(extensionPackage.id)} (${state})${entries}`; + }) + .join(", "); + io.stdout( + `${sanitizeExtensionPackageDisplay(entry.name)} ${version} ${packageSummary} ${describeSource(entry.record.cloneUrl, entry.record.ref)}${missing}\n`, + ); + } + return 0; + } + + if (input.action === "enable" || input.action === "disable") { + const entries = listExtensions(context); + const packageIds = resolveActivationPackageIds(entries, input.name); + if (packageIds.length === 0) { + throw new HunkUserError(`"${input.name}" is not a managed extension package.`, [ + "Run `hunk extension list` to see managed package identities.", + ]); + } + const enabled = input.action === "enable"; + for (const packageId of new Set(packageIds)) { + if (!setExtensionPackageActivation(packageId, enabled, env)) { + throw new HunkUserError("Could not resolve the extension activation file."); + } + io.stdout( + `${enabled ? "Enabled" : "Disabled"} ${sanitizeExtensionPackageDisplay(packageId)}.\n`, + ); + } + io.stdout("The change applies to new and reloaded Hunk sessions.\n"); + return 0; + } + + if (input.action === "update") { + const names = + input.name !== undefined ? [input.name] : listExtensions(context).map((entry) => entry.name); + if (names.length === 0) { + io.stdout("No managed extension installs to update.\n"); + return 0; + } + + for (const name of names) { + const outcome = updateExtension(context, name); + io.stdout( + outcome.changed + ? `Updated ${sanitizeExtensionPackageDisplay(outcome.name)}${outcome.version ? ` to v${sanitizeExtensionPackageDisplay(outcome.version)}` : ""}: ${shortCommit(outcome.previousCommit)} -> ${shortCommit(outcome.commit)}.\n` + : `${sanitizeExtensionPackageDisplay(outcome.name)} is already up to date (${shortCommit(outcome.commit)}).\n`, + ); + if (outcome.dependencyWarning) { + io.stderr(`warning: ${outcome.dependencyWarning}\n`); + } + } + return 0; + } + + removeExtension(context, input.name); + io.stdout(`Removed ${sanitizeExtensionPackageDisplay(input.name)}.\n`); + return 0; +} diff --git a/packages/hunk/src/extensions/manage/install.test.ts b/packages/hunk/src/extensions/manage/install.test.ts new file mode 100644 index 000000000..ea97230e3 --- /dev/null +++ b/packages/hunk/src/extensions/manage/install.test.ts @@ -0,0 +1,634 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { discoverExtensions } from "../discovery"; +import { runExtensionManageCommand } from "./cli"; +import { + installExtension, + listExtensions, + removeExtension, + updateExtension, + type ExtensionManageContext, +} from "./install"; +import { readInstallRecords, writeInstallRecords } from "./records"; +import { parseExtensionInstallSource } from "./source"; +import { readExtensionPackageActivations, setExtensionPackageActivation } from "./activation"; + +// Every test here spawns real Git processes — a fixture repo, usually a clone, +// sometimes a second one for an update. Hosted Windows runners can stall a +// single clone past Bun's five-second default, so bound the suite generously. +setDefaultTimeout(30_000); + +const tempDirs: string[] = []; + +function createTempDir(prefix: string) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +/** Run one git command in a fixture repo, failing the test on error. */ +function runFixtureGit(cwd: string, args: string[]) { + const proc = Bun.spawnSync(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + if (proc.exitCode !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${proc.stderr?.toString()}`); + } + return proc.stdout?.toString() ?? ""; +} + +/** Create one commit-able extension repository fixture and return its path. */ +function createExtensionRepoFixture(name: string) { + const repo = join(createTempDir("hunk-manage-fixture-"), name); + mkdirSync(repo, { recursive: true }); + runFixtureGit(repo, ["init", "--quiet"]); + runFixtureGit(repo, ["config", "user.email", "test@example.com"]); + runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); + writeFileSync( + join(repo, "package.json"), + JSON.stringify({ name, version: "1.0.0", hunk: { extensions: ["./index.ts"] } }), + ); + writeFileSync(join(repo, "index.ts"), "export default () => {};\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); + return repo; +} + +/** Change a fixture's manifest and commit it. */ +function commitFixtureManifest(repo: string, update: (manifest: Record) => void) { + const manifestPath = join(repo, "package.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record; + update(manifest); + writeFileSync(manifestPath, JSON.stringify(manifest)); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "update package manifest"]); +} + +/** Change a fixture's manifest package identity and commit it. */ +function commitFixturePackageName(repo: string, packageName: string) { + commitFixtureManifest(repo, (manifest) => { + manifest.name = packageName; + }); +} + +/** Create a collection repository with one manifest-owned folder per package id. */ +function createCollectionRepoFixture(name: string, packageIds: readonly string[]) { + const repo = join(createTempDir("hunk-manage-collection-"), name); + mkdirSync(repo, { recursive: true }); + runFixtureGit(repo, ["init", "--quiet"]); + runFixtureGit(repo, ["config", "user.email", "test@example.com"]); + runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); + for (const [index, packageId] of packageIds.entries()) { + const dir = join(repo, `package-${index + 1}`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ hunk: { packageId, extensions: ["./index.ts"] } }), + ); + writeFileSync(join(dir, "index.ts"), "export default () => {};\n"); + } + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); + return repo; +} + +/** Add one package to a collection fixture and commit it. */ +function commitCollectionPackage(repo: string, packageId: string) { + const dir = join(repo, `package-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ hunk: { packageId, extensions: ["./index.ts"] } }), + ); + writeFileSync(join(dir, "index.ts"), "export default () => {};\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", `add ${packageId}`]); +} + +/** Build one manage context against a fresh managed root. */ +function createTestContext(): ExtensionManageContext & { logs: string[] } { + const logs: string[] = []; + const configRoot = createTempDir("hunk-manage-root-"); + return { + installedRoot: join(configRoot, "installed"), + env: { XDG_CONFIG_HOME: configRoot }, + log: (line) => logs.push(line), + logs, + }; +} + +describe("managed extension installs", () => { + test("installs, records, and discovers a local git repository", () => { + const repo = createExtensionRepoFixture("word-diff"); + const context = createTestContext(); + + const outcome = installExtension(context, parseExtensionInstallSource(repo)); + + expect(outcome.name).toBe("word-diff"); + expect(outcome.version).toBe("1.0.0"); + expect(existsSync(join(outcome.directory, "index.ts"))).toBe(true); + + const records = readInstallRecords(context.installedRoot); + expect(records["word-diff"]?.cloneUrl).toBe(repo); + expect(records["word-diff"]?.commit).toBe(outcome.commit); + + // Discovery picks the install up through the global group, one level below + // the global extensions dir. + const globalDir = join(context.installedRoot, ".."); + const candidates = discoverExtensions({ + cwd: globalDir, + repoRoot: undefined, + globalExtensionsDir: globalDir, + }); + expect(candidates).toEqual([ + { + id: "word-diff", + path: join(context.installedRoot, "word-diff", "index.ts"), + origin: "global", + package: { + id: "word-diff", + name: "word-diff", + version: "1.0.0", + root: join(context.installedRoot, "word-diff"), + }, + }, + ]); + }); + + test("rolls back a promoted install when record persistence fails", () => { + const repo = createExtensionRepoFixture("failed-install"); + const context = createTestContext(); + context.onTransactionStep = (step) => { + if (step === "recorded") throw new Error("injected record failure"); + }; + + expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( + "injected record failure", + ); + expect(existsSync(join(context.installedRoot, "failed-install"))).toBe(false); + expect(readInstallRecords(context.installedRoot)).toEqual({}); + }); + + test("installs a pinned tag and stays put until updated", () => { + const repo = createExtensionRepoFixture("pinned-ext"); + runFixtureGit(repo, ["tag", "v1"]); + const context = createTestContext(); + + const outcome = installExtension(context, parseExtensionInstallSource(`${repo}@v1`)); + expect(readInstallRecords(context.installedRoot)["pinned-ext"]?.ref).toBe("v1"); + + // A new commit on the default branch must not move a tag-pinned install. + writeFileSync(join(repo, "extra.ts"), "export const later = true;\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "later"]); + + const update = updateExtension(context, "pinned-ext"); + expect(update.changed).toBe(false); + expect(update.commit).toBe(outcome.commit); + }); + + test("updates a branch-tracking install to the new commit", () => { + const repo = createExtensionRepoFixture("tracking-ext"); + const context = createTestContext(); + const installed = installExtension(context, parseExtensionInstallSource(repo)); + + writeFileSync( + join(repo, "package.json"), + JSON.stringify({ + name: "tracking-ext", + version: "1.1.0", + hunk: { extensions: ["./index.ts"] }, + }), + ); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "bump"]); + + const update = updateExtension(context, "tracking-ext"); + expect(update.changed).toBe(true); + expect(update.previousCommit).toBe(installed.commit); + expect(update.commit).not.toBe(installed.commit); + expect(update.version).toBe("1.1.0"); + expect(readInstallRecords(context.installedRoot)["tracking-ext"]?.commit).toBe(update.commit); + }); + + test.each(["promoted", "recorded", "activated"] as const)( + "rolls back code and metadata when update fails after %s", + (failureStep) => { + const repo = createExtensionRepoFixture(`rollback-${failureStep}`); + const context = createTestContext(); + const installed = installExtension(context, parseExtensionInstallSource(repo)); + const installedEntry = join(installed.directory, "index.ts"); + const previousSource = readFileSync(installedEntry, "utf8"); + const previousRecords = readInstallRecords(context.installedRoot); + const previousActivations = readExtensionPackageActivations(context.env); + + writeFileSync(join(repo, "index.ts"), `export default () => "${failureStep}";\n`); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", `update ${failureStep}`]); + context.onTransactionStep = (step) => { + if (step === failureStep) throw new Error(`injected ${failureStep} failure`); + }; + + expect(() => updateExtension(context, installed.name)).toThrow( + `injected ${failureStep} failure`, + ); + expect(readFileSync(installedEntry, "utf8")).toBe(previousSource); + expect(readInstallRecords(context.installedRoot)).toEqual(previousRecords); + expect(readExtensionPackageActivations(context.env)).toEqual(previousActivations); + expect(existsSync(join(context.installedRoot, `.previous-${installed.name}`))).toBe(false); + expect( + existsSync(join(context.installedRoot, `.staging-${installed.name}-${process.pid}`)), + ).toBe(false); + }, + ); + + test("keeps an updated install disabled when its manifest package id changes", () => { + const repo = createExtensionRepoFixture("renamed-ext"); + const context = createTestContext(); + installExtension(context, parseExtensionInstallSource(repo)); + expect(setExtensionPackageActivation("renamed-ext", false, context.env)).toBe(true); + + writeFileSync( + join(repo, "package.json"), + JSON.stringify({ + name: "renamed-package", + version: "2.0.0", + hunk: { extensions: ["./index.ts"] }, + }), + ); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "rename package"]); + + const update = updateExtension(context, "renamed-ext"); + + expect(update.changed).toBe(true); + expect(update.packages.map((entry) => entry.id)).toEqual(["renamed-package"]); + expect(readExtensionPackageActivations(context.env)).toEqual({ + "renamed-ext": false, + "renamed-package": false, + }); + }); + + test("recovers a disabled package identity from a legacy record before renaming it", () => { + const repo = createExtensionRepoFixture("legacy-record-rename"); + commitFixturePackageName(repo, "old-owned-package"); + const context = createTestContext(); + installExtension(context, parseExtensionInstallSource(repo)); + const records = readInstallRecords(context.installedRoot); + const record = records["legacy-record-rename"]!; + const { packageIds: _legacyOmission, ...legacyRecord } = record; + writeInstallRecords(context.installedRoot, { "legacy-record-rename": legacyRecord }); + expect(setExtensionPackageActivation("old-owned-package", false, context.env)).toBe(true); + + commitFixturePackageName(repo, "new-owned-package"); + const update = updateExtension(context, "legacy-record-rename"); + + expect(update.packages.map((entry) => entry.id)).toEqual(["new-owned-package"]); + expect(readExtensionPackageActivations(context.env)).toEqual({ + "old-owned-package": false, + "new-owned-package": false, + }); + }); + + test("rejects additions against a disabled package recovered from a legacy record", () => { + const repo = createCollectionRepoFixture("legacy-record-addition", ["old-owned-package"]); + const context = createTestContext(); + const installed = installExtension(context, parseExtensionInstallSource(repo)); + const records = readInstallRecords(context.installedRoot); + const record = records["legacy-record-addition"]!; + const { packageIds: _legacyOmission, ...legacyRecord } = record; + writeInstallRecords(context.installedRoot, { "legacy-record-addition": legacyRecord }); + expect(setExtensionPackageActivation("old-owned-package", false, context.env)).toBe(true); + commitCollectionPackage(repo, "added-package"); + + expect(() => updateExtension(context, "legacy-record-addition")).toThrow( + "would add or ambiguously replace package identities", + ); + expect(readInstallRecords(context.installedRoot)["legacy-record-addition"]?.commit).toBe( + installed.commit, + ); + expect(readExtensionPackageActivations(context.env)).toEqual({ + "old-owned-package": false, + }); + }); + + test("allows a package addition when the install has no explicit denial", () => { + const repo = createCollectionRepoFixture("growing-ext", ["package-a"]); + const context = createTestContext(); + installExtension(context, parseExtensionInstallSource(repo)); + commitCollectionPackage(repo, "package-b"); + + const update = updateExtension(context, "growing-ext"); + expect(update.packages.map((entry) => entry.id)).toEqual(["package-a", "package-b"]); + }); + + test("rejects a package addition while an existing package is denied", () => { + const repo = createCollectionRepoFixture("denied-growing-ext", ["package-a"]); + const context = createTestContext(); + const installed = installExtension(context, parseExtensionInstallSource(repo)); + expect(setExtensionPackageActivation("package-a", false, context.env)).toBe(true); + commitCollectionPackage(repo, "package-b"); + + expect(() => updateExtension(context, "denied-growing-ext")).toThrow( + "would add or ambiguously replace package identities", + ); + expect(readInstallRecords(context.installedRoot)["denied-growing-ext"]?.commit).toBe( + installed.commit, + ); + expect(readExtensionPackageActivations(context.env)).toEqual({ "package-a": false }); + }); + + test("rejects duplicate package ids declared by distinct roots in one install", () => { + const repo = createCollectionRepoFixture("duplicate-roots", ["shared", "shared"]); + const context = createTestContext(); + + expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( + 'package id "shared" is declared by distinct package roots', + ); + expect(readInstallRecords(context.installedRoot)).toEqual({}); + }); + + test("refuses a repository that contains no extension", () => { + const repo = join(createTempDir("hunk-manage-empty-"), "not-an-ext"); + mkdirSync(repo, { recursive: true }); + runFixtureGit(repo, ["init", "--quiet"]); + runFixtureGit(repo, ["config", "user.email", "test@example.com"]); + runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); + writeFileSync(join(repo, "README.md"), "not an extension\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); + const context = createTestContext(); + + expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( + /does not contain a Hunk extension/, + ); + expect(existsSync(join(context.installedRoot, "not-an-ext"))).toBe(false); + expect(readInstallRecords(context.installedRoot)).toEqual({}); + }); + + test("refuses to install over an existing record or an unmanaged directory", () => { + const repo = createExtensionRepoFixture("twice-ext"); + const context = createTestContext(); + installExtension(context, parseExtensionInstallSource(repo)); + + expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( + /already installed/, + ); + + mkdirSync(join(context.installedRoot, "hand-copied"), { recursive: true }); + expect(() => + installExtension(context, { + ...parseExtensionInstallSource(repo), + name: "hand-copied", + }), + ).toThrow(/not a managed install/); + }); + + test("removes a managed install's directory and record", () => { + const repo = createExtensionRepoFixture("removable-ext"); + const context = createTestContext(); + const outcome = installExtension(context, parseExtensionInstallSource(repo)); + + removeExtension(context, "removable-ext"); + + expect(existsSync(outcome.directory)).toBe(false); + expect(readInstallRecords(context.installedRoot)).toEqual({}); + expect(() => removeExtension(context, "removable-ext")).toThrow(/not a managed install/); + }); + + test("lists installs with version, source, and missing-directory state", () => { + const repo = createExtensionRepoFixture("listed-ext"); + const context = createTestContext(); + installExtension(context, parseExtensionInstallSource(repo)); + + const entries = listExtensions(context); + expect(entries).toHaveLength(1); + expect(entries[0]?.name).toBe("listed-ext"); + expect(entries[0]?.version).toBe("1.0.0"); + expect(entries[0]?.present).toBe(true); + + rmSync(join(context.installedRoot, "listed-ext"), { recursive: true, force: true }); + expect(listExtensions(context)[0]?.present).toBe(false); + }); +}); + +describe("hunk extension command runner", () => { + /** Drive the runner against a temp config dir, capturing output. */ + function createRunnerIo(confirmAnswer?: boolean) { + const configDir = createTempDir("hunk-manage-config-"); + const out: string[] = []; + const err: string[] = []; + return { + configDir, + out, + err, + io: { + stdout: (text: string) => out.push(text), + stderr: (text: string) => err.push(text), + ...(confirmAnswer !== undefined ? { confirm: async () => confirmAnswer } : {}), + env: { XDG_CONFIG_HOME: configDir } as NodeJS.ProcessEnv, + }, + }; + } + + test("install --yes runs end to end and list reports it", async () => { + const repo = createExtensionRepoFixture("runner-ext"); + const runner = createRunnerIo(); + + const exitCode = await runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: repo, yes: true }, + runner.io, + ); + + expect(exitCode).toBe(0); + expect(runner.out.join("")).toContain("Installed runner-ext v1.0.0"); + + const listExit = await runExtensionManageCommand( + { kind: "extension-manage", action: "list" }, + runner.io, + ); + expect(listExit).toBe(0); + expect(runner.out.join("")).toContain("runner-ext v1.0.0"); + }); + + test("sanitizes package version metadata in install and update output", async () => { + const repo = createExtensionRepoFixture("display-ext"); + commitFixtureManifest(repo, (manifest) => { + manifest.version = "1.0.0\u001b[31m\nunsafe"; + }); + const runner = createRunnerIo(); + await runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: repo, yes: true }, + runner.io, + ); + expect(runner.out.join("")).toContain("v1.0.0[31munsafe"); + expect(runner.out.join("")).not.toContain("\u001b"); + expect(runner.out.join("")).not.toContain("\nunsafe"); + + commitFixtureManifest(repo, (manifest) => { + manifest.version = "2.0.0\u001b[32m\runsafe"; + }); + await runExtensionManageCommand( + { kind: "extension-manage", action: "update", name: "display-ext" }, + runner.io, + ); + expect(runner.out.join("")).toContain("to v2.0.0[32munsafe"); + expect(runner.out.join("")).not.toContain("\u001b"); + }); + + test("lists and toggles a stable package identity independently of installation", async () => { + const repo = createExtensionRepoFixture("toggle-ext"); + const runner = createRunnerIo(); + await runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: repo, yes: true }, + runner.io, + ); + + expect( + await runExtensionManageCommand( + { kind: "extension-manage", action: "disable", name: "toggle-ext" }, + runner.io, + ), + ).toBe(0); + expect(readExtensionPackageActivations(runner.io.env)).toEqual({ "toggle-ext": false }); + await runExtensionManageCommand({ kind: "extension-manage", action: "list" }, runner.io); + expect(runner.out.join("")).toContain("toggle-ext (disabled) [toggle-ext]"); + + await runExtensionManageCommand( + { kind: "extension-manage", action: "enable", name: "toggle-ext" }, + runner.io, + ); + expect(readExtensionPackageActivations(runner.io.env)).toEqual({ "toggle-ext": true }); + }); + + test("rejects an activation selector shared by an install name and another package", async () => { + const installNamedTarget = createExtensionRepoFixture("selector-target"); + const packageNamedTarget = createExtensionRepoFixture("other-install"); + commitFixturePackageName(installNamedTarget, "first-package"); + commitFixturePackageName(packageNamedTarget, "selector-target"); + const runner = createRunnerIo(); + for (const repo of [installNamedTarget, packageNamedTarget]) { + await runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: repo, yes: true }, + runner.io, + ); + } + + await expect( + runExtensionManageCommand( + { kind: "extension-manage", action: "disable", name: "selector-target" }, + runner.io, + ), + ).rejects.toThrow("matches both a managed install name and a package id"); + }); + + test("rejects duplicate package ids across managed installs and updates", async () => { + const first = createExtensionRepoFixture("duplicate-one"); + const second = createExtensionRepoFixture("duplicate-two"); + commitFixturePackageName(first, "shared-package"); + const runner = createRunnerIo(); + await runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: first, yes: true }, + runner.io, + ); + + commitFixturePackageName(second, "shared-package"); + await expect( + runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: second, yes: true }, + runner.io, + ), + ).rejects.toThrow('package id "shared-package" is already owned'); + + const unique = createExtensionRepoFixture("unique-two"); + await runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: unique, yes: true }, + runner.io, + ); + commitFixturePackageName(unique, "shared-package"); + await expect( + runExtensionManageCommand( + { kind: "extension-manage", action: "update", name: "unique-two" }, + runner.io, + ), + ).rejects.toThrow('package id "shared-package" is already owned'); + }); + + test("install without --yes needs a confirmation and honors a refusal", async () => { + const repo = createExtensionRepoFixture("prompted-ext"); + const noTerminal = createRunnerIo(); + + await expect( + runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: repo, yes: false }, + noTerminal.io, + ), + ).rejects.toThrow(/no terminal/); + + const refused = createRunnerIo(false); + const exitCode = await runExtensionManageCommand( + { kind: "extension-manage", action: "install", source: repo, yes: false }, + refused.io, + ); + expect(exitCode).toBe(1); + expect(refused.out.join("")).toContain("full user permissions"); + expect(refused.out.join("")).toContain("Install cancelled."); + }); +}); + +describe("install validation strictness", () => { + test("refuses a repository whose only entry is an incidental src/index.ts", () => { + // The shape of nearly every JavaScript project — and of pi extensions, + // whose manifests use a `pi` field instead of `hunk`. + const repo = join(createTempDir("hunk-manage-incidental-"), "pi-shaped"); + mkdirSync(join(repo, "src"), { recursive: true }); + runFixtureGit(repo, ["init", "--quiet"]); + runFixtureGit(repo, ["config", "user.email", "test@example.com"]); + runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); + writeFileSync( + join(repo, "package.json"), + JSON.stringify({ name: "pi-shaped", pi: { extensions: ["./src/index.ts"] } }), + ); + writeFileSync(join(repo, "src", "index.ts"), "export default () => {};\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); + const context = createTestContext(); + + expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( + /does not contain a Hunk extension/, + ); + }); + + test("accepts a collection repository of subfolders with hunk manifests", () => { + const repo = join(createTempDir("hunk-manage-collection-"), "ext-pack"); + mkdirSync(join(repo, "one"), { recursive: true }); + runFixtureGit(repo, ["init", "--quiet"]); + runFixtureGit(repo, ["config", "user.email", "test@example.com"]); + runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); + writeFileSync( + join(repo, "one", "package.json"), + JSON.stringify({ name: "one", hunk: { extensions: ["./entry.ts"] } }), + ); + writeFileSync(join(repo, "one", "entry.ts"), "export default () => {};\n"); + runFixtureGit(repo, ["add", "."]); + runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); + const context = createTestContext(); + + const outcome = installExtension(context, parseExtensionInstallSource(repo)); + expect(outcome.name).toBe("ext-pack"); + }); +}); diff --git a/src/extensions/manage/install.ts b/packages/hunk/src/extensions/manage/install.ts similarity index 68% rename from src/extensions/manage/install.ts rename to packages/hunk/src/extensions/manage/install.ts index 08beb135f..3efd80891 100644 --- a/src/extensions/manage/install.ts +++ b/packages/hunk/src/extensions/manage/install.ts @@ -1,8 +1,13 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import { HunkUserError } from "../../core/run/errors"; -import { directoryContainsExtensionEntries } from "../discovery"; +import { describeExtensionPackages, directoryContainsExtensionEntries } from "../discovery"; import { readInstallRecords, writeInstallRecords, type ExtensionInstallRecord } from "./records"; +import { + planExtensionPackageActivationMigration, + readExtensionPackageActivations, + writeExtensionPackageActivations, +} from "./activation"; import type { ExtensionInstallSource } from "./source"; /** @@ -15,10 +20,14 @@ import type { ExtensionInstallSource } from "./source"; export interface ExtensionManageContext { /** Managed install root; created on demand. */ installedRoot: string; + /** Environment that owns package activation preferences. */ + env: NodeJS.ProcessEnv; /** Progress sink; one short line per step. */ log: (line: string) => void; /** Timestamp seam so tests can pin record times. */ now?: () => Date; + /** Fault-injection seam used to prove managed-update rollback boundaries. */ + onTransactionStep?: (step: "promoted" | "recorded" | "activated") => void; } /** Outcome of one install or update, for the runner to phrase. */ @@ -28,6 +37,8 @@ export interface ExtensionInstallOutcome { commit: string; /** Version from the clone's `package.json`, when it declares one. */ version?: string; + /** Stable package identities and runtime entries exposed by the install. */ + packages: ReturnType; /** Set when dependencies were declared but could not be installed. */ dependencyWarning?: string; } @@ -41,6 +52,8 @@ export interface ExtensionInstallListEntry { version?: string; /** False when the recorded directory is gone from disk. */ present: boolean; + /** Current package/entry identities, or recorded ids when the directory is missing. */ + packages: ReturnType; } /** Run one git invocation, returning stdout or throwing a user-facing error. */ @@ -225,21 +238,33 @@ function promoteStagedClone(stagingDir: string, directory: string) { rmSync(previousDir, { recursive: true, force: true }); const hadPrevious = existsSync(directory); - if (hadPrevious) { - renameSync(directory, previousDir); - } + if (hadPrevious) renameSync(directory, previousDir); try { renameSync(stagingDir, directory); } catch (error) { - if (hadPrevious) { - renameSync(previousDir, directory); - } + if (hadPrevious) renameSync(previousDir, directory); rmSync(stagingDir, { recursive: true, force: true }); throw error; } - rmSync(previousDir, { recursive: true, force: true }); + let settled = false; + return { + /** Keep the promoted checkout and discard the rollback copy. */ + commit() { + if (settled) return; + rmSync(previousDir, { recursive: true, force: true }); + settled = true; + }, + /** Restore the exact checkout present before promotion. */ + rollback() { + if (settled) return; + rmSync(directory, { recursive: true, force: true }); + if (hadPrevious) renameSync(previousDir, directory); + else rmSync(previousDir, { recursive: true, force: true }); + settled = true; + }, + }; } /** @@ -258,6 +283,39 @@ function saveRecord(context: ExtensionManageContext, name: string, record: Exten }); } +/** Recover identities omitted by legacy records from the checkout they already describe. */ +function resolvePreviousPackageIds( + record: ExtensionInstallRecord, + installName: string, + directory: string, +) { + if (record.packageIds) return record.packageIds; + if (existsSync(directory)) { + const discovered = describeExtensionPackages(directory).map((entry) => entry.id); + if (discovered.length > 0) return discovered; + } + return [installName]; +} + +/** Reject package identities already owned by another managed install. */ +function assertPackageIdsAvailable( + context: ExtensionManageContext, + installName: string, + packageIds: readonly string[], +) { + const records = readInstallRecords(context.installedRoot); + for (const [otherName, record] of Object.entries(records)) { + if (otherName === installName) continue; + const duplicate = (record.packageIds ?? [otherName]).find((id) => packageIds.includes(id)); + if (duplicate) { + throw new HunkUserError( + `Extension package id "${duplicate}" is already owned by managed install "${otherName}".`, + ["Remove or rename the duplicate package before installing or updating."], + ); + } + } +} + /** * Install one extension repository into the managed root. * @@ -287,23 +345,46 @@ export function installExtension( context.log(`cloning ${source.cloneUrl}${source.ref ? ` @ ${source.ref}` : ""}…`); const { stagingDir, commit } = stageClone(context, source); const dependencyWarning = prepareStagedDependencies(context, stagingDir); - promoteStagedClone(stagingDir, directory); - + let packages: ReturnType; + try { + packages = describeExtensionPackages(stagingDir); + assertPackageIdsAvailable( + context, + source.name, + packages.map((entry) => entry.id), + ); + } catch (error) { + rmSync(stagingDir, { recursive: true, force: true }); + throw error; + } const timestamp = (context.now?.() ?? new Date()).toISOString(); - saveRecord(context, source.name, { - source: source.spec, - cloneUrl: source.cloneUrl, - ...(source.ref !== undefined ? { ref: source.ref } : {}), - commit, - installedAt: timestamp, - updatedAt: timestamp, - }); + const previousRecords = readInstallRecords(context.installedRoot); + const promotion = promoteStagedClone(stagingDir, directory); + try { + context.onTransactionStep?.("promoted"); + saveRecord(context, source.name, { + packageIds: packages.map((entry) => entry.id), + source: source.spec, + cloneUrl: source.cloneUrl, + ...(source.ref !== undefined ? { ref: source.ref } : {}), + commit, + installedAt: timestamp, + updatedAt: timestamp, + }); + context.onTransactionStep?.("recorded"); + promotion.commit(); + } catch (error) { + promotion.rollback(); + writeInstallRecords(context.installedRoot, previousRecords); + throw error; + } return { name: source.name, directory, commit, version: readInstalledVersion(directory), + packages, ...(dependencyWarning !== undefined ? { dependencyWarning } : {}), }; } @@ -340,6 +421,10 @@ export function updateExtension( name, }; const directory = join(context.installedRoot, name); + // Records created before package identity was persisted must derive their + // previous IDs from the checkout, not from the install directory name. The + // latter could lose a denial when a manifest-owned identity is renamed. + const previousPackageIds = resolvePreviousPackageIds(record, name, directory); context.log(`checking ${record.cloneUrl}${record.ref ? ` @ ${record.ref}` : ""}…`); const { stagingDir, commit } = stageClone(context, source); @@ -353,16 +438,64 @@ export function updateExtension( previousCommit: record.commit, changed: false, version: readInstalledVersion(directory), + packages: describeExtensionPackages(directory), }; } const dependencyWarning = prepareStagedDependencies(context, stagingDir); - promoteStagedClone(stagingDir, directory); - saveRecord(context, name, { - ...record, - commit, - updatedAt: (context.now?.() ?? new Date()).toISOString(), - }); + let packages: ReturnType; + let nextPackageIds: string[]; + try { + packages = describeExtensionPackages(stagingDir); + nextPackageIds = packages.map((entry) => entry.id); + assertPackageIdsAvailable(context, name, nextPackageIds); + } catch (error) { + rmSync(stagingDir, { recursive: true, force: true }); + throw error; + } + const previousActivations = readExtensionPackageActivations(context.env); + const migration = planExtensionPackageActivationMigration( + previousPackageIds, + nextPackageIds, + previousActivations, + ); + if (!migration) { + rmSync(stagingDir, { recursive: true, force: true }); + throw new HunkUserError( + "The update would add or ambiguously replace package identities while an affected package is disabled.", + [ + "Enable the affected package before updating, then disable the intended new package identities afterward.", + ], + ); + } + + const previousRecords = readInstallRecords(context.installedRoot); + const promotion = promoteStagedClone(stagingDir, directory); + try { + context.onTransactionStep?.("promoted"); + saveRecord(context, name, { + ...record, + packageIds: nextPackageIds, + commit, + updatedAt: (context.now?.() ?? new Date()).toISOString(), + }); + context.onTransactionStep?.("recorded"); + if ( + migration.changed && + !writeExtensionPackageActivations(migration.activations, context.env) + ) { + throw new HunkUserError("Could not preserve extension package activation."); + } + context.onTransactionStep?.("activated"); + promotion.commit(); + } catch (error) { + promotion.rollback(); + // Records and activation are separate state files; restore both snapshots + // before surfacing the failed update so disk and metadata remain aligned. + writeInstallRecords(context.installedRoot, previousRecords); + if (migration.changed) writeExtensionPackageActivations(previousActivations, context.env); + throw error; + } return { name, @@ -371,6 +504,7 @@ export function updateExtension( previousCommit: record.commit, changed: true, version: readInstalledVersion(directory), + packages, ...(dependencyWarning !== undefined ? { dependencyWarning } : {}), }; } @@ -400,11 +534,15 @@ export function listExtensions(context: ExtensionManageContext): ExtensionInstal const directory = join(context.installedRoot, name); const present = existsSync(directory); const version = present ? readInstalledVersion(directory) : undefined; + const packages = present + ? describeExtensionPackages(directory) + : (record.packageIds ?? [name]).map((id) => ({ id, entries: [] })); return { name, record, directory, present, + packages, ...(version !== undefined ? { version } : {}), }; }); diff --git a/src/extensions/manage/records.ts b/packages/hunk/src/extensions/manage/records.ts similarity index 90% rename from src/extensions/manage/records.ts rename to packages/hunk/src/extensions/manage/records.ts index 015bc2990..09aaa56a5 100644 --- a/src/extensions/manage/records.ts +++ b/packages/hunk/src/extensions/manage/records.ts @@ -9,6 +9,8 @@ import { readAppStateRecord, writeAppStateRecord } from "../../core/process/appS * recorded names, so a folder the user copied in by hand is never touched. */ export interface ExtensionInstallRecord { + /** Stable package identities exposed by this install, in manifest/container order. */ + packageIds?: string[]; /** The install source exactly as the user typed it. */ source: string; /** URL (or local path) `git clone` was run with. */ @@ -51,6 +53,10 @@ function normalizeRecord(value: unknown): ExtensionInstallRecord | undefined { } return { + ...(Array.isArray(record.packageIds) && + record.packageIds.every((id): id is string => typeof id === "string" && id.length > 0) + ? { packageIds: [...new Set(record.packageIds)] } + : {}), source: record.source, cloneUrl: record.cloneUrl, ...(typeof record.ref === "string" ? { ref: record.ref } : {}), diff --git a/src/extensions/manage/source.test.ts b/packages/hunk/src/extensions/manage/source.test.ts similarity index 100% rename from src/extensions/manage/source.test.ts rename to packages/hunk/src/extensions/manage/source.test.ts diff --git a/src/extensions/manage/source.ts b/packages/hunk/src/extensions/manage/source.ts similarity index 100% rename from src/extensions/manage/source.ts rename to packages/hunk/src/extensions/manage/source.ts diff --git a/src/extensions/notifications.test.ts b/packages/hunk/src/extensions/notifications.test.ts similarity index 100% rename from src/extensions/notifications.test.ts rename to packages/hunk/src/extensions/notifications.test.ts diff --git a/src/extensions/notifications.ts b/packages/hunk/src/extensions/notifications.ts similarity index 100% rename from src/extensions/notifications.ts rename to packages/hunk/src/extensions/notifications.ts diff --git a/packages/hunk/src/extensions/packageIdentity.test.ts b/packages/hunk/src/extensions/packageIdentity.test.ts new file mode 100644 index 000000000..764071e31 --- /dev/null +++ b/packages/hunk/src/extensions/packageIdentity.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { + normalizeExtensionPackageId, + normalizeFallbackExtensionPackageId, + sanitizeExtensionPackageDisplay, +} from "./packageIdentity"; + +describe("extension package identity", () => { + test("accepts bounded package ids including npm scopes", () => { + expect(normalizeExtensionPackageId("hunk-git")).toBe("hunk-git"); + expect(normalizeExtensionPackageId("@acme/review-tools")).toBe("@acme/review-tools"); + expect(normalizeExtensionPackageId(" Upper ")).toBeUndefined(); + expect(normalizeExtensionPackageId("bad/id/extra")).toBeUndefined(); + expect(normalizeExtensionPackageId("a".repeat(129))).toBeUndefined(); + }); + + test("canonicalizes legacy folder names into manageable package ids", () => { + expect(normalizeFallbackExtensionPackageId("Upper_Name")).toBe("upper_name"); + expect(normalizeFallbackExtensionPackageId(" invalid folder!? ")).toBe("invalid-folder-"); + expect(normalizeFallbackExtensionPackageId("💥")).toBe("extension"); + }); + + test("removes terminal controls and bounds display metadata", () => { + expect(sanitizeExtensionPackageDisplay("safe\u001b[31m\nname")).toBe("safe[31mname"); + expect(sanitizeExtensionPackageDisplay("x".repeat(200))).toHaveLength(160); + }); +}); diff --git a/packages/hunk/src/extensions/packageIdentity.ts b/packages/hunk/src/extensions/packageIdentity.ts new file mode 100644 index 000000000..c99707409 --- /dev/null +++ b/packages/hunk/src/extensions/packageIdentity.ts @@ -0,0 +1,38 @@ +const EXTENSION_PACKAGE_ID_MAX_LENGTH = 128; +const EXTENSION_PACKAGE_METADATA_MAX_LENGTH = 160; +const EXTENSION_PACKAGE_ID_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const TERMINAL_CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f]/g; +const FALLBACK_PACKAGE_SEPARATOR_PATTERN = /[^a-z0-9._-]+/g; +const FALLBACK_PACKAGE_LEADING_PATTERN = /^[^a-z0-9]+/; + +/** Validate one manifest-owned package identity before it reaches activation state. */ +export function normalizeExtensionPackageId(value: unknown) { + if (typeof value !== "string") return undefined; + const id = value.trim(); + if ( + id.length === 0 || + id.length > EXTENSION_PACKAGE_ID_MAX_LENGTH || + !EXTENSION_PACKAGE_ID_PATTERN.test(id) + ) { + return undefined; + } + return id; +} + +/** Canonicalize a legacy folder/install name into a manageable package identity. */ +export function normalizeFallbackExtensionPackageId(value: string) { + const normalized = value + .trim() + .toLowerCase() + .replace(FALLBACK_PACKAGE_SEPARATOR_PATTERN, "-") + .replace(FALLBACK_PACKAGE_LEADING_PATTERN, "") + .slice(0, EXTENSION_PACKAGE_ID_MAX_LENGTH); + return normalizeExtensionPackageId(normalized) ?? "extension"; +} + +/** Bound extension metadata and remove terminal controls before display. */ +export function sanitizeExtensionPackageDisplay(value: string) { + return value + .replace(TERMINAL_CONTROL_PATTERN, "") + .slice(0, EXTENSION_PACKAGE_METADATA_MAX_LENGTH); +} diff --git a/src/extensions/panes.ts b/packages/hunk/src/extensions/panes.ts similarity index 100% rename from src/extensions/panes.ts rename to packages/hunk/src/extensions/panes.ts diff --git a/src/extensions/publicApiRobustness.test.ts b/packages/hunk/src/extensions/publicApiRobustness.test.ts similarity index 99% rename from src/extensions/publicApiRobustness.test.ts rename to packages/hunk/src/extensions/publicApiRobustness.test.ts index 7ff7b09ce..961e51643 100644 --- a/src/extensions/publicApiRobustness.test.ts +++ b/packages/hunk/src/extensions/publicApiRobustness.test.ts @@ -3,7 +3,7 @@ import { collectSessionCustomThemes } from "../core/theme/customThemes"; import type { Changeset } from "../core/changeset/model"; import { detectVcs, extendVcsCatalog } from "../core/vcs"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { applyExtensionChangesetTransforms, applyExtensionFileLanguages, diff --git a/src/extensions/reviewSnapshot.test.ts b/packages/hunk/src/extensions/reviewSnapshot.test.ts similarity index 99% rename from src/extensions/reviewSnapshot.test.ts rename to packages/hunk/src/extensions/reviewSnapshot.test.ts index a698069a0..512ea45e0 100644 --- a/src/extensions/reviewSnapshot.test.ts +++ b/packages/hunk/src/extensions/reviewSnapshot.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewState, createTestStoredNote, -} from "../../test/helpers/review-store-helpers"; +} from "../../../../test/helpers/review-store-helpers"; import { buildExtensionReviewSnapshot, diffExtensionReviewNotes, diff --git a/src/extensions/reviewSnapshot.ts b/packages/hunk/src/extensions/reviewSnapshot.ts similarity index 100% rename from src/extensions/reviewSnapshot.ts rename to packages/hunk/src/extensions/reviewSnapshot.ts diff --git a/src/extensions/runExtension.test.ts b/packages/hunk/src/extensions/runExtension.test.ts similarity index 100% rename from src/extensions/runExtension.test.ts rename to packages/hunk/src/extensions/runExtension.test.ts diff --git a/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts similarity index 98% rename from src/extensions/runExtension.ts rename to packages/hunk/src/extensions/runExtension.ts index 6345da02b..c7ef70025 100644 --- a/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -370,6 +370,7 @@ export function createExtensionApi( }); }, emit(event: string, payload: unknown) { + assertOpen("events.emit"); assertNonEmptyString(event, "events.emit requires a non-empty event name."); if (registry.emitCustomEvent) { registry.emitCustomEvent(event, payload); @@ -688,6 +689,8 @@ export interface RunExtensionFactoryOptions { /** Failure sink; a factory that throws appends here instead of propagating. */ issues: ExtensionLoadIssue[]; factory: ExtensionFactory; + /** Reject promises before they can mutate a registry that must finish synchronously. */ + synchronous?: boolean; /** The extension's own `[extension.]` table, empty when it has none. */ config?: Record; } @@ -711,6 +714,7 @@ export function runExtensionFactory({ registry, issues, factory, + synchronous = false, config = {}, }: RunExtensionFactoryOptions): void | Promise { const snapshot = snapshotRegistry(registry); @@ -751,6 +755,15 @@ export function runExtensionFactory({ return; } + if (synchronous) { + // Attach a rejection handler before sealing so hostile or rejected promises + // cannot become unhandled after the bundled catalog has been returned. + void Promise.resolve(pending).catch(() => undefined); + seal(); + fail(new Error("Bundled extension factories must be synchronous.")); + return; + } + // Promise assimilation turns a throwing or otherwise hostile `then` access // into the ordinary rejection path instead of leaking out of the load pass. return Promise.resolve(pending).then( diff --git a/src/extensions/startup.test.ts b/packages/hunk/src/extensions/startup.test.ts similarity index 86% rename from src/extensions/startup.test.ts rename to packages/hunk/src/extensions/startup.test.ts index 1e12af04c..38704ac59 100644 --- a/src/extensions/startup.test.ts +++ b/packages/hunk/src/extensions/startup.test.ts @@ -55,6 +55,33 @@ describe("extension startup", () => { expect(result.pendingTrustRepoRoot).toBeUndefined(); }); + test("filters a disabled package before importing any entry", async () => { + const home = createTempDir("hunk-startup-package-disabled-"); + const packageRoot = join(home, "hunk", "extensions", "review-tools"); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ + name: "@acme/review-tools", + hunk: { extensions: ["./alpha.ts", "./beta.ts"] }, + }), + ); + writeFileSync(join(packageRoot, "alpha.ts"), "throw new Error('alpha imported');\n"); + writeFileSync(join(packageRoot, "beta.ts"), "throw new Error('beta imported');\n"); + + const result = await loadStartupExtensions({ + extensions: createExtensionsConfig(), + cwd: home, + env: { XDG_CONFIG_HOME: home } as NodeJS.ProcessEnv, + disabledPackageIds: new Set(["@acme/review-tools"]), + hostOverrides: { repoRoot: undefined }, + }); + + expect(result.loaded).toEqual([]); + expect(result.issues).toEqual([]); + expect(result.loadState.candidates).toEqual([]); + }); + test("discovers and loads global extensions with their config tables", async () => { const home = createTempDir("hunk-startup-global-"); writeGlobalExtension( @@ -77,6 +104,10 @@ describe("extension startup", () => { expect(result.issues).toEqual([]); expect(result.loaded.map((entry) => entry.origin)).toEqual(["global"]); + expect(result.loaded[0]?.package).toEqual({ + id: "themed", + root: join(home, "hunk", "extensions"), + }); expect(result.registry.themes.map((entry) => entry.theme.id)).toEqual(["midnight"]); }); diff --git a/src/extensions/startup.ts b/packages/hunk/src/extensions/startup.ts similarity index 95% rename from src/extensions/startup.ts rename to packages/hunk/src/extensions/startup.ts index c46d8e19b..195ff7191 100644 --- a/src/extensions/startup.ts +++ b/packages/hunk/src/extensions/startup.ts @@ -6,6 +6,7 @@ import { discoverExtensions } from "./discovery"; import { retireExtensionLoadResult } from "./events"; import { loadExtensions, type LoadExtensionsOptions } from "./host"; import { createExtensionNotificationHub, type ExtensionNotificationHub } from "./notifications"; +import { readDisabledExtensionPackageIds } from "./manage/activation"; import { createEmptyExtensionLoadResult, type ExtensionCandidate, @@ -27,6 +28,8 @@ export interface LoadStartupExtensionsOptions { projectRoot?: string; /** Product-owned ids user extension modules may not claim. */ reservedExtensionIds?: ReadonlySet; + /** Test/caller override for user-owned package activation preferences. */ + disabledPackageIds?: ReadonlySet; /** * Sink extension `ctx.notify` calls land in. Pass the hub from an earlier * pass when reloading extensions so the mounted UI keeps receiving them. @@ -101,6 +104,7 @@ export async function loadStartupExtensions( flagPaths: options.cliExtensionPaths, configPaths: options.extensions.paths, repoConfigPaths: options.extensions.repoPaths, + disabledPackageIds: options.disabledPackageIds ?? readDisabledExtensionPackageIds(env), }); if (candidates.length === 0) { diff --git a/src/extensions/trust.test.ts b/packages/hunk/src/extensions/trust.test.ts similarity index 100% rename from src/extensions/trust.test.ts rename to packages/hunk/src/extensions/trust.test.ts diff --git a/src/extensions/trust.ts b/packages/hunk/src/extensions/trust.ts similarity index 100% rename from src/extensions/trust.ts rename to packages/hunk/src/extensions/trust.ts diff --git a/src/extensions/types.ts b/packages/hunk/src/extensions/types.ts similarity index 95% rename from src/extensions/types.ts rename to packages/hunk/src/extensions/types.ts index ca5d5f312..1f5359f3b 100644 --- a/src/extensions/types.ts +++ b/packages/hunk/src/extensions/types.ts @@ -92,11 +92,23 @@ export type ExtensionOrigin = "bundled" | "global" | "repo" | "config" | "flag"; /** Sink the host routes `ctx.notify` through; the UI supplies a real toast later. */ export type ExtensionNotifySink = (message: string, type: ExtensionNotifyType) => void; +/** Stable package identity shared by every entry activated from one package root. */ +export interface ExtensionPackageIdentity { + /** Manifest package id/name, or a legacy folder/file id when no manifest names the package. */ + id: string; + /** Absolute package root for disk-loaded packages; absent for statically bundled packages. */ + root?: string; + name?: string; + version?: string; +} + /** Identity of one loaded extension, carried on everything it registered. */ export interface ExtensionMetadata { id: string; sourcePath: string; origin: ExtensionOrigin; + /** Package ownership is distinct from the entry id and supports multi-entry packages. */ + package?: ExtensionPackageIdentity; } /** One extension entry file discovery found, before it is imported. */ @@ -105,6 +117,7 @@ export interface ExtensionCandidate { /** Absolute, resolved path to the entry file. */ path: string; origin: ExtensionOrigin; + package?: ExtensionPackageIdentity; /** * Minimum extension API version the folder's manifest requires * (`"hunk": { "apiVersion": N }`). The host refuses the candidate before diff --git a/src/extensions/vcsPatchResult.test.ts b/packages/hunk/src/extensions/vcsPatchResult.test.ts similarity index 100% rename from src/extensions/vcsPatchResult.test.ts rename to packages/hunk/src/extensions/vcsPatchResult.test.ts diff --git a/src/extensions/vcsPatchResult.ts b/packages/hunk/src/extensions/vcsPatchResult.ts similarity index 100% rename from src/extensions/vcsPatchResult.ts rename to packages/hunk/src/extensions/vcsPatchResult.ts diff --git a/src/highlightWorkerClient.ts b/packages/hunk/src/highlightWorkerClient.ts similarity index 100% rename from src/highlightWorkerClient.ts rename to packages/hunk/src/highlightWorkerClient.ts diff --git a/src/highlightWorkerEntry.ts b/packages/hunk/src/highlightWorkerEntry.ts similarity index 100% rename from src/highlightWorkerEntry.ts rename to packages/hunk/src/highlightWorkerEntry.ts diff --git a/src/hunk-review/skillDocument.test.ts b/packages/hunk/src/hunk-review/skillDocument.test.ts similarity index 90% rename from src/hunk-review/skillDocument.test.ts rename to packages/hunk/src/hunk-review/skillDocument.test.ts index e45f4175e..ac5eded55 100644 --- a/src/hunk-review/skillDocument.test.ts +++ b/packages/hunk/src/hunk-review/skillDocument.test.ts @@ -9,7 +9,15 @@ import { import { renderHunkReviewSkill } from "./skillDocument"; const SKILL_PATH = join(import.meta.dir, "..", "..", "skills", "hunk-review", "SKILL.md"); -const AGENT_WORKFLOWS_PATH = join(import.meta.dir, "..", "..", "docs", "agent-workflows.md"); +const AGENT_WORKFLOWS_PATH = join( + import.meta.dir, + "..", + "..", + "..", + "..", + "docs", + "agent-workflows.md", +); /** Every flag the agent-facing docs may reference: the session surface plus auxiliary options. */ const DOCUMENTED_AGENT_FLAGS = new Set([ @@ -32,7 +40,7 @@ describe("hunk-review skill document", () => { if (checkedIn !== rendered) { throw new Error( - "skills/hunk-review/SKILL.md is out of date. Run `bun run generate:skill` and commit the result.", + "packages/hunk/skills/hunk-review/SKILL.md is out of date. Run `bun run generate:skill` and commit the result.", ); } diff --git a/src/hunk-review/skillDocument.ts b/packages/hunk/src/hunk-review/skillDocument.ts similarity index 99% rename from src/hunk-review/skillDocument.ts rename to packages/hunk/src/hunk-review/skillDocument.ts index 79694d612..8a3983f07 100644 --- a/src/hunk-review/skillDocument.ts +++ b/packages/hunk/src/hunk-review/skillDocument.ts @@ -2,7 +2,7 @@ import { AGENT_ERROR_DOCS } from "../session/agent/errors"; import { SESSION_AGENT_COMMANDS, type AgentCommandSpec } from "../session/agent/surface"; /** - * Deterministic renderer for `skills/hunk-review/SKILL.md`. + * Deterministic renderer for `packages/hunk/skills/hunk-review/SKILL.md`. * * The command reference blocks and the "Common errors" section are derived from * `agentSurface.ts` and `agentErrors.ts` so the skill can never disagree with the parser or the diff --git a/src/lib/commandKeys.test.ts b/packages/hunk/src/lib/commandKeys.test.ts similarity index 100% rename from src/lib/commandKeys.test.ts rename to packages/hunk/src/lib/commandKeys.test.ts diff --git a/src/lib/commandKeys.ts b/packages/hunk/src/lib/commandKeys.ts similarity index 100% rename from src/lib/commandKeys.ts rename to packages/hunk/src/lib/commandKeys.ts diff --git a/src/lib/patchPath.ts b/packages/hunk/src/lib/patchPath.ts similarity index 100% rename from src/lib/patchPath.ts rename to packages/hunk/src/lib/patchPath.ts diff --git a/src/lib/terminalText.test.ts b/packages/hunk/src/lib/terminalText.test.ts similarity index 100% rename from src/lib/terminalText.test.ts rename to packages/hunk/src/lib/terminalText.test.ts diff --git a/src/lib/terminalText.ts b/packages/hunk/src/lib/terminalText.ts similarity index 100% rename from src/lib/terminalText.ts rename to packages/hunk/src/lib/terminalText.ts diff --git a/src/main.tsx b/packages/hunk/src/main.tsx similarity index 100% rename from src/main.tsx rename to packages/hunk/src/main.tsx diff --git a/src/opentui/HunkDiffBody.tsx b/packages/hunk/src/opentui/HunkDiffBody.tsx similarity index 100% rename from src/opentui/HunkDiffBody.tsx rename to packages/hunk/src/opentui/HunkDiffBody.tsx diff --git a/src/opentui/HunkDiffFileHeader.tsx b/packages/hunk/src/opentui/HunkDiffFileHeader.tsx similarity index 100% rename from src/opentui/HunkDiffFileHeader.tsx rename to packages/hunk/src/opentui/HunkDiffFileHeader.tsx diff --git a/src/opentui/HunkDiffView.test.tsx b/packages/hunk/src/opentui/HunkDiffView.test.tsx similarity index 100% rename from src/opentui/HunkDiffView.test.tsx rename to packages/hunk/src/opentui/HunkDiffView.test.tsx diff --git a/src/opentui/HunkDiffView.tsx b/packages/hunk/src/opentui/HunkDiffView.tsx similarity index 100% rename from src/opentui/HunkDiffView.tsx rename to packages/hunk/src/opentui/HunkDiffView.tsx diff --git a/src/opentui/HunkFileNav.tsx b/packages/hunk/src/opentui/HunkFileNav.tsx similarity index 100% rename from src/opentui/HunkFileNav.tsx rename to packages/hunk/src/opentui/HunkFileNav.tsx diff --git a/src/opentui/HunkReviewStream.tsx b/packages/hunk/src/opentui/HunkReviewStream.tsx similarity index 100% rename from src/opentui/HunkReviewStream.tsx rename to packages/hunk/src/opentui/HunkReviewStream.tsx diff --git a/src/opentui/index.ts b/packages/hunk/src/opentui/index.ts similarity index 100% rename from src/opentui/index.ts rename to packages/hunk/src/opentui/index.ts diff --git a/src/opentui/model.ts b/packages/hunk/src/opentui/model.ts similarity index 88% rename from src/opentui/model.ts rename to packages/hunk/src/opentui/model.ts index 533c1ad34..0073ce809 100644 --- a/src/opentui/model.ts +++ b/packages/hunk/src/opentui/model.ts @@ -1,16 +1,18 @@ -import { parsePatchFiles } from "@pierre/diffs"; +import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"; import { patchLooksBinary } from "../core/changeset/binary"; import { normalizeDiffMetadataPaths, normalizeDiffPath } from "../core/changeset/diffPaths"; import { countDiffStats } from "../core/changeset/diffFile"; import { splitPatchIntoFileChunks, findPatchChunk } from "../core/patch/chunks"; import { sanitizePatch } from "../core/patch/sanitize"; import type { DiffFile } from "../core/changeset/model"; -import type { HunkDiffFile, HunkDiffFileInput } from "./types"; +import type { HunkDiffFile, HunkDiffFileInput, HunkDiffStats } from "./types"; const NORMALIZED_HUNK_DIFF_FILES = new WeakSet(); /** Count visible additions and deletions from Pierre metadata. */ -export const countHunkDiffStats = countDiffStats; +export function countHunkDiffStats(metadata: FileDiffMetadata): HunkDiffStats { + return countDiffStats(metadata); +} /** Build one public file while optionally preserving paths decoded exactly from Git quoting. */ function buildHunkDiffFile(input: HunkDiffFileInput, pathsAreExact: boolean): HunkDiffFile { @@ -48,7 +50,7 @@ function resolveHunkDiffFile(input: HunkDiffFileInput) { return createHunkDiffFile(input); } -/** Adapt the public OpenTUI file shape into Hunk's internal review file model. */ +/** @internal Adapt the public OpenTUI file shape into Hunk's internal review file model. */ export function toInternalDiffFile(diff: HunkDiffFileInput): DiffFile { const normalized = resolveHunkDiffFile(diff); const patch = normalized.patch ?? ""; @@ -93,7 +95,7 @@ export function createHunkDiffFilesFromPatch(patchText: string, sourceId = "patc }); } -/** Adapt a list of public OpenTUI files into Hunk's internal review file model. */ +/** @internal Adapt a list of public OpenTUI files into Hunk's internal review file model. */ export function toInternalDiffFiles(files: HunkDiffFileInput[]) { return files.map(toInternalDiffFile); } diff --git a/src/opentui/themes.ts b/packages/hunk/src/opentui/themes.ts similarity index 100% rename from src/opentui/themes.ts rename to packages/hunk/src/opentui/themes.ts diff --git a/src/opentui/types.ts b/packages/hunk/src/opentui/types.ts similarity index 100% rename from src/opentui/types.ts rename to packages/hunk/src/opentui/types.ts diff --git a/src/session/agent/cliClient.test.ts b/packages/hunk/src/session/agent/cliClient.test.ts similarity index 99% rename from src/session/agent/cliClient.test.ts rename to packages/hunk/src/session/agent/cliClient.test.ts index b58b0a9f9..7598c5a59 100644 --- a/src/session/agent/cliClient.test.ts +++ b/packages/hunk/src/session/agent/cliClient.test.ts @@ -8,7 +8,7 @@ import { createTestSessionReviewFile, createTestSessionReviewHunk, createTestSessionSnapshot, -} from "../../../test/helpers/session-daemon-fixtures"; +} from "../../../../../test/helpers/session-daemon-fixtures"; import type { SessionSelectorInput } from "../../core/run/commandInputs"; import { HUNK_SESSION_API_PATH, diff --git a/src/session/agent/cliClient.ts b/packages/hunk/src/session/agent/cliClient.ts similarity index 100% rename from src/session/agent/cliClient.ts rename to packages/hunk/src/session/agent/cliClient.ts diff --git a/src/session/agent/commands.daemon.test.ts b/packages/hunk/src/session/agent/commands.daemon.test.ts similarity index 98% rename from src/session/agent/commands.daemon.test.ts rename to packages/hunk/src/session/agent/commands.daemon.test.ts index dbb4c7f62..93f59b68d 100644 --- a/src/session/agent/commands.daemon.test.ts +++ b/packages/hunk/src/session/agent/commands.daemon.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createServer } from "node:net"; import { platform } from "node:os"; import type { SessionCommandInput } from "../../core/run/commandInputs"; -import { createTestListedSession } from "../../../test/helpers/session-daemon-fixtures"; +import { createTestListedSession } from "../../../../../test/helpers/session-daemon-fixtures"; import { runSessionCommand, setSessionCommandTestHooks, diff --git a/src/session/agent/commands.test.ts b/packages/hunk/src/session/agent/commands.test.ts similarity index 99% rename from src/session/agent/commands.test.ts rename to packages/hunk/src/session/agent/commands.test.ts index 8b7d9e7b7..8470c8f5d 100644 --- a/src/session/agent/commands.test.ts +++ b/packages/hunk/src/session/agent/commands.test.ts @@ -6,7 +6,7 @@ import { createTestSessionFileSummary, createTestSessionReview as buildTestSessionReview, createTestSessionSnapshot, -} from "../../../test/helpers/session-daemon-fixtures"; +} from "../../../../../test/helpers/session-daemon-fixtures"; import type { SessionCommandInput, SessionSelectorInput } from "../../core/run/commandInputs"; import { runSessionCommand, diff --git a/src/session/agent/commands.ts b/packages/hunk/src/session/agent/commands.ts similarity index 100% rename from src/session/agent/commands.ts rename to packages/hunk/src/session/agent/commands.ts diff --git a/src/session/agent/errors.test.ts b/packages/hunk/src/session/agent/errors.test.ts similarity index 100% rename from src/session/agent/errors.test.ts rename to packages/hunk/src/session/agent/errors.test.ts diff --git a/src/session/agent/errors.ts b/packages/hunk/src/session/agent/errors.ts similarity index 97% rename from src/session/agent/errors.ts rename to packages/hunk/src/session/agent/errors.ts index dcba9dad4..b8db1f392 100644 --- a/src/session/agent/errors.ts +++ b/packages/hunk/src/session/agent/errors.ts @@ -1,7 +1,7 @@ /** * Agent-facing error messages for the `hunk session` surface. * - * Every message quoted by the generated `skills/hunk-review/SKILL.md` "Common errors" section is + * Every message quoted by the generated `packages/hunk/skills/hunk-review/SKILL.md` "Common errors" section is * defined (or contract-tested) here, so the skill can never quote wording the CLI no longer * throws. Throw sites import these builders instead of repeating string literals. */ diff --git a/src/session/agent/surface.test.ts b/packages/hunk/src/session/agent/surface.test.ts similarity index 100% rename from src/session/agent/surface.test.ts rename to packages/hunk/src/session/agent/surface.test.ts diff --git a/src/session/agent/surface.ts b/packages/hunk/src/session/agent/surface.ts similarity index 99% rename from src/session/agent/surface.ts rename to packages/hunk/src/session/agent/surface.ts index 80007bc85..3b43fddb9 100644 --- a/src/session/agent/surface.ts +++ b/packages/hunk/src/session/agent/surface.ts @@ -5,7 +5,7 @@ import type { SessionDaemonAction } from "../protocol"; * * This module is the single source of truth for what agents can invoke: the Commander commands in * `src/app/cli.ts`, the `hunk session --help` usage text, and the generated - * `skills/hunk-review/SKILL.md` reference sections are all derived from these specs, so the parser + * `packages/hunk/skills/hunk-review/SKILL.md` reference sections are all derived from these specs, so the parser * and the docs cannot drift apart. Keep it pure data with no runtime dependencies. */ diff --git a/src/session/broker/appContract.ts b/packages/hunk/src/session/broker/appContract.ts similarity index 100% rename from src/session/broker/appContract.ts rename to packages/hunk/src/session/broker/appContract.ts diff --git a/src/session/broker/brokerClient.test.ts b/packages/hunk/src/session/broker/brokerClient.test.ts similarity index 99% rename from src/session/broker/brokerClient.test.ts rename to packages/hunk/src/session/broker/brokerClient.test.ts index e6a6a0264..fb3c6aee3 100644 --- a/src/session/broker/brokerClient.test.ts +++ b/packages/hunk/src/session/broker/brokerClient.test.ts @@ -7,7 +7,7 @@ import { createTestSessionRegistration, createTestSessionReviewFile, createTestSessionSnapshot, -} from "../../../test/helpers/session-daemon-fixtures"; +} from "../../../../../test/helpers/session-daemon-fixtures"; import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "../protocol"; import { SESSION_BROKER_LIFECYCLE_DEFECT_MESSAGE, @@ -27,7 +27,7 @@ import { serveSessionBrokerDaemon as serveHunkSessionBrokerDaemon } from "./brok import { createHttpHunkSessionCliClient } from "../agent/cliClient"; import { HUNK_DAEMON_UPGRADE_WAIT_MESSAGE } from "../client/capabilities"; import { resolveSessionBrokerRuntimePaths } from "./brokerLauncher"; -import { DeterministicLifecycleClockTest } from "../../../test/helpers/lifecycleClockTest"; +import { DeterministicLifecycleClockTest } from "../../../../../test/helpers/lifecycleClockTest"; const originalHost = process.env.HUNK_MCP_HOST; const originalPort = process.env.HUNK_MCP_PORT; diff --git a/src/session/broker/brokerClient.ts b/packages/hunk/src/session/broker/brokerClient.ts similarity index 100% rename from src/session/broker/brokerClient.ts rename to packages/hunk/src/session/broker/brokerClient.ts diff --git a/src/session/broker/brokerConfig.test.ts b/packages/hunk/src/session/broker/brokerConfig.test.ts similarity index 100% rename from src/session/broker/brokerConfig.test.ts rename to packages/hunk/src/session/broker/brokerConfig.test.ts diff --git a/src/session/broker/brokerConfig.ts b/packages/hunk/src/session/broker/brokerConfig.ts similarity index 100% rename from src/session/broker/brokerConfig.ts rename to packages/hunk/src/session/broker/brokerConfig.ts diff --git a/src/session/broker/brokerLauncher.test.ts b/packages/hunk/src/session/broker/brokerLauncher.test.ts similarity index 99% rename from src/session/broker/brokerLauncher.test.ts rename to packages/hunk/src/session/broker/brokerLauncher.test.ts index 9123d78d7..bb9eeaf55 100644 --- a/src/session/broker/brokerLauncher.test.ts +++ b/packages/hunk/src/session/broker/brokerLauncher.test.ts @@ -11,7 +11,7 @@ import { resolveDaemonLaunchCommand, resolveSessionBrokerRuntimePaths, } from "./brokerLauncher"; -import { DeterministicLifecycleClockTest } from "../../../test/helpers/lifecycleClockTest"; +import { DeterministicLifecycleClockTest } from "../../../../../test/helpers/lifecycleClockTest"; const tempDirs: string[] = []; const testConfig = { diff --git a/src/session/broker/brokerLauncher.ts b/packages/hunk/src/session/broker/brokerLauncher.ts similarity index 100% rename from src/session/broker/brokerLauncher.ts rename to packages/hunk/src/session/broker/brokerLauncher.ts diff --git a/src/session/broker/brokerServer.helpers.test.ts b/packages/hunk/src/session/broker/brokerServer.helpers.test.ts similarity index 99% rename from src/session/broker/brokerServer.helpers.test.ts rename to packages/hunk/src/session/broker/brokerServer.helpers.test.ts index 9a5d3f7f1..03e495ccc 100644 --- a/src/session/broker/brokerServer.helpers.test.ts +++ b/packages/hunk/src/session/broker/brokerServer.helpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestListedSession } from "../../../test/helpers/session-daemon-fixtures"; +import { createTestListedSession } from "../../../../../test/helpers/session-daemon-fixtures"; import type { HunkSessionBrokerState } from "./state"; import type { SessionDaemonRequest } from "../protocol"; import { diff --git a/src/session/broker/brokerServer.test.ts b/packages/hunk/src/session/broker/brokerServer.test.ts similarity index 99% rename from src/session/broker/brokerServer.test.ts rename to packages/hunk/src/session/broker/brokerServer.test.ts index d1ae3fb53..bd9273d40 100644 --- a/src/session/broker/brokerServer.test.ts +++ b/packages/hunk/src/session/broker/brokerServer.test.ts @@ -5,7 +5,7 @@ import { platform } from "node:os"; import { createTestSessionRegistration, createTestSessionSnapshot, -} from "../../../test/helpers/session-daemon-fixtures"; +} from "../../../../../test/helpers/session-daemon-fixtures"; import { SessionBrokerState } from "@hunk/session-broker-core"; import { SessionBrokerCallerClient, diff --git a/src/session/broker/brokerServer.ts b/packages/hunk/src/session/broker/brokerServer.ts similarity index 100% rename from src/session/broker/brokerServer.ts rename to packages/hunk/src/session/broker/brokerServer.ts diff --git a/src/session/broker/browserReviewServer.integration.test.ts b/packages/hunk/src/session/broker/browserReviewServer.integration.test.ts similarity index 99% rename from src/session/broker/browserReviewServer.integration.test.ts rename to packages/hunk/src/session/broker/browserReviewServer.integration.test.ts index 09458693d..f3449bb66 100644 --- a/src/session/broker/browserReviewServer.integration.test.ts +++ b/packages/hunk/src/session/broker/browserReviewServer.integration.test.ts @@ -17,8 +17,8 @@ import { REVIEW_PATCH_CONTENT_TYPE, reviewResourceId } from "../../core/review/r import { connectReviewSession, createTestPatchFile, -} from "../../../test/helpers/review-session-harness"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +} from "../../../../../test/helpers/review-session-harness"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { parseReviewEventBegin, parseReviewEventChunk, diff --git a/src/session/broker/browserReviewServer.ts b/packages/hunk/src/session/broker/browserReviewServer.ts similarity index 100% rename from src/session/broker/browserReviewServer.ts rename to packages/hunk/src/session/broker/browserReviewServer.ts diff --git a/src/session/broker/credentials.test.ts b/packages/hunk/src/session/broker/credentials.test.ts similarity index 100% rename from src/session/broker/credentials.test.ts rename to packages/hunk/src/session/broker/credentials.test.ts diff --git a/src/session/broker/credentials.ts b/packages/hunk/src/session/broker/credentials.ts similarity index 100% rename from src/session/broker/credentials.ts rename to packages/hunk/src/session/broker/credentials.ts diff --git a/src/session/broker/lifecycleDefect.test.ts b/packages/hunk/src/session/broker/lifecycleDefect.test.ts similarity index 100% rename from src/session/broker/lifecycleDefect.test.ts rename to packages/hunk/src/session/broker/lifecycleDefect.test.ts diff --git a/src/session/broker/lifecycleDefect.ts b/packages/hunk/src/session/broker/lifecycleDefect.ts similarity index 100% rename from src/session/broker/lifecycleDefect.ts rename to packages/hunk/src/session/broker/lifecycleDefect.ts diff --git a/src/session/broker/projections.test.ts b/packages/hunk/src/session/broker/projections.test.ts similarity index 98% rename from src/session/broker/projections.test.ts rename to packages/hunk/src/session/broker/projections.test.ts index 6c1e16909..1b5dab061 100644 --- a/src/session/broker/projections.test.ts +++ b/packages/hunk/src/session/broker/projections.test.ts @@ -3,7 +3,7 @@ import { createTestSessionLiveComment, createTestSessionRegistration, createTestSessionSnapshot, -} from "../../../test/helpers/session-daemon-fixtures"; +} from "../../../../../test/helpers/session-daemon-fixtures"; import { buildHunkSessionReview, buildListedHunkSession, diff --git a/src/session/broker/projections.ts b/packages/hunk/src/session/broker/projections.ts similarity index 100% rename from src/session/broker/projections.ts rename to packages/hunk/src/session/broker/projections.ts diff --git a/src/session/broker/protocolParsers.test.ts b/packages/hunk/src/session/broker/protocolParsers.test.ts similarity index 100% rename from src/session/broker/protocolParsers.test.ts rename to packages/hunk/src/session/broker/protocolParsers.test.ts diff --git a/src/session/broker/protocolParsers.ts b/packages/hunk/src/session/broker/protocolParsers.ts similarity index 100% rename from src/session/broker/protocolParsers.ts rename to packages/hunk/src/session/broker/protocolParsers.ts diff --git a/src/session/broker/reviewMirror.test.ts b/packages/hunk/src/session/broker/reviewMirror.test.ts similarity index 100% rename from src/session/broker/reviewMirror.test.ts rename to packages/hunk/src/session/broker/reviewMirror.test.ts diff --git a/src/session/broker/reviewMirror.ts b/packages/hunk/src/session/broker/reviewMirror.ts similarity index 100% rename from src/session/broker/reviewMirror.ts rename to packages/hunk/src/session/broker/reviewMirror.ts diff --git a/src/session/broker/reviewResourceCache.test.ts b/packages/hunk/src/session/broker/reviewResourceCache.test.ts similarity index 100% rename from src/session/broker/reviewResourceCache.test.ts rename to packages/hunk/src/session/broker/reviewResourceCache.test.ts diff --git a/src/session/broker/reviewResourceCache.ts b/packages/hunk/src/session/broker/reviewResourceCache.ts similarity index 100% rename from src/session/broker/reviewResourceCache.ts rename to packages/hunk/src/session/broker/reviewResourceCache.ts diff --git a/src/session/broker/reviewResources.integration.test.ts b/packages/hunk/src/session/broker/reviewResources.integration.test.ts similarity index 99% rename from src/session/broker/reviewResources.integration.test.ts rename to packages/hunk/src/session/broker/reviewResources.integration.test.ts index 0d17607e4..8e5b5c81f 100644 --- a/src/session/broker/reviewResources.integration.test.ts +++ b/packages/hunk/src/session/broker/reviewResources.integration.test.ts @@ -11,7 +11,7 @@ import { describe, expect, test } from "bun:test"; import { connectReviewSession, createTestPatchFile, -} from "../../../test/helpers/review-session-harness"; +} from "../../../../../test/helpers/review-session-harness"; import { REVIEW_RESOURCE_CHUNK_BYTES, reviewResourceId } from "../../core/review/resources"; import { createSessionRegistration, diff --git a/src/session/broker/state.ts b/packages/hunk/src/session/broker/state.ts similarity index 100% rename from src/session/broker/state.ts rename to packages/hunk/src/session/broker/state.ts diff --git a/src/session/broker/wire.test.ts b/packages/hunk/src/session/broker/wire.test.ts similarity index 100% rename from src/session/broker/wire.test.ts rename to packages/hunk/src/session/broker/wire.test.ts diff --git a/src/session/broker/wire.ts b/packages/hunk/src/session/broker/wire.ts similarity index 100% rename from src/session/broker/wire.ts rename to packages/hunk/src/session/broker/wire.ts diff --git a/src/session/client/capabilities.test.ts b/packages/hunk/src/session/client/capabilities.test.ts similarity index 100% rename from src/session/client/capabilities.test.ts rename to packages/hunk/src/session/client/capabilities.test.ts diff --git a/src/session/client/capabilities.ts b/packages/hunk/src/session/client/capabilities.ts similarity index 100% rename from src/session/client/capabilities.ts rename to packages/hunk/src/session/client/capabilities.ts diff --git a/src/session/client/daemonHttp.test.ts b/packages/hunk/src/session/client/daemonHttp.test.ts similarity index 100% rename from src/session/client/daemonHttp.test.ts rename to packages/hunk/src/session/client/daemonHttp.test.ts diff --git a/src/session/client/daemonHttp.ts b/packages/hunk/src/session/client/daemonHttp.ts similarity index 100% rename from src/session/client/daemonHttp.ts rename to packages/hunk/src/session/client/daemonHttp.ts diff --git a/src/session/protocol.ts b/packages/hunk/src/session/protocol.ts similarity index 100% rename from src/session/protocol.ts rename to packages/hunk/src/session/protocol.ts diff --git a/src/session/protocolSchemas.test.ts b/packages/hunk/src/session/protocolSchemas.test.ts similarity index 99% rename from src/session/protocolSchemas.test.ts rename to packages/hunk/src/session/protocolSchemas.test.ts index 585279378..a8bb30e6f 100644 --- a/src/session/protocolSchemas.test.ts +++ b/packages/hunk/src/session/protocolSchemas.test.ts @@ -4,7 +4,7 @@ import type { CliInput } from "../core/run/commandInputs"; import { createTestSessionRegistration, createTestSessionSnapshot, -} from "../../test/helpers/session-daemon-fixtures"; +} from "../../../../test/helpers/session-daemon-fixtures"; import { buildListedHunkSession } from "./broker/projections"; import { HUNK_SESSION_API_VERSION, diff --git a/src/session/protocolSchemas.ts b/packages/hunk/src/session/protocolSchemas.ts similarity index 100% rename from src/session/protocolSchemas.ts rename to packages/hunk/src/session/protocolSchemas.ts diff --git a/src/session/reviewErrorCatalog.test.ts b/packages/hunk/src/session/reviewErrorCatalog.test.ts similarity index 100% rename from src/session/reviewErrorCatalog.test.ts rename to packages/hunk/src/session/reviewErrorCatalog.test.ts diff --git a/src/session/reviewErrorCatalog.ts b/packages/hunk/src/session/reviewErrorCatalog.ts similarity index 100% rename from src/session/reviewErrorCatalog.ts rename to packages/hunk/src/session/reviewErrorCatalog.ts diff --git a/src/session/reviewEventProtocol.test.ts b/packages/hunk/src/session/reviewEventProtocol.test.ts similarity index 100% rename from src/session/reviewEventProtocol.test.ts rename to packages/hunk/src/session/reviewEventProtocol.test.ts diff --git a/src/session/reviewEventProtocol.ts b/packages/hunk/src/session/reviewEventProtocol.ts similarity index 100% rename from src/session/reviewEventProtocol.ts rename to packages/hunk/src/session/reviewEventProtocol.ts diff --git a/src/session/reviewHttpProtocol.test.ts b/packages/hunk/src/session/reviewHttpProtocol.test.ts similarity index 100% rename from src/session/reviewHttpProtocol.test.ts rename to packages/hunk/src/session/reviewHttpProtocol.test.ts diff --git a/src/session/reviewHttpProtocol.ts b/packages/hunk/src/session/reviewHttpProtocol.ts similarity index 100% rename from src/session/reviewHttpProtocol.ts rename to packages/hunk/src/session/reviewHttpProtocol.ts diff --git a/src/session/reviewProtocol.test.ts b/packages/hunk/src/session/reviewProtocol.test.ts similarity index 100% rename from src/session/reviewProtocol.test.ts rename to packages/hunk/src/session/reviewProtocol.test.ts diff --git a/src/session/reviewProtocol.ts b/packages/hunk/src/session/reviewProtocol.ts similarity index 100% rename from src/session/reviewProtocol.ts rename to packages/hunk/src/session/reviewProtocol.ts diff --git a/src/session/types.ts b/packages/hunk/src/session/types.ts similarity index 100% rename from src/session/types.ts rename to packages/hunk/src/session/types.ts diff --git a/src/ui/App.extension-command-controls.test.tsx b/packages/hunk/src/ui/App.extension-command-controls.test.tsx similarity index 96% rename from src/ui/App.extension-command-controls.test.tsx rename to packages/hunk/src/ui/App.extension-command-controls.test.tsx index cb89d00d4..b6b347d4e 100644 --- a/src/ui/App.extension-command-controls.test.tsx +++ b/packages/hunk/src/ui/App.extension-command-controls.test.tsx @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, StrictMode, useState } from "react"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import type { AppBootstrap } from "../app/types"; import type { ExtensionCommandControls, ExtensionWorkspace } from "../extension-api/types"; import { createEmptyExtensionLoadResult } from "../extensions/types"; diff --git a/src/ui/App.extension-runtime.test.tsx b/packages/hunk/src/ui/App.extension-runtime.test.tsx similarity index 95% rename from src/ui/App.extension-runtime.test.tsx rename to packages/hunk/src/ui/App.extension-runtime.test.tsx index cc29e5829..863792d6f 100644 --- a/src/ui/App.extension-runtime.test.tsx +++ b/packages/hunk/src/ui/App.extension-runtime.test.tsx @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, Suspense, useState } from "react"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import type { AppBootstrap } from "../app/types"; import { createEmptyExtensionLoadResult, type ExtensionLoadResult } from "../extensions/types"; import { App } from "./App"; diff --git a/src/ui/App.extension-trust.test.tsx b/packages/hunk/src/ui/App.extension-trust.test.tsx similarity index 96% rename from src/ui/App.extension-trust.test.tsx rename to packages/hunk/src/ui/App.extension-trust.test.tsx index 7c922f980..50bacf43c 100644 --- a/src/ui/App.extension-trust.test.tsx +++ b/packages/hunk/src/ui/App.extension-trust.test.tsx @@ -3,8 +3,8 @@ import { testRender } from "@opentui/react/test-utils"; import { act, useState } from "react"; import type { AppBootstrap } from "../app/types"; import { createEmptyExtensionLoadResult } from "../extensions/types"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; const { App } = await import("./App"); diff --git a/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx similarity index 100% rename from src/ui/App.tsx rename to packages/hunk/src/ui/App.tsx diff --git a/src/ui/AppHost.cursor-line.test.tsx b/packages/hunk/src/ui/AppHost.cursor-line.test.tsx similarity index 97% rename from src/ui/AppHost.cursor-line.test.tsx rename to packages/hunk/src/ui/AppHost.cursor-line.test.tsx index 7de451afc..88c33dde7 100644 --- a/src/ui/AppHost.cursor-line.test.tsx +++ b/packages/hunk/src/ui/AppHost.cursor-line.test.tsx @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import type { CursorLine } from "../core/run/commandInputs"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile, lines } from "../../../../test/helpers/diff-helpers"; import { AppHost } from "./AppHost"; const BEFORE = lines( diff --git a/src/ui/AppHost.edit-in-editor.test.tsx b/packages/hunk/src/ui/AppHost.edit-in-editor.test.tsx similarity index 95% rename from src/ui/AppHost.edit-in-editor.test.tsx rename to packages/hunk/src/ui/AppHost.edit-in-editor.test.tsx index d4f5a3d91..25c8ab198 100644 --- a/src/ui/AppHost.edit-in-editor.test.tsx +++ b/packages/hunk/src/ui/AppHost.edit-in-editor.test.tsx @@ -5,8 +5,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { act } from "react"; import type { AppBootstrap } from "../core/bootstrap"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile, lines } from "../../../../test/helpers/diff-helpers"; const { AppHost } = await import("./AppHost"); diff --git a/src/ui/AppHost.extension-dialogs.test.tsx b/packages/hunk/src/ui/AppHost.extension-dialogs.test.tsx similarity index 99% rename from src/ui/AppHost.extension-dialogs.test.tsx rename to packages/hunk/src/ui/AppHost.extension-dialogs.test.tsx index 7df60410d..9e8cc5316 100644 --- a/src/ui/AppHost.extension-dialogs.test.tsx +++ b/packages/hunk/src/ui/AppHost.extension-dialogs.test.tsx @@ -5,7 +5,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { removeTestDirectory } from "../../test/helpers/filesystem"; +import { removeTestDirectory } from "../../../../test/helpers/filesystem"; import { loadAppBootstrap as loadCoreAppBootstrap } from "../core/changeset/loaders"; import type { AppBootstrap } from "../app/types"; diff --git a/src/ui/AppHost.extension-navigation.test.tsx b/packages/hunk/src/ui/AppHost.extension-navigation.test.tsx similarity index 99% rename from src/ui/AppHost.extension-navigation.test.tsx rename to packages/hunk/src/ui/AppHost.extension-navigation.test.tsx index a75a067c9..2ae051598 100644 --- a/src/ui/AppHost.extension-navigation.test.tsx +++ b/packages/hunk/src/ui/AppHost.extension-navigation.test.tsx @@ -5,7 +5,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { removeTestDirectory } from "../../test/helpers/filesystem"; +import { removeTestDirectory } from "../../../../test/helpers/filesystem"; import { loadAppBootstrap as loadCoreAppBootstrap } from "../core/changeset/loaders"; import type { AppBootstrap } from "../app/types"; diff --git a/src/ui/AppHost.extension-sidebar.test.tsx b/packages/hunk/src/ui/AppHost.extension-sidebar.test.tsx similarity index 99% rename from src/ui/AppHost.extension-sidebar.test.tsx rename to packages/hunk/src/ui/AppHost.extension-sidebar.test.tsx index 82cc7c45b..a4d28ad33 100644 --- a/src/ui/AppHost.extension-sidebar.test.tsx +++ b/packages/hunk/src/ui/AppHost.extension-sidebar.test.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { KeyEvent, type ParsedKey } from "@opentui/core"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { removeTestDirectory } from "../../test/helpers/filesystem"; +import { removeTestDirectory } from "../../../../test/helpers/filesystem"; import { loadAppBootstrap as loadCoreAppBootstrap } from "../core/changeset/loaders"; import type { AppBootstrap } from "../app/types"; diff --git a/src/ui/AppHost.extensions.test.tsx b/packages/hunk/src/ui/AppHost.extensions.test.tsx similarity index 99% rename from src/ui/AppHost.extensions.test.tsx rename to packages/hunk/src/ui/AppHost.extensions.test.tsx index b777021af..afa26d12d 100644 --- a/src/ui/AppHost.extensions.test.tsx +++ b/packages/hunk/src/ui/AppHost.extensions.test.tsx @@ -12,7 +12,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { removeTestDirectory } from "../../test/helpers/filesystem"; +import { removeTestDirectory } from "../../../../test/helpers/filesystem"; import { ReviewProducer } from "../app/review/producer"; import type { AppBootstrap } from "../app/types"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; diff --git a/src/ui/AppHost.file-view-modes.test.tsx b/packages/hunk/src/ui/AppHost.file-view-modes.test.tsx similarity index 99% rename from src/ui/AppHost.file-view-modes.test.tsx rename to packages/hunk/src/ui/AppHost.file-view-modes.test.tsx index f23fa7a72..299782034 100644 --- a/src/ui/AppHost.file-view-modes.test.tsx +++ b/packages/hunk/src/ui/AppHost.file-view-modes.test.tsx @@ -5,9 +5,9 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { KeyEvent, type ParsedKey } from "@opentui/core"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; -import { createWatchTestRuntime } from "../../test/helpers/watchTest"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; +import { createWatchTestRuntime } from "../../../../test/helpers/watchTest"; import { loadAppBootstrap } from "../core/changeset/loaders"; import { loadStartupExtensions } from "../extensions/startup"; import { AppHost } from "./AppHost"; diff --git a/src/ui/AppHost.file-views.test.tsx b/packages/hunk/src/ui/AppHost.file-views.test.tsx similarity index 98% rename from src/ui/AppHost.file-views.test.tsx rename to packages/hunk/src/ui/AppHost.file-views.test.tsx index 2e032db09..5d7d320ec 100644 --- a/src/ui/AppHost.file-views.test.tsx +++ b/packages/hunk/src/ui/AppHost.file-views.test.tsx @@ -4,12 +4,15 @@ import { join } from "node:path"; import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile, createTestSourceFetcher } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile, createTestSourceFetcher } from "../../../../test/helpers/diff-helpers"; import { loadStartupExtensions } from "../extensions/startup"; import { AppHost } from "./AppHost"; -const JSX_FILE_VIEW_EXTENSION = join(import.meta.dir, "../../examples/extensions/jsx-file-view"); +const JSX_FILE_VIEW_EXTENSION = join( + import.meta.dir, + "../../../../examples/extensions/jsx-file-view", +); const tempDirs: string[] = []; setDefaultTimeout(20_000); diff --git a/src/ui/AppHost.interactions.test.tsx b/packages/hunk/src/ui/AppHost.interactions.test.tsx similarity index 99% rename from src/ui/AppHost.interactions.test.tsx rename to packages/hunk/src/ui/AppHost.interactions.test.tsx index e7c707496..92c8e896a 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/packages/hunk/src/ui/AppHost.interactions.test.tsx @@ -14,9 +14,12 @@ import type { import { LEGACY_CUSTOM_SYNTAX_NOTICE } from "../core/process/startupNotice"; import type { AppBootstrap } from "../core/bootstrap"; import type { LayoutMode } from "../core/run/commandInputs"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { capturedTestColorToHex } from "../../test/helpers/test-color-helpers"; -import { createTestDiffFile as buildTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { capturedTestColorToHex } from "../../../../test/helpers/test-color-helpers"; +import { + createTestDiffFile as buildTestDiffFile, + lines, +} from "../../../../test/helpers/diff-helpers"; import { createEmptyExtensionLoadResult } from "../extensions/types"; import { AGENT_SKILL_COMMAND, AGENT_SKILL_PROMPT } from "./components/chrome/AgentSkillDialog"; import { App } from "./App"; diff --git a/src/ui/AppHost.key-routing.test.tsx b/packages/hunk/src/ui/AppHost.key-routing.test.tsx similarity index 98% rename from src/ui/AppHost.key-routing.test.tsx rename to packages/hunk/src/ui/AppHost.key-routing.test.tsx index 632e84d70..c24f65711 100644 --- a/src/ui/AppHost.key-routing.test.tsx +++ b/packages/hunk/src/ui/AppHost.key-routing.test.tsx @@ -6,8 +6,8 @@ import { ScrollBoxRenderable, type Renderable } from "@opentui/core"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import type { AppBootstrap } from "../core/bootstrap"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { loadStartupExtensions } from "../extensions/startup"; mock.restore(); diff --git a/src/ui/AppHost.keybindings.test.tsx b/packages/hunk/src/ui/AppHost.keybindings.test.tsx similarity index 99% rename from src/ui/AppHost.keybindings.test.tsx rename to packages/hunk/src/ui/AppHost.keybindings.test.tsx index 1416daf55..8a1063e4a 100644 --- a/src/ui/AppHost.keybindings.test.tsx +++ b/packages/hunk/src/ui/AppHost.keybindings.test.tsx @@ -5,7 +5,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { removeTestDirectory } from "../../test/helpers/filesystem"; +import { removeTestDirectory } from "../../../../test/helpers/filesystem"; import { resolveConfiguredCliInput } from "../core/run/config"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; import { loadAppBootstrap } from "../core/changeset/loaders"; diff --git a/src/ui/AppHost.keyboard-modes.test.tsx b/packages/hunk/src/ui/AppHost.keyboard-modes.test.tsx similarity index 98% rename from src/ui/AppHost.keyboard-modes.test.tsx rename to packages/hunk/src/ui/AppHost.keyboard-modes.test.tsx index a918f9a4b..8256da386 100644 --- a/src/ui/AppHost.keyboard-modes.test.tsx +++ b/packages/hunk/src/ui/AppHost.keyboard-modes.test.tsx @@ -5,8 +5,8 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { KeyEvent, type ParsedKey } from "@opentui/core"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { loadStartupExtensions } from "../extensions/startup"; import { AppHost } from "./AppHost"; diff --git a/src/ui/AppHost.reload.test.tsx b/packages/hunk/src/ui/AppHost.reload.test.tsx similarity index 99% rename from src/ui/AppHost.reload.test.tsx rename to packages/hunk/src/ui/AppHost.reload.test.tsx index eafe0f289..e2abf32f2 100644 --- a/src/ui/AppHost.reload.test.tsx +++ b/packages/hunk/src/ui/AppHost.reload.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import { SESSION_BROKER_REGISTRATION_VERSION } from "@hunk/session-broker-core"; -import { removeTestDirectory } from "../../test/helpers/filesystem"; +import { removeTestDirectory } from "../../../../test/helpers/filesystem"; import type { HunkSessionBrokerClient } from "../session/broker/brokerClient"; import type { HunkSessionRegistration, diff --git a/src/ui/AppHost.responsive.test.tsx b/packages/hunk/src/ui/AppHost.responsive.test.tsx similarity index 98% rename from src/ui/AppHost.responsive.test.tsx rename to packages/hunk/src/ui/AppHost.responsive.test.tsx index 24d688dea..da121099b 100644 --- a/src/ui/AppHost.responsive.test.tsx +++ b/packages/hunk/src/ui/AppHost.responsive.test.tsx @@ -3,8 +3,8 @@ import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import type { AppBootstrap } from "../core/bootstrap"; import type { LayoutMode } from "../core/run/commandInputs"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; const { AppHost } = await import("./AppHost"); diff --git a/src/ui/AppHost.review-metadata.test.tsx b/packages/hunk/src/ui/AppHost.review-metadata.test.tsx similarity index 99% rename from src/ui/AppHost.review-metadata.test.tsx rename to packages/hunk/src/ui/AppHost.review-metadata.test.tsx index 74d4aa194..cf4dea5cb 100644 --- a/src/ui/AppHost.review-metadata.test.tsx +++ b/packages/hunk/src/ui/AppHost.review-metadata.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import { SESSION_BROKER_REGISTRATION_VERSION } from "@hunk/session-broker-core"; -import { createWatchTestRuntime } from "../../test/helpers/watchTest"; +import { createWatchTestRuntime } from "../../../../test/helpers/watchTest"; import type { AppBootstrap } from "../core/bootstrap"; import { loadAppBootstrap } from "../core/changeset/loaders"; import type { HunkSessionBrokerClient } from "../session/broker/brokerClient"; diff --git a/src/ui/AppHost.scroll-regression.test.tsx b/packages/hunk/src/ui/AppHost.scroll-regression.test.tsx similarity index 93% rename from src/ui/AppHost.scroll-regression.test.tsx rename to packages/hunk/src/ui/AppHost.scroll-regression.test.tsx index bb0e7f99b..2f344dc78 100644 --- a/src/ui/AppHost.scroll-regression.test.tsx +++ b/packages/hunk/src/ui/AppHost.scroll-regression.test.tsx @@ -2,8 +2,8 @@ import { describe, expect, mock, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import type { AppBootstrap } from "../core/bootstrap"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; mock.restore(); diff --git a/src/ui/AppHost.selection.test.tsx b/packages/hunk/src/ui/AppHost.selection.test.tsx similarity index 98% rename from src/ui/AppHost.selection.test.tsx rename to packages/hunk/src/ui/AppHost.selection.test.tsx index 7ce52e12c..b6552ab91 100644 --- a/src/ui/AppHost.selection.test.tsx +++ b/packages/hunk/src/ui/AppHost.selection.test.tsx @@ -3,8 +3,8 @@ import { testRender } from "@opentui/react/test-utils"; import { MouseButtons } from "@opentui/core/testing"; import { act } from "react"; import type { AppBootstrap } from "../core/bootstrap"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile, lines } from "../../../../test/helpers/diff-helpers"; import { measureTextWidth } from "./lib/text"; // These tests drive the DiffPane mouse-drag text-selection path end to end: begin/update/end diff --git a/src/ui/AppHost.sidebar-resize.test.tsx b/packages/hunk/src/ui/AppHost.sidebar-resize.test.tsx similarity index 97% rename from src/ui/AppHost.sidebar-resize.test.tsx rename to packages/hunk/src/ui/AppHost.sidebar-resize.test.tsx index f0e905e10..8f9d70dc1 100644 --- a/src/ui/AppHost.sidebar-resize.test.tsx +++ b/packages/hunk/src/ui/AppHost.sidebar-resize.test.tsx @@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import type { AppBootstrap } from "../core/bootstrap"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile as buildTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { + createTestDiffFile as buildTestDiffFile, + lines, +} from "../../../../test/helpers/diff-helpers"; import { createEmptyExtensionLoadResult } from "../extensions/types"; const { AppHost } = await import("./AppHost"); diff --git a/src/ui/AppHost.sidebar-visibility.test.tsx b/packages/hunk/src/ui/AppHost.sidebar-visibility.test.tsx similarity index 96% rename from src/ui/AppHost.sidebar-visibility.test.tsx rename to packages/hunk/src/ui/AppHost.sidebar-visibility.test.tsx index 35ef95450..091206a73 100644 --- a/src/ui/AppHost.sidebar-visibility.test.tsx +++ b/packages/hunk/src/ui/AppHost.sidebar-visibility.test.tsx @@ -3,8 +3,11 @@ import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import type { AppBootstrap } from "../core/bootstrap"; import type { SidebarVisibility } from "../core/run/commandInputs"; -import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; -import { createTestDiffFile as buildTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createTestVcsAppBootstrap } from "../../../../test/helpers/app-bootstrap"; +import { + createTestDiffFile as buildTestDiffFile, + lines, +} from "../../../../test/helpers/diff-helpers"; import { HUNK_FILES_PANE_KEY } from "../extensions/extensionIds"; import { createEmptyExtensionLoadResult } from "../extensions/types"; diff --git a/src/ui/AppHost.tsx b/packages/hunk/src/ui/AppHost.tsx similarity index 100% rename from src/ui/AppHost.tsx rename to packages/hunk/src/ui/AppHost.tsx diff --git a/src/ui/AppHost.watch.test.tsx b/packages/hunk/src/ui/AppHost.watch.test.tsx similarity index 97% rename from src/ui/AppHost.watch.test.tsx rename to packages/hunk/src/ui/AppHost.watch.test.tsx index 1fd75a50d..7f6d5ee1d 100644 --- a/src/ui/AppHost.watch.test.tsx +++ b/packages/hunk/src/ui/AppHost.watch.test.tsx @@ -3,8 +3,8 @@ import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { capturedTestColorToHex } from "../../test/helpers/test-color-helpers"; -import { createWatchTestRuntime } from "../../test/helpers/watchTest"; +import { capturedTestColorToHex } from "../../../../test/helpers/test-color-helpers"; +import { createWatchTestRuntime } from "../../../../test/helpers/watchTest"; import { loadAppBootstrap } from "../core/changeset/loaders"; import { AppHost } from "./AppHost"; import { resolveTheme } from "./themes"; diff --git a/src/ui/AppHost.workspace.test.tsx b/packages/hunk/src/ui/AppHost.workspace.test.tsx similarity index 99% rename from src/ui/AppHost.workspace.test.tsx rename to packages/hunk/src/ui/AppHost.workspace.test.tsx index dee65d146..361660821 100644 --- a/src/ui/AppHost.workspace.test.tsx +++ b/packages/hunk/src/ui/AppHost.workspace.test.tsx @@ -12,7 +12,7 @@ import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { removeTestDirectory } from "../../test/helpers/filesystem"; +import { removeTestDirectory } from "../../../../test/helpers/filesystem"; import { loadAppBootstrap as loadCoreAppBootstrap } from "../core/changeset/loaders"; import type { AppBootstrap } from "../app/types"; diff --git a/src/ui/components/chrome/AgentSkillDialog.tsx b/packages/hunk/src/ui/components/chrome/AgentSkillDialog.tsx similarity index 100% rename from src/ui/components/chrome/AgentSkillDialog.tsx rename to packages/hunk/src/ui/components/chrome/AgentSkillDialog.tsx diff --git a/src/ui/components/chrome/ConfirmDialog.tsx b/packages/hunk/src/ui/components/chrome/ConfirmDialog.tsx similarity index 100% rename from src/ui/components/chrome/ConfirmDialog.tsx rename to packages/hunk/src/ui/components/chrome/ConfirmDialog.tsx diff --git a/src/ui/components/chrome/ExtensionDialog.tsx b/packages/hunk/src/ui/components/chrome/ExtensionDialog.tsx similarity index 100% rename from src/ui/components/chrome/ExtensionDialog.tsx rename to packages/hunk/src/ui/components/chrome/ExtensionDialog.tsx diff --git a/src/ui/components/chrome/ExtensionToast.tsx b/packages/hunk/src/ui/components/chrome/ExtensionToast.tsx similarity index 100% rename from src/ui/components/chrome/ExtensionToast.tsx rename to packages/hunk/src/ui/components/chrome/ExtensionToast.tsx diff --git a/src/ui/components/chrome/HelpDialog.tsx b/packages/hunk/src/ui/components/chrome/HelpDialog.tsx similarity index 100% rename from src/ui/components/chrome/HelpDialog.tsx rename to packages/hunk/src/ui/components/chrome/HelpDialog.tsx diff --git a/src/ui/components/chrome/MenuBar.tsx b/packages/hunk/src/ui/components/chrome/MenuBar.tsx similarity index 100% rename from src/ui/components/chrome/MenuBar.tsx rename to packages/hunk/src/ui/components/chrome/MenuBar.tsx diff --git a/src/ui/components/chrome/MenuDropdown.tsx b/packages/hunk/src/ui/components/chrome/MenuDropdown.tsx similarity index 100% rename from src/ui/components/chrome/MenuDropdown.tsx rename to packages/hunk/src/ui/components/chrome/MenuDropdown.tsx diff --git a/src/ui/components/chrome/ModalFrame.tsx b/packages/hunk/src/ui/components/chrome/ModalFrame.tsx similarity index 100% rename from src/ui/components/chrome/ModalFrame.tsx rename to packages/hunk/src/ui/components/chrome/ModalFrame.tsx diff --git a/src/ui/components/chrome/StatusBar.tsx b/packages/hunk/src/ui/components/chrome/StatusBar.tsx similarity index 100% rename from src/ui/components/chrome/StatusBar.tsx rename to packages/hunk/src/ui/components/chrome/StatusBar.tsx diff --git a/src/ui/components/chrome/ThemeSelectorDialog.tsx b/packages/hunk/src/ui/components/chrome/ThemeSelectorDialog.tsx similarity index 100% rename from src/ui/components/chrome/ThemeSelectorDialog.tsx rename to packages/hunk/src/ui/components/chrome/ThemeSelectorDialog.tsx diff --git a/src/ui/components/chrome/menu.ts b/packages/hunk/src/ui/components/chrome/menu.ts similarity index 100% rename from src/ui/components/chrome/menu.ts rename to packages/hunk/src/ui/components/chrome/menu.ts diff --git a/src/ui/components/panes/AgentCard.tsx b/packages/hunk/src/ui/components/panes/AgentCard.tsx similarity index 100% rename from src/ui/components/panes/AgentCard.tsx rename to packages/hunk/src/ui/components/panes/AgentCard.tsx diff --git a/src/ui/components/panes/AgentInlineNote.test.tsx b/packages/hunk/src/ui/components/panes/AgentInlineNote.test.tsx similarity index 100% rename from src/ui/components/panes/AgentInlineNote.test.tsx rename to packages/hunk/src/ui/components/panes/AgentInlineNote.test.tsx diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/packages/hunk/src/ui/components/panes/AgentInlineNote.tsx similarity index 100% rename from src/ui/components/panes/AgentInlineNote.tsx rename to packages/hunk/src/ui/components/panes/AgentInlineNote.tsx diff --git a/src/ui/components/panes/DiffFileHeaderRow.tsx b/packages/hunk/src/ui/components/panes/DiffFileHeaderRow.tsx similarity index 100% rename from src/ui/components/panes/DiffFileHeaderRow.tsx rename to packages/hunk/src/ui/components/panes/DiffFileHeaderRow.tsx diff --git a/src/ui/components/panes/DiffPane.test.tsx b/packages/hunk/src/ui/components/panes/DiffPane.test.tsx similarity index 100% rename from src/ui/components/panes/DiffPane.test.tsx rename to packages/hunk/src/ui/components/panes/DiffPane.test.tsx diff --git a/src/ui/components/panes/DiffPane.tsx b/packages/hunk/src/ui/components/panes/DiffPane.tsx similarity index 100% rename from src/ui/components/panes/DiffPane.tsx rename to packages/hunk/src/ui/components/panes/DiffPane.tsx diff --git a/src/ui/components/panes/DiffSection.tsx b/packages/hunk/src/ui/components/panes/DiffSection.tsx similarity index 100% rename from src/ui/components/panes/DiffSection.tsx rename to packages/hunk/src/ui/components/panes/DiffSection.tsx diff --git a/src/ui/components/panes/ExtensionPane.test.tsx b/packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx similarity index 99% rename from src/ui/components/panes/ExtensionPane.test.tsx rename to packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx index 3afe6cbc6..88525ad84 100644 --- a/src/ui/components/panes/ExtensionPane.test.tsx +++ b/packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { MouseButtons } from "@opentui/core/testing"; import { testRender } from "@opentui/react/test-utils"; import { act, useState, type ReactNode } from "react"; -import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../../test/helpers/diff-helpers"; import type { ExtensionPaneActions, ExtensionPaneKeybindings, diff --git a/src/ui/components/panes/ExtensionPane.tsx b/packages/hunk/src/ui/components/panes/ExtensionPane.tsx similarity index 100% rename from src/ui/components/panes/ExtensionPane.tsx rename to packages/hunk/src/ui/components/panes/ExtensionPane.tsx diff --git a/src/ui/components/panes/FileListItem.tsx b/packages/hunk/src/ui/components/panes/FileListItem.tsx similarity index 100% rename from src/ui/components/panes/FileListItem.tsx rename to packages/hunk/src/ui/components/panes/FileListItem.tsx diff --git a/src/ui/components/panes/FileView.test.tsx b/packages/hunk/src/ui/components/panes/FileView.test.tsx similarity index 99% rename from src/ui/components/panes/FileView.test.tsx rename to packages/hunk/src/ui/components/panes/FileView.test.tsx index 5cd4fbc6b..69ce5964e 100644 --- a/src/ui/components/panes/FileView.test.tsx +++ b/packages/hunk/src/ui/components/panes/FileView.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, useState } from "react"; -import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../../test/helpers/diff-helpers"; import type { ExtensionFileViewLayout, ExtensionFileViewRowComponentProps, diff --git a/src/ui/components/panes/FileView.tsx b/packages/hunk/src/ui/components/panes/FileView.tsx similarity index 100% rename from src/ui/components/panes/FileView.tsx rename to packages/hunk/src/ui/components/panes/FileView.tsx diff --git a/src/ui/components/panes/PaneDivider.tsx b/packages/hunk/src/ui/components/panes/PaneDivider.tsx similarity index 100% rename from src/ui/components/panes/PaneDivider.tsx rename to packages/hunk/src/ui/components/panes/PaneDivider.tsx diff --git a/src/ui/components/panes/copySelection.test.ts b/packages/hunk/src/ui/components/panes/copySelection.test.ts similarity index 100% rename from src/ui/components/panes/copySelection.test.ts rename to packages/hunk/src/ui/components/panes/copySelection.test.ts diff --git a/src/ui/components/panes/copySelection.ts b/packages/hunk/src/ui/components/panes/copySelection.ts similarity index 100% rename from src/ui/components/panes/copySelection.ts rename to packages/hunk/src/ui/components/panes/copySelection.ts diff --git a/src/ui/components/scrollbar/VerticalScrollbar.test.tsx b/packages/hunk/src/ui/components/scrollbar/VerticalScrollbar.test.tsx similarity index 99% rename from src/ui/components/scrollbar/VerticalScrollbar.test.tsx rename to packages/hunk/src/ui/components/scrollbar/VerticalScrollbar.test.tsx index 9a89eb0ba..46afaa9f6 100644 --- a/src/ui/components/scrollbar/VerticalScrollbar.test.tsx +++ b/packages/hunk/src/ui/components/scrollbar/VerticalScrollbar.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { parseDiffFromFile } from "@pierre/diffs"; import { act, createRef } from "react"; -import { capturedTestColorToHex } from "../../../../test/helpers/test-color-helpers"; +import { capturedTestColorToHex } from "../../../../../../test/helpers/test-color-helpers"; import type { AppBootstrap } from "../../../core/bootstrap"; import type { DiffFile } from "../../../core/changeset/model"; import { resolveTheme } from "../../themes"; diff --git a/src/ui/components/scrollbar/VerticalScrollbar.tsx b/packages/hunk/src/ui/components/scrollbar/VerticalScrollbar.tsx similarity index 100% rename from src/ui/components/scrollbar/VerticalScrollbar.tsx rename to packages/hunk/src/ui/components/scrollbar/VerticalScrollbar.tsx diff --git a/src/ui/components/ui-components.test.tsx b/packages/hunk/src/ui/components/ui-components.test.tsx similarity index 99% rename from src/ui/components/ui-components.test.tsx rename to packages/hunk/src/ui/components/ui-components.test.tsx index 74d9acd5c..0bfdf4699 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/packages/hunk/src/ui/components/ui-components.test.tsx @@ -5,13 +5,13 @@ import { testRender } from "@opentui/react/test-utils"; import { act, createRef, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import type { AppBootstrap } from "../../core/bootstrap"; import type { DiffFile } from "../../core/changeset/model"; -import { createTestVcsAppBootstrap } from "../../../test/helpers/app-bootstrap"; -import { capturedTestColorToHex } from "../../../test/helpers/test-color-helpers"; +import { createTestVcsAppBootstrap } from "../../../../../test/helpers/app-bootstrap"; +import { capturedTestColorToHex } from "../../../../../test/helpers/test-color-helpers"; import { createTestDiffFile as buildTestDiffFile, createTestSourceFetcher, lines, -} from "../../../test/helpers/diff-helpers"; +} from "../../../../../test/helpers/diff-helpers"; import { createVisibleAgentNote } from "../lib/agentAnnotations"; import { hexColorDistance } from "../lib/color"; import { RAPID_SCROLL_OVERSCAN_IDLE_MS } from "../lib/adaptiveScrollOverscan"; diff --git a/src/ui/currentReviewRefresh.test.ts b/packages/hunk/src/ui/currentReviewRefresh.test.ts similarity index 100% rename from src/ui/currentReviewRefresh.test.ts rename to packages/hunk/src/ui/currentReviewRefresh.test.ts diff --git a/src/ui/currentReviewRefresh.ts b/packages/hunk/src/ui/currentReviewRefresh.ts similarity index 100% rename from src/ui/currentReviewRefresh.ts rename to packages/hunk/src/ui/currentReviewRefresh.ts diff --git a/src/ui/diff/CodeCellView.test.tsx b/packages/hunk/src/ui/diff/CodeCellView.test.tsx similarity index 99% rename from src/ui/diff/CodeCellView.test.tsx rename to packages/hunk/src/ui/diff/CodeCellView.test.tsx index a4a9f2a8f..6a6ca8ff4 100644 --- a/src/ui/diff/CodeCellView.test.tsx +++ b/packages/hunk/src/ui/diff/CodeCellView.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, type ReactNode } from "react"; -import { capturedTestColorToHex } from "../../../test/helpers/test-color-helpers"; +import { capturedTestColorToHex } from "../../../../../test/helpers/test-color-helpers"; import { contrastRatio } from "../lib/color"; import { cursorLineHighlightBg, diff --git a/src/ui/diff/CodeCellView.tsx b/packages/hunk/src/ui/diff/CodeCellView.tsx similarity index 100% rename from src/ui/diff/CodeCellView.tsx rename to packages/hunk/src/ui/diff/CodeCellView.tsx diff --git a/src/ui/diff/CodeRowView.test.tsx b/packages/hunk/src/ui/diff/CodeRowView.test.tsx similarity index 96% rename from src/ui/diff/CodeRowView.test.tsx rename to packages/hunk/src/ui/diff/CodeRowView.test.tsx index 8091c8a12..f7422976d 100644 --- a/src/ui/diff/CodeRowView.test.tsx +++ b/packages/hunk/src/ui/diff/CodeRowView.test.tsx @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { capturedTestColorToHex } from "../../../test/helpers/test-color-helpers"; +import { capturedTestColorToHex } from "../../../../../test/helpers/test-color-helpers"; import { resolveTheme } from "../themes"; import { CodeRowView, type PlannedCodeReviewRow } from "./CodeRowView"; import { cursorLineHighlightBg, selectionHighlightBg, stackCellPalette } from "./rowStyle"; diff --git a/src/ui/diff/CodeRowView.tsx b/packages/hunk/src/ui/diff/CodeRowView.tsx similarity index 100% rename from src/ui/diff/CodeRowView.tsx rename to packages/hunk/src/ui/diff/CodeRowView.tsx diff --git a/src/ui/diff/DiffMetaRowView.tsx b/packages/hunk/src/ui/diff/DiffMetaRowView.tsx similarity index 100% rename from src/ui/diff/DiffMetaRowView.tsx rename to packages/hunk/src/ui/diff/DiffMetaRowView.tsx diff --git a/src/ui/diff/DiffRowView.tsx b/packages/hunk/src/ui/diff/DiffRowView.tsx similarity index 100% rename from src/ui/diff/DiffRowView.tsx rename to packages/hunk/src/ui/diff/DiffRowView.tsx diff --git a/src/ui/diff/DiffSectionBody.tsx b/packages/hunk/src/ui/diff/DiffSectionBody.tsx similarity index 100% rename from src/ui/diff/DiffSectionBody.tsx rename to packages/hunk/src/ui/diff/DiffSectionBody.tsx diff --git a/src/ui/diff/codeColumns.test.ts b/packages/hunk/src/ui/diff/codeColumns.test.ts similarity index 100% rename from src/ui/diff/codeColumns.test.ts rename to packages/hunk/src/ui/diff/codeColumns.test.ts diff --git a/src/ui/diff/codeColumns.ts b/packages/hunk/src/ui/diff/codeColumns.ts similarity index 100% rename from src/ui/diff/codeColumns.ts rename to packages/hunk/src/ui/diff/codeColumns.ts diff --git a/src/ui/diff/codeRowAffordance.ts b/packages/hunk/src/ui/diff/codeRowAffordance.ts similarity index 100% rename from src/ui/diff/codeRowAffordance.ts rename to packages/hunk/src/ui/diff/codeRowAffordance.ts diff --git a/src/ui/diff/codeRowLayout.test.ts b/packages/hunk/src/ui/diff/codeRowLayout.test.ts similarity index 100% rename from src/ui/diff/codeRowLayout.test.ts rename to packages/hunk/src/ui/diff/codeRowLayout.test.ts diff --git a/src/ui/diff/codeRowLayout.ts b/packages/hunk/src/ui/diff/codeRowLayout.ts similarity index 100% rename from src/ui/diff/codeRowLayout.ts rename to packages/hunk/src/ui/diff/codeRowLayout.ts diff --git a/src/ui/diff/cursorHighlight.test.ts b/packages/hunk/src/ui/diff/cursorHighlight.test.ts similarity index 100% rename from src/ui/diff/cursorHighlight.test.ts rename to packages/hunk/src/ui/diff/cursorHighlight.test.ts diff --git a/src/ui/diff/cursorHighlight.ts b/packages/hunk/src/ui/diff/cursorHighlight.ts similarity index 100% rename from src/ui/diff/cursorHighlight.ts rename to packages/hunk/src/ui/diff/cursorHighlight.ts diff --git a/src/ui/diff/diffRowModel.ts b/packages/hunk/src/ui/diff/diffRowModel.ts similarity index 100% rename from src/ui/diff/diffRowModel.ts rename to packages/hunk/src/ui/diff/diffRowModel.ts diff --git a/src/ui/diff/diffRows.test.ts b/packages/hunk/src/ui/diff/diffRows.test.ts similarity index 99% rename from src/ui/diff/diffRows.test.ts rename to packages/hunk/src/ui/diff/diffRows.test.ts index 87b1b7f75..a19cf413b 100644 --- a/src/ui/diff/diffRows.test.ts +++ b/packages/hunk/src/ui/diff/diffRows.test.ts @@ -20,8 +20,8 @@ import { stackCellPalette } from "./rowStyle"; import { buildReviewRenderPlan } from "./reviewRenderPlan"; import { measureTextWidth } from "../lib/text"; import { TRANSPARENT_BACKGROUND, resolveTheme } from "../themes"; -import { createTestSourceFetcher } from "../../../test/helpers/diff-helpers"; -import { createTestCustomThemes } from "../../../test/helpers/theme-helpers"; +import { createTestSourceFetcher } from "../../../../../test/helpers/diff-helpers"; +import { createTestCustomThemes } from "../../../../../test/helpers/theme-helpers"; import { registerHighlightWorker } from "./worker"; function createDiffFile(): DiffFile { diff --git a/src/ui/diff/diffRows.ts b/packages/hunk/src/ui/diff/diffRows.ts similarity index 100% rename from src/ui/diff/diffRows.ts rename to packages/hunk/src/ui/diff/diffRows.ts diff --git a/src/ui/diff/diffSectionGeometry.test.ts b/packages/hunk/src/ui/diff/diffSectionGeometry.test.ts similarity index 99% rename from src/ui/diff/diffSectionGeometry.test.ts rename to packages/hunk/src/ui/diff/diffSectionGeometry.test.ts index e35934a3e..48bc8a78c 100644 --- a/src/ui/diff/diffSectionGeometry.test.ts +++ b/packages/hunk/src/ui/diff/diffSectionGeometry.test.ts @@ -6,7 +6,7 @@ import { createTestDiffFile, createTestHeaderOnlyDiffFile, lines, -} from "../../../test/helpers/diff-helpers"; +} from "../../../../../test/helpers/diff-helpers"; describe("measureDiffSectionGeometry", () => { const theme = resolveTheme("github-dark-default", null); diff --git a/src/ui/diff/diffSectionGeometry.ts b/packages/hunk/src/ui/diff/diffSectionGeometry.ts similarity index 100% rename from src/ui/diff/diffSectionGeometry.ts rename to packages/hunk/src/ui/diff/diffSectionGeometry.ts diff --git a/src/ui/diff/diffSectionRowPlan.ts b/packages/hunk/src/ui/diff/diffSectionRowPlan.ts similarity index 100% rename from src/ui/diff/diffSectionRowPlan.ts rename to packages/hunk/src/ui/diff/diffSectionRowPlan.ts diff --git a/src/ui/diff/expandCollapsedRows.test.ts b/packages/hunk/src/ui/diff/expandCollapsedRows.test.ts similarity index 100% rename from src/ui/diff/expandCollapsedRows.test.ts rename to packages/hunk/src/ui/diff/expandCollapsedRows.test.ts diff --git a/src/ui/diff/expandCollapsedRows.ts b/packages/hunk/src/ui/diff/expandCollapsedRows.ts similarity index 100% rename from src/ui/diff/expandCollapsedRows.ts rename to packages/hunk/src/ui/diff/expandCollapsedRows.ts diff --git a/src/ui/diff/highlightedDiffCache.test.ts b/packages/hunk/src/ui/diff/highlightedDiffCache.test.ts similarity index 100% rename from src/ui/diff/highlightedDiffCache.test.ts rename to packages/hunk/src/ui/diff/highlightedDiffCache.test.ts diff --git a/src/ui/diff/highlightedDiffCache.ts b/packages/hunk/src/ui/diff/highlightedDiffCache.ts similarity index 100% rename from src/ui/diff/highlightedDiffCache.ts rename to packages/hunk/src/ui/diff/highlightedDiffCache.ts diff --git a/src/ui/diff/lineHighlightPaint.test.ts b/packages/hunk/src/ui/diff/lineHighlightPaint.test.ts similarity index 99% rename from src/ui/diff/lineHighlightPaint.test.ts rename to packages/hunk/src/ui/diff/lineHighlightPaint.test.ts index ed9786e41..75477bb60 100644 --- a/src/ui/diff/lineHighlightPaint.test.ts +++ b/packages/hunk/src/ui/diff/lineHighlightPaint.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; import { DEFAULT_TAB_WIDTH } from "../../core/run/tabWidth"; import type { ValidatedLineHighlight } from "../highlights/validate"; import { measureTextWidth } from "../lib/text"; diff --git a/src/ui/diff/lineHighlightPaint.ts b/packages/hunk/src/ui/diff/lineHighlightPaint.ts similarity index 100% rename from src/ui/diff/lineHighlightPaint.ts rename to packages/hunk/src/ui/diff/lineHighlightPaint.ts diff --git a/src/ui/diff/plannedRowText.ts b/packages/hunk/src/ui/diff/plannedRowText.ts similarity index 100% rename from src/ui/diff/plannedRowText.ts rename to packages/hunk/src/ui/diff/plannedRowText.ts diff --git a/src/ui/diff/reviewRenderPlan.test.ts b/packages/hunk/src/ui/diff/reviewRenderPlan.test.ts similarity index 100% rename from src/ui/diff/reviewRenderPlan.test.ts rename to packages/hunk/src/ui/diff/reviewRenderPlan.test.ts diff --git a/src/ui/diff/reviewRenderPlan.ts b/packages/hunk/src/ui/diff/reviewRenderPlan.ts similarity index 100% rename from src/ui/diff/reviewRenderPlan.ts rename to packages/hunk/src/ui/diff/reviewRenderPlan.ts diff --git a/src/ui/diff/reviewRowGeometry.test.ts b/packages/hunk/src/ui/diff/reviewRowGeometry.test.ts similarity index 100% rename from src/ui/diff/reviewRowGeometry.test.ts rename to packages/hunk/src/ui/diff/reviewRowGeometry.test.ts diff --git a/src/ui/diff/reviewRowGeometry.ts b/packages/hunk/src/ui/diff/reviewRowGeometry.ts similarity index 100% rename from src/ui/diff/reviewRowGeometry.ts rename to packages/hunk/src/ui/diff/reviewRowGeometry.ts diff --git a/src/ui/diff/rowMouseActions.ts b/packages/hunk/src/ui/diff/rowMouseActions.ts similarity index 100% rename from src/ui/diff/rowMouseActions.ts rename to packages/hunk/src/ui/diff/rowMouseActions.ts diff --git a/src/ui/diff/rowStyle.test.ts b/packages/hunk/src/ui/diff/rowStyle.test.ts similarity index 100% rename from src/ui/diff/rowStyle.test.ts rename to packages/hunk/src/ui/diff/rowStyle.test.ts diff --git a/src/ui/diff/rowStyle.ts b/packages/hunk/src/ui/diff/rowStyle.ts similarity index 100% rename from src/ui/diff/rowStyle.ts rename to packages/hunk/src/ui/diff/rowStyle.ts diff --git a/src/ui/diff/rowWindowing.test.ts b/packages/hunk/src/ui/diff/rowWindowing.test.ts similarity index 100% rename from src/ui/diff/rowWindowing.test.ts rename to packages/hunk/src/ui/diff/rowWindowing.test.ts diff --git a/src/ui/diff/rowWindowing.ts b/packages/hunk/src/ui/diff/rowWindowing.ts similarity index 100% rename from src/ui/diff/rowWindowing.ts rename to packages/hunk/src/ui/diff/rowWindowing.ts diff --git a/src/ui/diff/sourceBackedHighlight.test.ts b/packages/hunk/src/ui/diff/sourceBackedHighlight.test.ts similarity index 100% rename from src/ui/diff/sourceBackedHighlight.test.ts rename to packages/hunk/src/ui/diff/sourceBackedHighlight.test.ts diff --git a/src/ui/diff/sourceBackedHighlight.ts b/packages/hunk/src/ui/diff/sourceBackedHighlight.ts similarity index 100% rename from src/ui/diff/sourceBackedHighlight.ts rename to packages/hunk/src/ui/diff/sourceBackedHighlight.ts diff --git a/src/ui/diff/styledSpanLayout.ts b/packages/hunk/src/ui/diff/styledSpanLayout.ts similarity index 100% rename from src/ui/diff/styledSpanLayout.ts rename to packages/hunk/src/ui/diff/styledSpanLayout.ts diff --git a/src/ui/diff/syntaxHighlightTheme.test.ts b/packages/hunk/src/ui/diff/syntaxHighlightTheme.test.ts similarity index 91% rename from src/ui/diff/syntaxHighlightTheme.test.ts rename to packages/hunk/src/ui/diff/syntaxHighlightTheme.test.ts index e83c1f414..def833269 100644 --- a/src/ui/diff/syntaxHighlightTheme.test.ts +++ b/packages/hunk/src/ui/diff/syntaxHighlightTheme.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { resolveTheme } from "../themes"; -import { createTestCustomThemes } from "../../../test/helpers/theme-helpers"; +import { createTestCustomThemes } from "../../../../../test/helpers/theme-helpers"; import { syntaxHighlightThemeName } from "./syntaxHighlightTheme"; describe("syntaxHighlightThemeName", () => { diff --git a/src/ui/diff/syntaxHighlightTheme.ts b/packages/hunk/src/ui/diff/syntaxHighlightTheme.ts similarity index 100% rename from src/ui/diff/syntaxHighlightTheme.ts rename to packages/hunk/src/ui/diff/syntaxHighlightTheme.ts diff --git a/src/ui/diff/useHighlightedDiff.test.ts b/packages/hunk/src/ui/diff/useHighlightedDiff.test.ts similarity index 98% rename from src/ui/diff/useHighlightedDiff.test.ts rename to packages/hunk/src/ui/diff/useHighlightedDiff.test.ts index 3a9e0bdb5..3bd53d1be 100644 --- a/src/ui/diff/useHighlightedDiff.test.ts +++ b/packages/hunk/src/ui/diff/useHighlightedDiff.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, createTestSourceFetcher } from "../../../test/helpers/diff-helpers"; +import { + createTestDiffFile, + createTestSourceFetcher, +} from "../../../../../test/helpers/diff-helpers"; import { resolveTheme } from "../themes"; import { HIGHLIGHT_WORKER_MIN_LINES } from "./diffRows"; import { prefetchHighlightedDiff, highlightedDiffCacheKey } from "./useHighlightedDiff"; diff --git a/src/ui/diff/useHighlightedDiff.ts b/packages/hunk/src/ui/diff/useHighlightedDiff.ts similarity index 100% rename from src/ui/diff/useHighlightedDiff.ts rename to packages/hunk/src/ui/diff/useHighlightedDiff.ts diff --git a/src/ui/diff/useHighlightedSource.ts b/packages/hunk/src/ui/diff/useHighlightedSource.ts similarity index 100% rename from src/ui/diff/useHighlightedSource.ts rename to packages/hunk/src/ui/diff/useHighlightedSource.ts diff --git a/src/ui/diff/worker/highlightCompact.test.ts b/packages/hunk/src/ui/diff/worker/highlightCompact.test.ts similarity index 98% rename from src/ui/diff/worker/highlightCompact.test.ts rename to packages/hunk/src/ui/diff/worker/highlightCompact.test.ts index fa8d639e1..525daad3e 100644 --- a/src/ui/diff/worker/highlightCompact.test.ts +++ b/packages/hunk/src/ui/diff/worker/highlightCompact.test.ts @@ -13,7 +13,7 @@ import { } from "./highlightCompact"; import { collectHastHighlightRuns, type HastNode } from "./highlightHast"; import { resolveTheme } from "../../themes"; -import { createTestSourceFetcher } from "../../../../test/helpers/diff-helpers"; +import { createTestSourceFetcher } from "../../../../../../test/helpers/diff-helpers"; /** Build a regular changed file with tabs and word-diff emphasis. */ function createDiffFile(): DiffFile { diff --git a/src/ui/diff/worker/highlightCompact.ts b/packages/hunk/src/ui/diff/worker/highlightCompact.ts similarity index 100% rename from src/ui/diff/worker/highlightCompact.ts rename to packages/hunk/src/ui/diff/worker/highlightCompact.ts diff --git a/src/ui/diff/worker/highlightContext.ts b/packages/hunk/src/ui/diff/worker/highlightContext.ts similarity index 100% rename from src/ui/diff/worker/highlightContext.ts rename to packages/hunk/src/ui/diff/worker/highlightContext.ts diff --git a/src/ui/diff/worker/highlightHast.ts b/packages/hunk/src/ui/diff/worker/highlightHast.ts similarity index 100% rename from src/ui/diff/worker/highlightHast.ts rename to packages/hunk/src/ui/diff/worker/highlightHast.ts diff --git a/src/ui/diff/worker/highlightWorker.ts b/packages/hunk/src/ui/diff/worker/highlightWorker.ts similarity index 100% rename from src/ui/diff/worker/highlightWorker.ts rename to packages/hunk/src/ui/diff/worker/highlightWorker.ts diff --git a/src/ui/diff/worker/highlightWorkerCache.test.ts b/packages/hunk/src/ui/diff/worker/highlightWorkerCache.test.ts similarity index 100% rename from src/ui/diff/worker/highlightWorkerCache.test.ts rename to packages/hunk/src/ui/diff/worker/highlightWorkerCache.test.ts diff --git a/src/ui/diff/worker/highlightWorkerCache.ts b/packages/hunk/src/ui/diff/worker/highlightWorkerCache.ts similarity index 100% rename from src/ui/diff/worker/highlightWorkerCache.ts rename to packages/hunk/src/ui/diff/worker/highlightWorkerCache.ts diff --git a/src/ui/diff/worker/highlightWorkerClient.test.ts b/packages/hunk/src/ui/diff/worker/highlightWorkerClient.test.ts similarity index 98% rename from src/ui/diff/worker/highlightWorkerClient.test.ts rename to packages/hunk/src/ui/diff/worker/highlightWorkerClient.test.ts index 46556a170..6f8a829f1 100644 --- a/src/ui/diff/worker/highlightWorkerClient.test.ts +++ b/packages/hunk/src/ui/diff/worker/highlightWorkerClient.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../../test/helpers/diff-helpers"; import { supportsHighlightWorkerOffload } from "../../../highlightWorkerClient"; import type { CompactHighlightedDiff } from "./highlightCompact"; import { diff --git a/src/ui/diff/worker/highlightWorkerClient.ts b/packages/hunk/src/ui/diff/worker/highlightWorkerClient.ts similarity index 100% rename from src/ui/diff/worker/highlightWorkerClient.ts rename to packages/hunk/src/ui/diff/worker/highlightWorkerClient.ts diff --git a/src/ui/diff/worker/highlightWorkerIdentity.test.ts b/packages/hunk/src/ui/diff/worker/highlightWorkerIdentity.test.ts similarity index 100% rename from src/ui/diff/worker/highlightWorkerIdentity.test.ts rename to packages/hunk/src/ui/diff/worker/highlightWorkerIdentity.test.ts diff --git a/src/ui/diff/worker/highlightWorkerIdentity.ts b/packages/hunk/src/ui/diff/worker/highlightWorkerIdentity.ts similarity index 100% rename from src/ui/diff/worker/highlightWorkerIdentity.ts rename to packages/hunk/src/ui/diff/worker/highlightWorkerIdentity.ts diff --git a/src/ui/diff/worker/index.ts b/packages/hunk/src/ui/diff/worker/index.ts similarity index 100% rename from src/ui/diff/worker/index.ts rename to packages/hunk/src/ui/diff/worker/index.ts diff --git a/src/ui/fileViews/availability.test.ts b/packages/hunk/src/ui/fileViews/availability.test.ts similarity index 100% rename from src/ui/fileViews/availability.test.ts rename to packages/hunk/src/ui/fileViews/availability.test.ts diff --git a/src/ui/fileViews/availability.ts b/packages/hunk/src/ui/fileViews/availability.ts similarity index 100% rename from src/ui/fileViews/availability.ts rename to packages/hunk/src/ui/fileViews/availability.ts diff --git a/src/ui/fileViews/geometry.test.ts b/packages/hunk/src/ui/fileViews/geometry.test.ts similarity index 100% rename from src/ui/fileViews/geometry.test.ts rename to packages/hunk/src/ui/fileViews/geometry.test.ts diff --git a/src/ui/fileViews/geometry.ts b/packages/hunk/src/ui/fileViews/geometry.ts similarity index 100% rename from src/ui/fileViews/geometry.ts rename to packages/hunk/src/ui/fileViews/geometry.ts diff --git a/src/ui/fileViews/host.test.ts b/packages/hunk/src/ui/fileViews/host.test.ts similarity index 98% rename from src/ui/fileViews/host.test.ts rename to packages/hunk/src/ui/fileViews/host.test.ts index 9f31c92db..adbfd28a1 100644 --- a/src/ui/fileViews/host.test.ts +++ b/packages/hunk/src/ui/fileViews/host.test.ts @@ -3,7 +3,7 @@ import { createTestDeferred, createTestDiffFile, createTestSourceFetcher, -} from "../../../test/helpers/diff-helpers"; +} from "../../../../../test/helpers/diff-helpers"; import { createFileViewInput, createFileViewInputSnapshot, fileViewChanges } from "./host"; describe("file-view host input", () => { diff --git a/src/ui/fileViews/host.ts b/packages/hunk/src/ui/fileViews/host.ts similarity index 100% rename from src/ui/fileViews/host.ts rename to packages/hunk/src/ui/fileViews/host.ts diff --git a/src/ui/fileViews/layout.test.ts b/packages/hunk/src/ui/fileViews/layout.test.ts similarity index 100% rename from src/ui/fileViews/layout.test.ts rename to packages/hunk/src/ui/fileViews/layout.test.ts diff --git a/src/ui/fileViews/layout.ts b/packages/hunk/src/ui/fileViews/layout.ts similarity index 100% rename from src/ui/fileViews/layout.ts rename to packages/hunk/src/ui/fileViews/layout.ts diff --git a/src/ui/fileViews/mode.test.ts b/packages/hunk/src/ui/fileViews/mode.test.ts similarity index 100% rename from src/ui/fileViews/mode.test.ts rename to packages/hunk/src/ui/fileViews/mode.test.ts diff --git a/src/ui/fileViews/mode.ts b/packages/hunk/src/ui/fileViews/mode.ts similarity index 100% rename from src/ui/fileViews/mode.ts rename to packages/hunk/src/ui/fileViews/mode.ts diff --git a/src/ui/fileViews/renderPlan.test.ts b/packages/hunk/src/ui/fileViews/renderPlan.test.ts similarity index 100% rename from src/ui/fileViews/renderPlan.test.ts rename to packages/hunk/src/ui/fileViews/renderPlan.test.ts diff --git a/src/ui/fileViews/renderPlan.ts b/packages/hunk/src/ui/fileViews/renderPlan.ts similarity index 100% rename from src/ui/fileViews/renderPlan.ts rename to packages/hunk/src/ui/fileViews/renderPlan.ts diff --git a/src/ui/fileViews/state.test.ts b/packages/hunk/src/ui/fileViews/state.test.ts similarity index 100% rename from src/ui/fileViews/state.test.ts rename to packages/hunk/src/ui/fileViews/state.test.ts diff --git a/src/ui/fileViews/state.ts b/packages/hunk/src/ui/fileViews/state.ts similarity index 100% rename from src/ui/fileViews/state.ts rename to packages/hunk/src/ui/fileViews/state.ts diff --git a/src/ui/fileViews/types.ts b/packages/hunk/src/ui/fileViews/types.ts similarity index 100% rename from src/ui/fileViews/types.ts rename to packages/hunk/src/ui/fileViews/types.ts diff --git a/src/ui/fileViews/useFilePresentationController.test.tsx b/packages/hunk/src/ui/fileViews/useFilePresentationController.test.tsx similarity index 99% rename from src/ui/fileViews/useFilePresentationController.test.tsx rename to packages/hunk/src/ui/fileViews/useFilePresentationController.test.tsx index 663979523..f27ab7ff7 100644 --- a/src/ui/fileViews/useFilePresentationController.test.tsx +++ b/packages/hunk/src/ui/fileViews/useFilePresentationController.test.tsx @@ -4,7 +4,7 @@ import { act, useCallback, useMemo, useRef, useState } from "react"; import type { DiffFile } from "../../core/changeset/model"; import { toReadOnlyFileViews } from "../../extensions/events"; import type { RegisteredFileView } from "../../extensions/types"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { buildExtensionReviewSelection } from "../lib/extensionSelection"; import { registeredFileViewKey } from "./state"; import { useFilePresentationController } from "./useFilePresentationController"; diff --git a/src/ui/fileViews/useFilePresentationController.ts b/packages/hunk/src/ui/fileViews/useFilePresentationController.ts similarity index 100% rename from src/ui/fileViews/useFilePresentationController.ts rename to packages/hunk/src/ui/fileViews/useFilePresentationController.ts diff --git a/src/ui/fileViews/useFilePresentationRendering.test.tsx b/packages/hunk/src/ui/fileViews/useFilePresentationRendering.test.tsx similarity index 98% rename from src/ui/fileViews/useFilePresentationRendering.test.tsx rename to packages/hunk/src/ui/fileViews/useFilePresentationRendering.test.tsx index 0df1451e1..48f029adb 100644 --- a/src/ui/fileViews/useFilePresentationRendering.test.tsx +++ b/packages/hunk/src/ui/fileViews/useFilePresentationRendering.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, useState } from "react"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import type { RegisteredFileView } from "../../extensions/types"; import { registeredFileViewKey } from "./state"; import type { FileViewRowFailure } from "./types"; diff --git a/src/ui/fileViews/useFilePresentationRendering.ts b/packages/hunk/src/ui/fileViews/useFilePresentationRendering.ts similarity index 100% rename from src/ui/fileViews/useFilePresentationRendering.ts rename to packages/hunk/src/ui/fileViews/useFilePresentationRendering.ts diff --git a/src/ui/fileViews/useFileViews.test.tsx b/packages/hunk/src/ui/fileViews/useFileViews.test.tsx similarity index 99% rename from src/ui/fileViews/useFileViews.test.tsx rename to packages/hunk/src/ui/fileViews/useFileViews.test.tsx index f936f1fa5..734e7e78e 100644 --- a/src/ui/fileViews/useFileViews.test.tsx +++ b/packages/hunk/src/ui/fileViews/useFileViews.test.tsx @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, createElement, useState } from "react"; -import { createTestDiffFile, createTestSourceFetcher } from "../../../test/helpers/diff-helpers"; +import { + createTestDiffFile, + createTestSourceFetcher, +} from "../../../../../test/helpers/diff-helpers"; import type { RegisteredFileView } from "../../extensions/types"; import { bumpFileViewEpoch, registeredFileViewKey, type FileViewEpochState } from "./state"; import { diff --git a/src/ui/fileViews/useFileViews.ts b/packages/hunk/src/ui/fileViews/useFileViews.ts similarity index 100% rename from src/ui/fileViews/useFileViews.ts rename to packages/hunk/src/ui/fileViews/useFileViews.ts diff --git a/src/ui/highlights/merge.test.ts b/packages/hunk/src/ui/highlights/merge.test.ts similarity index 100% rename from src/ui/highlights/merge.test.ts rename to packages/hunk/src/ui/highlights/merge.test.ts diff --git a/src/ui/highlights/merge.ts b/packages/hunk/src/ui/highlights/merge.ts similarity index 100% rename from src/ui/highlights/merge.ts rename to packages/hunk/src/ui/highlights/merge.ts diff --git a/src/ui/highlights/reconcile.test.ts b/packages/hunk/src/ui/highlights/reconcile.test.ts similarity index 96% rename from src/ui/highlights/reconcile.test.ts rename to packages/hunk/src/ui/highlights/reconcile.test.ts index 3058eedff..f1c7148cd 100644 --- a/src/ui/highlights/reconcile.test.ts +++ b/packages/hunk/src/ui/highlights/reconcile.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { ReviewDocumentV1 } from "../../core/review/types"; -import { createTestReviewDocument } from "../../../test/helpers/review-store-helpers"; +import { createTestReviewDocument } from "../../../../../test/helpers/review-store-helpers"; import type { ValidatedLineHighlight } from "./validate"; import { carryOverLineHighlights } from "./reconcile"; diff --git a/src/ui/highlights/reconcile.ts b/packages/hunk/src/ui/highlights/reconcile.ts similarity index 100% rename from src/ui/highlights/reconcile.ts rename to packages/hunk/src/ui/highlights/reconcile.ts diff --git a/src/ui/highlights/state.ts b/packages/hunk/src/ui/highlights/state.ts similarity index 100% rename from src/ui/highlights/state.ts rename to packages/hunk/src/ui/highlights/state.ts diff --git a/src/ui/highlights/useLineHighlights.test.tsx b/packages/hunk/src/ui/highlights/useLineHighlights.test.tsx similarity index 99% rename from src/ui/highlights/useLineHighlights.test.tsx rename to packages/hunk/src/ui/highlights/useLineHighlights.test.tsx index ae1fd2413..d36e23596 100644 --- a/src/ui/highlights/useLineHighlights.test.tsx +++ b/packages/hunk/src/ui/highlights/useLineHighlights.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, createElement, useState } from "react"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import type { ExtensionLineHighlight } from "../../extension-api/types"; import type { RegisteredLineHighlighter } from "../../extensions/types"; import { bumpScopedEpoch } from "../lib/scopedEpochs"; diff --git a/src/ui/highlights/useLineHighlights.ts b/packages/hunk/src/ui/highlights/useLineHighlights.ts similarity index 100% rename from src/ui/highlights/useLineHighlights.ts rename to packages/hunk/src/ui/highlights/useLineHighlights.ts diff --git a/src/ui/highlights/useLineHighlightsController.test.tsx b/packages/hunk/src/ui/highlights/useLineHighlightsController.test.tsx similarity index 98% rename from src/ui/highlights/useLineHighlightsController.test.tsx rename to packages/hunk/src/ui/highlights/useLineHighlightsController.test.tsx index 9ad051580..9ca3971ff 100644 --- a/src/ui/highlights/useLineHighlightsController.test.tsx +++ b/packages/hunk/src/ui/highlights/useLineHighlightsController.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, createElement, useState } from "react"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import type { DiffFile } from "../../core/changeset/model"; import type { RegisteredLineHighlighter } from "../../extensions/types"; import { registeredLineHighlighterKey } from "./state"; diff --git a/src/ui/highlights/useLineHighlightsController.ts b/packages/hunk/src/ui/highlights/useLineHighlightsController.ts similarity index 100% rename from src/ui/highlights/useLineHighlightsController.ts rename to packages/hunk/src/ui/highlights/useLineHighlightsController.ts diff --git a/src/ui/highlights/validate.test.ts b/packages/hunk/src/ui/highlights/validate.test.ts similarity index 100% rename from src/ui/highlights/validate.test.ts rename to packages/hunk/src/ui/highlights/validate.test.ts diff --git a/src/ui/highlights/validate.ts b/packages/hunk/src/ui/highlights/validate.ts similarity index 100% rename from src/ui/highlights/validate.ts rename to packages/hunk/src/ui/highlights/validate.ts diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts similarity index 100% rename from src/ui/hooks/useAppKeyboardShortcuts.ts rename to packages/hunk/src/ui/hooks/useAppKeyboardShortcuts.ts diff --git a/src/ui/hooks/useCurrentReviewRefreshController.test.tsx b/packages/hunk/src/ui/hooks/useCurrentReviewRefreshController.test.tsx similarity index 99% rename from src/ui/hooks/useCurrentReviewRefreshController.test.tsx rename to packages/hunk/src/ui/hooks/useCurrentReviewRefreshController.test.tsx index 13d1c5626..023ac86b5 100644 --- a/src/ui/hooks/useCurrentReviewRefreshController.test.tsx +++ b/packages/hunk/src/ui/hooks/useCurrentReviewRefreshController.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, useState } from "react"; -import { createWatchTestRuntime } from "../../../test/helpers/watchTest"; +import { createWatchTestRuntime } from "../../../../../test/helpers/watchTest"; import type { CliInput } from "../../core/run/commandInputs"; import type { ReloadSessionOptions, ReloadedSessionResult } from "../../session/types"; import type { WorkspaceRefreshRequest } from "../currentReviewRefresh"; diff --git a/src/ui/hooks/useCurrentReviewRefreshController.ts b/packages/hunk/src/ui/hooks/useCurrentReviewRefreshController.ts similarity index 100% rename from src/ui/hooks/useCurrentReviewRefreshController.ts rename to packages/hunk/src/ui/hooks/useCurrentReviewRefreshController.ts diff --git a/src/ui/hooks/useExtensionCommandRunner.test.tsx b/packages/hunk/src/ui/hooks/useExtensionCommandRunner.test.tsx similarity index 100% rename from src/ui/hooks/useExtensionCommandRunner.test.tsx rename to packages/hunk/src/ui/hooks/useExtensionCommandRunner.test.tsx diff --git a/src/ui/hooks/useExtensionCommandRunner.ts b/packages/hunk/src/ui/hooks/useExtensionCommandRunner.ts similarity index 100% rename from src/ui/hooks/useExtensionCommandRunner.ts rename to packages/hunk/src/ui/hooks/useExtensionCommandRunner.ts diff --git a/src/ui/hooks/useExtensionDialogController.test.tsx b/packages/hunk/src/ui/hooks/useExtensionDialogController.test.tsx similarity index 100% rename from src/ui/hooks/useExtensionDialogController.test.tsx rename to packages/hunk/src/ui/hooks/useExtensionDialogController.test.tsx diff --git a/src/ui/hooks/useExtensionDialogController.ts b/packages/hunk/src/ui/hooks/useExtensionDialogController.ts similarity index 100% rename from src/ui/hooks/useExtensionDialogController.ts rename to packages/hunk/src/ui/hooks/useExtensionDialogController.ts diff --git a/src/ui/hooks/useExtensionEventContextProvider.test.tsx b/packages/hunk/src/ui/hooks/useExtensionEventContextProvider.test.tsx similarity index 100% rename from src/ui/hooks/useExtensionEventContextProvider.test.tsx rename to packages/hunk/src/ui/hooks/useExtensionEventContextProvider.test.tsx diff --git a/src/ui/hooks/useExtensionEventContextProvider.ts b/packages/hunk/src/ui/hooks/useExtensionEventContextProvider.ts similarity index 100% rename from src/ui/hooks/useExtensionEventContextProvider.ts rename to packages/hunk/src/ui/hooks/useExtensionEventContextProvider.ts diff --git a/src/ui/hooks/useExtensionNotifications.test.tsx b/packages/hunk/src/ui/hooks/useExtensionNotifications.test.tsx similarity index 100% rename from src/ui/hooks/useExtensionNotifications.test.tsx rename to packages/hunk/src/ui/hooks/useExtensionNotifications.test.tsx diff --git a/src/ui/hooks/useExtensionNotifications.ts b/packages/hunk/src/ui/hooks/useExtensionNotifications.ts similarity index 100% rename from src/ui/hooks/useExtensionNotifications.ts rename to packages/hunk/src/ui/hooks/useExtensionNotifications.ts diff --git a/src/ui/hooks/useExtensionPaneController.test.tsx b/packages/hunk/src/ui/hooks/useExtensionPaneController.test.tsx similarity index 100% rename from src/ui/hooks/useExtensionPaneController.test.tsx rename to packages/hunk/src/ui/hooks/useExtensionPaneController.test.tsx diff --git a/src/ui/hooks/useExtensionPaneController.ts b/packages/hunk/src/ui/hooks/useExtensionPaneController.ts similarity index 100% rename from src/ui/hooks/useExtensionPaneController.ts rename to packages/hunk/src/ui/hooks/useExtensionPaneController.ts diff --git a/src/ui/hooks/useExtensionReviewEvents.test.tsx b/packages/hunk/src/ui/hooks/useExtensionReviewEvents.test.tsx similarity index 99% rename from src/ui/hooks/useExtensionReviewEvents.test.tsx rename to packages/hunk/src/ui/hooks/useExtensionReviewEvents.test.tsx index c7f136872..b2e443c5d 100644 --- a/src/ui/hooks/useExtensionReviewEvents.test.tsx +++ b/packages/hunk/src/ui/hooks/useExtensionReviewEvents.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, Activity, StrictMode, useLayoutEffect, useState } from "react"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import type { ExtensionEventPayloads, ExtensionLayoutMode, diff --git a/src/ui/hooks/useExtensionReviewEvents.ts b/packages/hunk/src/ui/hooks/useExtensionReviewEvents.ts similarity index 100% rename from src/ui/hooks/useExtensionReviewEvents.ts rename to packages/hunk/src/ui/hooks/useExtensionReviewEvents.ts diff --git a/src/ui/hooks/useExtensionRuntimeBridge.test.tsx b/packages/hunk/src/ui/hooks/useExtensionRuntimeBridge.test.tsx similarity index 97% rename from src/ui/hooks/useExtensionRuntimeBridge.test.tsx rename to packages/hunk/src/ui/hooks/useExtensionRuntimeBridge.test.tsx index db480c88f..d6f4708f6 100644 --- a/src/ui/hooks/useExtensionRuntimeBridge.test.tsx +++ b/packages/hunk/src/ui/hooks/useExtensionRuntimeBridge.test.tsx @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act, StrictMode, useState } from "react"; -import { createTestVcsAppBootstrap } from "../../../test/helpers/app-bootstrap"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; -import { createTestReviewState } from "../../../test/helpers/review-store-helpers"; +import { createTestVcsAppBootstrap } from "../../../../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; +import { createTestReviewState } from "../../../../../test/helpers/review-store-helpers"; import type { AppBootstrap } from "../../core/bootstrap"; import type { DiffFile } from "../../core/changeset/model"; import { createEmptyExtensionLoadResult, type ExtensionLoadResult } from "../../extensions/types"; diff --git a/src/ui/hooks/useExtensionRuntimeBridge.ts b/packages/hunk/src/ui/hooks/useExtensionRuntimeBridge.ts similarity index 100% rename from src/ui/hooks/useExtensionRuntimeBridge.ts rename to packages/hunk/src/ui/hooks/useExtensionRuntimeBridge.ts diff --git a/src/ui/hooks/useExtensionTrustController.test.tsx b/packages/hunk/src/ui/hooks/useExtensionTrustController.test.tsx similarity index 100% rename from src/ui/hooks/useExtensionTrustController.test.tsx rename to packages/hunk/src/ui/hooks/useExtensionTrustController.test.tsx diff --git a/src/ui/hooks/useExtensionTrustController.ts b/packages/hunk/src/ui/hooks/useExtensionTrustController.ts similarity index 100% rename from src/ui/hooks/useExtensionTrustController.ts rename to packages/hunk/src/ui/hooks/useExtensionTrustController.ts diff --git a/src/ui/hooks/useExtensionWorkspaceControls.test.tsx b/packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.test.tsx similarity index 100% rename from src/ui/hooks/useExtensionWorkspaceControls.test.tsx rename to packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.test.tsx diff --git a/src/ui/hooks/useExtensionWorkspaceControls.ts b/packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.ts similarity index 100% rename from src/ui/hooks/useExtensionWorkspaceControls.ts rename to packages/hunk/src/ui/hooks/useExtensionWorkspaceControls.ts diff --git a/src/ui/hooks/useHunkSessionBridge.ts b/packages/hunk/src/ui/hooks/useHunkSessionBridge.ts similarity index 100% rename from src/ui/hooks/useHunkSessionBridge.ts rename to packages/hunk/src/ui/hooks/useHunkSessionBridge.ts diff --git a/src/ui/hooks/useMenuController.test.tsx b/packages/hunk/src/ui/hooks/useMenuController.test.tsx similarity index 100% rename from src/ui/hooks/useMenuController.test.tsx rename to packages/hunk/src/ui/hooks/useMenuController.test.tsx diff --git a/src/ui/hooks/useMenuController.ts b/packages/hunk/src/ui/hooks/useMenuController.ts similarity index 100% rename from src/ui/hooks/useMenuController.ts rename to packages/hunk/src/ui/hooks/useMenuController.ts diff --git a/src/ui/hooks/useStartupNotices.test.tsx b/packages/hunk/src/ui/hooks/useStartupNotices.test.tsx similarity index 100% rename from src/ui/hooks/useStartupNotices.test.tsx rename to packages/hunk/src/ui/hooks/useStartupNotices.test.tsx diff --git a/src/ui/hooks/useStartupNotices.ts b/packages/hunk/src/ui/hooks/useStartupNotices.ts similarity index 100% rename from src/ui/hooks/useStartupNotices.ts rename to packages/hunk/src/ui/hooks/useStartupNotices.ts diff --git a/src/ui/hooks/useTerminalReview.test.tsx b/packages/hunk/src/ui/hooks/useTerminalReview.test.tsx similarity index 99% rename from src/ui/hooks/useTerminalReview.test.tsx rename to packages/hunk/src/ui/hooks/useTerminalReview.test.tsx index ed9cf33ee..9cb683869 100644 --- a/src/ui/hooks/useTerminalReview.test.tsx +++ b/packages/hunk/src/ui/hooks/useTerminalReview.test.tsx @@ -9,7 +9,7 @@ import { createTestDiffFile, createTestSourceFetcher, lines, -} from "../../../test/helpers/diff-helpers"; +} from "../../../../../test/helpers/diff-helpers"; import { measureDiffSectionGeometry } from "../diff/diffSectionGeometry"; import { buildLineCursors, type LineCursor } from "../lib/lineCursors"; import { resolveTheme } from "../themes"; diff --git a/src/ui/hooks/useTerminalReview.ts b/packages/hunk/src/ui/hooks/useTerminalReview.ts similarity index 100% rename from src/ui/hooks/useTerminalReview.ts rename to packages/hunk/src/ui/hooks/useTerminalReview.ts diff --git a/src/ui/hooks/useThemeSelectorController.test.tsx b/packages/hunk/src/ui/hooks/useThemeSelectorController.test.tsx similarity index 100% rename from src/ui/hooks/useThemeSelectorController.test.tsx rename to packages/hunk/src/ui/hooks/useThemeSelectorController.test.tsx diff --git a/src/ui/hooks/useThemeSelectorController.ts b/packages/hunk/src/ui/hooks/useThemeSelectorController.ts similarity index 100% rename from src/ui/hooks/useThemeSelectorController.ts rename to packages/hunk/src/ui/hooks/useThemeSelectorController.ts diff --git a/src/ui/hooks/useTimedNotice.test.tsx b/packages/hunk/src/ui/hooks/useTimedNotice.test.tsx similarity index 100% rename from src/ui/hooks/useTimedNotice.test.tsx rename to packages/hunk/src/ui/hooks/useTimedNotice.test.tsx diff --git a/src/ui/hooks/useTimedNotice.ts b/packages/hunk/src/ui/hooks/useTimedNotice.ts similarity index 100% rename from src/ui/hooks/useTimedNotice.ts rename to packages/hunk/src/ui/hooks/useTimedNotice.ts diff --git a/src/ui/hooks/useUserNoteComposer.test.tsx b/packages/hunk/src/ui/hooks/useUserNoteComposer.test.tsx similarity index 100% rename from src/ui/hooks/useUserNoteComposer.test.tsx rename to packages/hunk/src/ui/hooks/useUserNoteComposer.test.tsx diff --git a/src/ui/hooks/useUserNoteComposer.ts b/packages/hunk/src/ui/hooks/useUserNoteComposer.ts similarity index 100% rename from src/ui/hooks/useUserNoteComposer.ts rename to packages/hunk/src/ui/hooks/useUserNoteComposer.ts diff --git a/src/ui/hooks/useViewPreferenceQuitController.test.tsx b/packages/hunk/src/ui/hooks/useViewPreferenceQuitController.test.tsx similarity index 100% rename from src/ui/hooks/useViewPreferenceQuitController.test.tsx rename to packages/hunk/src/ui/hooks/useViewPreferenceQuitController.test.tsx diff --git a/src/ui/hooks/useViewPreferenceQuitController.ts b/packages/hunk/src/ui/hooks/useViewPreferenceQuitController.ts similarity index 100% rename from src/ui/hooks/useViewPreferenceQuitController.ts rename to packages/hunk/src/ui/hooks/useViewPreferenceQuitController.ts diff --git a/src/ui/hooks/useWatchedInput.ts b/packages/hunk/src/ui/hooks/useWatchedInput.ts similarity index 100% rename from src/ui/hooks/useWatchedInput.ts rename to packages/hunk/src/ui/hooks/useWatchedInput.ts diff --git a/src/ui/keyboardModes/mode.test.ts b/packages/hunk/src/ui/keyboardModes/mode.test.ts similarity index 100% rename from src/ui/keyboardModes/mode.test.ts rename to packages/hunk/src/ui/keyboardModes/mode.test.ts diff --git a/src/ui/keyboardModes/mode.ts b/packages/hunk/src/ui/keyboardModes/mode.ts similarity index 100% rename from src/ui/keyboardModes/mode.ts rename to packages/hunk/src/ui/keyboardModes/mode.ts diff --git a/src/ui/keyboardModes/useKeyboardModeController.test.tsx b/packages/hunk/src/ui/keyboardModes/useKeyboardModeController.test.tsx similarity index 100% rename from src/ui/keyboardModes/useKeyboardModeController.test.tsx rename to packages/hunk/src/ui/keyboardModes/useKeyboardModeController.test.tsx diff --git a/src/ui/keyboardModes/useKeyboardModeController.ts b/packages/hunk/src/ui/keyboardModes/useKeyboardModeController.ts similarity index 100% rename from src/ui/keyboardModes/useKeyboardModeController.ts rename to packages/hunk/src/ui/keyboardModes/useKeyboardModeController.ts diff --git a/src/ui/lib/adaptiveScrollOverscan.test.ts b/packages/hunk/src/ui/lib/adaptiveScrollOverscan.test.ts similarity index 100% rename from src/ui/lib/adaptiveScrollOverscan.test.ts rename to packages/hunk/src/ui/lib/adaptiveScrollOverscan.test.ts diff --git a/src/ui/lib/adaptiveScrollOverscan.ts b/packages/hunk/src/ui/lib/adaptiveScrollOverscan.ts similarity index 100% rename from src/ui/lib/adaptiveScrollOverscan.ts rename to packages/hunk/src/ui/lib/adaptiveScrollOverscan.ts diff --git a/src/ui/lib/agentAnnotations.test.ts b/packages/hunk/src/ui/lib/agentAnnotations.test.ts similarity index 97% rename from src/ui/lib/agentAnnotations.test.ts rename to packages/hunk/src/ui/lib/agentAnnotations.test.ts index 04548a784..4946ec59d 100644 --- a/src/ui/lib/agentAnnotations.test.ts +++ b/packages/hunk/src/ui/lib/agentAnnotations.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { reviewAnnotatedHunkIndices } from "../../core/review/annotations"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; import { buildLiveComment, resolveCommentTarget } from "../../core/liveComments"; import { annotationRangeLabel, getSelectedAnnotations, inlineNoteTitle } from "./agentAnnotations"; diff --git a/src/ui/lib/agentAnnotations.ts b/packages/hunk/src/ui/lib/agentAnnotations.ts similarity index 100% rename from src/ui/lib/agentAnnotations.ts rename to packages/hunk/src/ui/lib/agentAnnotations.ts diff --git a/src/ui/lib/agentNoteGeometry.test.ts b/packages/hunk/src/ui/lib/agentNoteGeometry.test.ts similarity index 100% rename from src/ui/lib/agentNoteGeometry.test.ts rename to packages/hunk/src/ui/lib/agentNoteGeometry.test.ts diff --git a/src/ui/lib/agentNoteGeometry.ts b/packages/hunk/src/ui/lib/agentNoteGeometry.ts similarity index 100% rename from src/ui/lib/agentNoteGeometry.ts rename to packages/hunk/src/ui/lib/agentNoteGeometry.ts diff --git a/src/ui/lib/agentPopover.ts b/packages/hunk/src/ui/lib/agentPopover.ts similarity index 100% rename from src/ui/lib/agentPopover.ts rename to packages/hunk/src/ui/lib/agentPopover.ts diff --git a/src/ui/lib/appCommands.test.ts b/packages/hunk/src/ui/lib/appCommands.test.ts similarity index 99% rename from src/ui/lib/appCommands.test.ts rename to packages/hunk/src/ui/lib/appCommands.test.ts index 7e0ddd04b..933fd157e 100644 --- a/src/ui/lib/appCommands.test.ts +++ b/packages/hunk/src/ui/lib/appCommands.test.ts @@ -238,7 +238,10 @@ describe("built-in commands under user keybindings", () => { describe("builtinCommandKeyDefaults", () => { test("keeps the documented command-id table sorted and identical to the runtime catalog", () => { - const markdown = readFileSync(resolve(import.meta.dir, "../../../docs/keybindings.md"), "utf8"); + const markdown = readFileSync( + resolve(import.meta.dir, "../../../../../docs/keybindings.md"), + "utf8", + ); const documentedIds = Array.from( markdown.matchAll(/^\| `(hunk\.[^`]+)`\s+\|/gm), (match) => match[1], diff --git a/src/ui/lib/appCommands.ts b/packages/hunk/src/ui/lib/appCommands.ts similarity index 100% rename from src/ui/lib/appCommands.ts rename to packages/hunk/src/ui/lib/appCommands.ts diff --git a/src/ui/lib/appMenus.test.ts b/packages/hunk/src/ui/lib/appMenus.test.ts similarity index 100% rename from src/ui/lib/appMenus.test.ts rename to packages/hunk/src/ui/lib/appMenus.test.ts diff --git a/src/ui/lib/appMenus.ts b/packages/hunk/src/ui/lib/appMenus.ts similarity index 100% rename from src/ui/lib/appMenus.ts rename to packages/hunk/src/ui/lib/appMenus.ts diff --git a/src/ui/lib/color.ts b/packages/hunk/src/ui/lib/color.ts similarity index 100% rename from src/ui/lib/color.ts rename to packages/hunk/src/ui/lib/color.ts diff --git a/src/ui/lib/diffSpatial.ts b/packages/hunk/src/ui/lib/diffSpatial.ts similarity index 100% rename from src/ui/lib/diffSpatial.ts rename to packages/hunk/src/ui/lib/diffSpatial.ts diff --git a/src/ui/lib/extensionCapabilityLease.test.ts b/packages/hunk/src/ui/lib/extensionCapabilityLease.test.ts similarity index 100% rename from src/ui/lib/extensionCapabilityLease.test.ts rename to packages/hunk/src/ui/lib/extensionCapabilityLease.test.ts diff --git a/src/ui/lib/extensionCapabilityLease.ts b/packages/hunk/src/ui/lib/extensionCapabilityLease.ts similarity index 100% rename from src/ui/lib/extensionCapabilityLease.ts rename to packages/hunk/src/ui/lib/extensionCapabilityLease.ts diff --git a/src/ui/lib/extensionCommandControls.test.ts b/packages/hunk/src/ui/lib/extensionCommandControls.test.ts similarity index 100% rename from src/ui/lib/extensionCommandControls.test.ts rename to packages/hunk/src/ui/lib/extensionCommandControls.test.ts diff --git a/src/ui/lib/extensionCommandControls.ts b/packages/hunk/src/ui/lib/extensionCommandControls.ts similarity index 100% rename from src/ui/lib/extensionCommandControls.ts rename to packages/hunk/src/ui/lib/extensionCommandControls.ts diff --git a/src/ui/lib/extensionCommands.test.ts b/packages/hunk/src/ui/lib/extensionCommands.test.ts similarity index 100% rename from src/ui/lib/extensionCommands.test.ts rename to packages/hunk/src/ui/lib/extensionCommands.test.ts diff --git a/src/ui/lib/extensionCommands.ts b/packages/hunk/src/ui/lib/extensionCommands.ts similarity index 100% rename from src/ui/lib/extensionCommands.ts rename to packages/hunk/src/ui/lib/extensionCommands.ts diff --git a/src/ui/lib/extensionCurrentLine.test.ts b/packages/hunk/src/ui/lib/extensionCurrentLine.test.ts similarity index 98% rename from src/ui/lib/extensionCurrentLine.test.ts rename to packages/hunk/src/ui/lib/extensionCurrentLine.test.ts index 8fae4ffe1..15269d7ef 100644 --- a/src/ui/lib/extensionCurrentLine.test.ts +++ b/packages/hunk/src/ui/lib/extensionCurrentLine.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { buildDiffSectionRowPlan } from "../diff/diffSectionRowPlan"; import { resolveTheme } from "../themes"; import { diff --git a/src/ui/lib/extensionCurrentLine.tsx b/packages/hunk/src/ui/lib/extensionCurrentLine.tsx similarity index 100% rename from src/ui/lib/extensionCurrentLine.tsx rename to packages/hunk/src/ui/lib/extensionCurrentLine.tsx diff --git a/src/ui/lib/extensionDialogGeometry.test.ts b/packages/hunk/src/ui/lib/extensionDialogGeometry.test.ts similarity index 100% rename from src/ui/lib/extensionDialogGeometry.test.ts rename to packages/hunk/src/ui/lib/extensionDialogGeometry.test.ts diff --git a/src/ui/lib/extensionDialogGeometry.ts b/packages/hunk/src/ui/lib/extensionDialogGeometry.ts similarity index 100% rename from src/ui/lib/extensionDialogGeometry.ts rename to packages/hunk/src/ui/lib/extensionDialogGeometry.ts diff --git a/src/ui/lib/extensionDialogs.test.ts b/packages/hunk/src/ui/lib/extensionDialogs.test.ts similarity index 100% rename from src/ui/lib/extensionDialogs.test.ts rename to packages/hunk/src/ui/lib/extensionDialogs.test.ts diff --git a/src/ui/lib/extensionDialogs.ts b/packages/hunk/src/ui/lib/extensionDialogs.ts similarity index 100% rename from src/ui/lib/extensionDialogs.ts rename to packages/hunk/src/ui/lib/extensionDialogs.ts diff --git a/src/ui/lib/extensionDocumentReader.ts b/packages/hunk/src/ui/lib/extensionDocumentReader.ts similarity index 100% rename from src/ui/lib/extensionDocumentReader.ts rename to packages/hunk/src/ui/lib/extensionDocumentReader.ts diff --git a/src/ui/lib/extensionKeyEvent.test.ts b/packages/hunk/src/ui/lib/extensionKeyEvent.test.ts similarity index 100% rename from src/ui/lib/extensionKeyEvent.test.ts rename to packages/hunk/src/ui/lib/extensionKeyEvent.test.ts diff --git a/src/ui/lib/extensionKeyEvent.ts b/packages/hunk/src/ui/lib/extensionKeyEvent.ts similarity index 100% rename from src/ui/lib/extensionKeyEvent.ts rename to packages/hunk/src/ui/lib/extensionKeyEvent.ts diff --git a/src/ui/lib/extensionNavigation.test.ts b/packages/hunk/src/ui/lib/extensionNavigation.test.ts similarity index 100% rename from src/ui/lib/extensionNavigation.test.ts rename to packages/hunk/src/ui/lib/extensionNavigation.test.ts diff --git a/src/ui/lib/extensionNavigation.ts b/packages/hunk/src/ui/lib/extensionNavigation.ts similarity index 100% rename from src/ui/lib/extensionNavigation.ts rename to packages/hunk/src/ui/lib/extensionNavigation.ts diff --git a/src/ui/lib/extensionNotifications.test.ts b/packages/hunk/src/ui/lib/extensionNotifications.test.ts similarity index 100% rename from src/ui/lib/extensionNotifications.test.ts rename to packages/hunk/src/ui/lib/extensionNotifications.test.ts diff --git a/src/ui/lib/extensionNotifications.ts b/packages/hunk/src/ui/lib/extensionNotifications.ts similarity index 100% rename from src/ui/lib/extensionNotifications.ts rename to packages/hunk/src/ui/lib/extensionNotifications.ts diff --git a/src/ui/lib/extensionPaintTheme.ts b/packages/hunk/src/ui/lib/extensionPaintTheme.ts similarity index 100% rename from src/ui/lib/extensionPaintTheme.ts rename to packages/hunk/src/ui/lib/extensionPaintTheme.ts diff --git a/src/ui/lib/extensionPanes.test.ts b/packages/hunk/src/ui/lib/extensionPanes.test.ts similarity index 100% rename from src/ui/lib/extensionPanes.test.ts rename to packages/hunk/src/ui/lib/extensionPanes.test.ts diff --git a/src/ui/lib/extensionPanes.ts b/packages/hunk/src/ui/lib/extensionPanes.ts similarity index 100% rename from src/ui/lib/extensionPanes.ts rename to packages/hunk/src/ui/lib/extensionPanes.ts diff --git a/src/ui/lib/extensionSelection.test.ts b/packages/hunk/src/ui/lib/extensionSelection.test.ts similarity index 98% rename from src/ui/lib/extensionSelection.test.ts rename to packages/hunk/src/ui/lib/extensionSelection.test.ts index 2629f4e81..ea2472a73 100644 --- a/src/ui/lib/extensionSelection.test.ts +++ b/packages/hunk/src/ui/lib/extensionSelection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { toReadOnlyFileViews } from "../../extensions/events"; import { buildExtensionReviewSelection } from "./extensionSelection"; diff --git a/src/ui/lib/extensionSelection.ts b/packages/hunk/src/ui/lib/extensionSelection.ts similarity index 100% rename from src/ui/lib/extensionSelection.ts rename to packages/hunk/src/ui/lib/extensionSelection.ts diff --git a/src/ui/lib/extensionTrustPrompt.ts b/packages/hunk/src/ui/lib/extensionTrustPrompt.ts similarity index 100% rename from src/ui/lib/extensionTrustPrompt.ts rename to packages/hunk/src/ui/lib/extensionTrustPrompt.ts diff --git a/src/ui/lib/extensionWorkspace.test.ts b/packages/hunk/src/ui/lib/extensionWorkspace.test.ts similarity index 100% rename from src/ui/lib/extensionWorkspace.test.ts rename to packages/hunk/src/ui/lib/extensionWorkspace.test.ts diff --git a/src/ui/lib/extensionWorkspace.ts b/packages/hunk/src/ui/lib/extensionWorkspace.ts similarity index 100% rename from src/ui/lib/extensionWorkspace.ts rename to packages/hunk/src/ui/lib/extensionWorkspace.ts diff --git a/src/ui/lib/fileHeader.test.ts b/packages/hunk/src/ui/lib/fileHeader.test.ts similarity index 96% rename from src/ui/lib/fileHeader.test.ts rename to packages/hunk/src/ui/lib/fileHeader.test.ts index 1121377ff..a32c37137 100644 --- a/src/ui/lib/fileHeader.test.ts +++ b/packages/hunk/src/ui/lib/fileHeader.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { FILE_HEADER_OVERFLOW_MARKER, fileHeaderStats, diff --git a/src/ui/lib/fileHeader.ts b/packages/hunk/src/ui/lib/fileHeader.ts similarity index 100% rename from src/ui/lib/fileHeader.ts rename to packages/hunk/src/ui/lib/fileHeader.ts diff --git a/src/ui/lib/fileRenderWindow.test.ts b/packages/hunk/src/ui/lib/fileRenderWindow.test.ts similarity index 100% rename from src/ui/lib/fileRenderWindow.test.ts rename to packages/hunk/src/ui/lib/fileRenderWindow.test.ts diff --git a/src/ui/lib/fileRenderWindow.ts b/packages/hunk/src/ui/lib/fileRenderWindow.ts similarity index 100% rename from src/ui/lib/fileRenderWindow.ts rename to packages/hunk/src/ui/lib/fileRenderWindow.ts diff --git a/src/ui/lib/fileSectionLayout.test.ts b/packages/hunk/src/ui/lib/fileSectionLayout.test.ts similarity index 100% rename from src/ui/lib/fileSectionLayout.test.ts rename to packages/hunk/src/ui/lib/fileSectionLayout.test.ts diff --git a/src/ui/lib/fileSectionLayout.ts b/packages/hunk/src/ui/lib/fileSectionLayout.ts similarity index 100% rename from src/ui/lib/fileSectionLayout.ts rename to packages/hunk/src/ui/lib/fileSectionLayout.ts diff --git a/src/ui/lib/files.test.ts b/packages/hunk/src/ui/lib/files.test.ts similarity index 99% rename from src/ui/lib/files.test.ts rename to packages/hunk/src/ui/lib/files.test.ts index 0e7c1aa5c..9c59e1903 100644 --- a/src/ui/lib/files.test.ts +++ b/packages/hunk/src/ui/lib/files.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; import { buildFlatSidebarEntries, buildTreeSidebarEntries, diff --git a/src/ui/lib/files.ts b/packages/hunk/src/ui/lib/files.ts similarity index 100% rename from src/ui/lib/files.ts rename to packages/hunk/src/ui/lib/files.ts diff --git a/src/ui/lib/helpContent.test.ts b/packages/hunk/src/ui/lib/helpContent.test.ts similarity index 100% rename from src/ui/lib/helpContent.test.ts rename to packages/hunk/src/ui/lib/helpContent.test.ts diff --git a/src/ui/lib/helpContent.ts b/packages/hunk/src/ui/lib/helpContent.ts similarity index 100% rename from src/ui/lib/helpContent.ts rename to packages/hunk/src/ui/lib/helpContent.ts diff --git a/src/ui/lib/hunkScroll.test.ts b/packages/hunk/src/ui/lib/hunkScroll.test.ts similarity index 100% rename from src/ui/lib/hunkScroll.test.ts rename to packages/hunk/src/ui/lib/hunkScroll.test.ts diff --git a/src/ui/lib/hunkScroll.ts b/packages/hunk/src/ui/lib/hunkScroll.ts similarity index 100% rename from src/ui/lib/hunkScroll.ts rename to packages/hunk/src/ui/lib/hunkScroll.ts diff --git a/src/ui/lib/ids.ts b/packages/hunk/src/ui/lib/ids.ts similarity index 100% rename from src/ui/lib/ids.ts rename to packages/hunk/src/ui/lib/ids.ts diff --git a/src/ui/lib/keyRouting.test.ts b/packages/hunk/src/ui/lib/keyRouting.test.ts similarity index 100% rename from src/ui/lib/keyRouting.test.ts rename to packages/hunk/src/ui/lib/keyRouting.test.ts diff --git a/src/ui/lib/keyRouting.ts b/packages/hunk/src/ui/lib/keyRouting.ts similarity index 100% rename from src/ui/lib/keyRouting.ts rename to packages/hunk/src/ui/lib/keyRouting.ts diff --git a/src/ui/lib/keyboard.ts b/packages/hunk/src/ui/lib/keyboard.ts similarity index 100% rename from src/ui/lib/keyboard.ts rename to packages/hunk/src/ui/lib/keyboard.ts diff --git a/src/ui/lib/keymap.test.ts b/packages/hunk/src/ui/lib/keymap.test.ts similarity index 100% rename from src/ui/lib/keymap.test.ts rename to packages/hunk/src/ui/lib/keymap.test.ts diff --git a/src/ui/lib/keymap.ts b/packages/hunk/src/ui/lib/keymap.ts similarity index 100% rename from src/ui/lib/keymap.ts rename to packages/hunk/src/ui/lib/keymap.ts diff --git a/src/ui/lib/lineCursors.test.ts b/packages/hunk/src/ui/lib/lineCursors.test.ts similarity index 99% rename from src/ui/lib/lineCursors.test.ts rename to packages/hunk/src/ui/lib/lineCursors.test.ts index 0b043b0f8..53c08ecc2 100644 --- a/src/ui/lib/lineCursors.test.ts +++ b/packages/hunk/src/ui/lib/lineCursors.test.ts @@ -3,7 +3,7 @@ import { createTestDiffFile, createTestHeaderOnlyDiffFile, lines, -} from "../../../test/helpers/diff-helpers"; +} from "../../../../../test/helpers/diff-helpers"; import type { DiffFile } from "../../core/changeset/model"; import type { LayoutMode } from "../../core/run/commandInputs"; import { reviewGapId } from "../../core/review/expansion"; diff --git a/src/ui/lib/lineCursors.ts b/packages/hunk/src/ui/lib/lineCursors.ts similarity index 100% rename from src/ui/lib/lineCursors.ts rename to packages/hunk/src/ui/lib/lineCursors.ts diff --git a/src/ui/lib/listWindow.test.ts b/packages/hunk/src/ui/lib/listWindow.test.ts similarity index 100% rename from src/ui/lib/listWindow.test.ts rename to packages/hunk/src/ui/lib/listWindow.test.ts diff --git a/src/ui/lib/listWindow.ts b/packages/hunk/src/ui/lib/listWindow.ts similarity index 100% rename from src/ui/lib/listWindow.ts rename to packages/hunk/src/ui/lib/listWindow.ts diff --git a/src/ui/lib/modalGeometry.test.ts b/packages/hunk/src/ui/lib/modalGeometry.test.ts similarity index 100% rename from src/ui/lib/modalGeometry.test.ts rename to packages/hunk/src/ui/lib/modalGeometry.test.ts diff --git a/src/ui/lib/modalGeometry.ts b/packages/hunk/src/ui/lib/modalGeometry.ts similarity index 100% rename from src/ui/lib/modalGeometry.ts rename to packages/hunk/src/ui/lib/modalGeometry.ts diff --git a/src/ui/lib/mouseCapture.ts b/packages/hunk/src/ui/lib/mouseCapture.ts similarity index 100% rename from src/ui/lib/mouseCapture.ts rename to packages/hunk/src/ui/lib/mouseCapture.ts diff --git a/src/ui/lib/openInEditor.test.ts b/packages/hunk/src/ui/lib/openInEditor.test.ts similarity index 99% rename from src/ui/lib/openInEditor.test.ts rename to packages/hunk/src/ui/lib/openInEditor.test.ts index 16d099fb5..81512e48f 100644 --- a/src/ui/lib/openInEditor.test.ts +++ b/packages/hunk/src/ui/lib/openInEditor.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { buildEditorCommand, openSelectedFileInEditor, diff --git a/src/ui/lib/openInEditor.ts b/packages/hunk/src/ui/lib/openInEditor.ts similarity index 100% rename from src/ui/lib/openInEditor.ts rename to packages/hunk/src/ui/lib/openInEditor.ts diff --git a/src/ui/lib/responsive.test.ts b/packages/hunk/src/ui/lib/responsive.test.ts similarity index 100% rename from src/ui/lib/responsive.test.ts rename to packages/hunk/src/ui/lib/responsive.test.ts diff --git a/src/ui/lib/responsive.ts b/packages/hunk/src/ui/lib/responsive.ts similarity index 100% rename from src/ui/lib/responsive.ts rename to packages/hunk/src/ui/lib/responsive.ts diff --git a/src/ui/lib/reviewNoteMapping.test.ts b/packages/hunk/src/ui/lib/reviewNoteMapping.test.ts similarity index 97% rename from src/ui/lib/reviewNoteMapping.test.ts rename to packages/hunk/src/ui/lib/reviewNoteMapping.test.ts index 83342ecda..41bb4cdff 100644 --- a/src/ui/lib/reviewNoteMapping.test.ts +++ b/packages/hunk/src/ui/lib/reviewNoteMapping.test.ts @@ -3,7 +3,10 @@ import { buildLiveComment } from "../../core/liveComments"; import { reviewLineAnchor } from "../../core/review/anchors"; import type { ReviewHunkSpan } from "../../core/review/geometry"; import type { ReviewNoteV1 } from "../../core/review/types"; -import { createTestDiffFile, createTestSourceFetcher } from "../../../test/helpers/diff-helpers"; +import { + createTestDiffFile, + createTestSourceFetcher, +} from "../../../../../test/helpers/diff-helpers"; import { groupStoredNotesByFileId, liveCommentToStoredNote, diff --git a/src/ui/lib/reviewNoteMapping.ts b/packages/hunk/src/ui/lib/reviewNoteMapping.ts similarity index 100% rename from src/ui/lib/reviewNoteMapping.ts rename to packages/hunk/src/ui/lib/reviewNoteMapping.ts diff --git a/src/ui/lib/reviewState.test.ts b/packages/hunk/src/ui/lib/reviewState.test.ts similarity index 97% rename from src/ui/lib/reviewState.test.ts rename to packages/hunk/src/ui/lib/reviewState.test.ts index c397f65d7..9029922e6 100644 --- a/src/ui/lib/reviewState.test.ts +++ b/packages/hunk/src/ui/lib/reviewState.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; import { buildReviewAnnotationIndex } from "../../core/review/annotations"; -import { createTestAgentFileContext, createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { + createTestAgentFileContext, + createTestDiffFile, +} from "../../../../../test/helpers/diff-helpers"; import { buildReviewStreamState, buildSelectedHunkSummary, diff --git a/src/ui/lib/reviewState.ts b/packages/hunk/src/ui/lib/reviewState.ts similarity index 100% rename from src/ui/lib/reviewState.ts rename to packages/hunk/src/ui/lib/reviewState.ts diff --git a/src/ui/lib/scopedEpochs.test.ts b/packages/hunk/src/ui/lib/scopedEpochs.test.ts similarity index 100% rename from src/ui/lib/scopedEpochs.test.ts rename to packages/hunk/src/ui/lib/scopedEpochs.test.ts diff --git a/src/ui/lib/scopedEpochs.ts b/packages/hunk/src/ui/lib/scopedEpochs.ts similarity index 100% rename from src/ui/lib/scopedEpochs.ts rename to packages/hunk/src/ui/lib/scopedEpochs.ts diff --git a/src/ui/lib/scrollAcceleration.ts b/packages/hunk/src/ui/lib/scrollAcceleration.ts similarity index 100% rename from src/ui/lib/scrollAcceleration.ts rename to packages/hunk/src/ui/lib/scrollAcceleration.ts diff --git a/src/ui/lib/sidebar.ts b/packages/hunk/src/ui/lib/sidebar.ts similarity index 100% rename from src/ui/lib/sidebar.ts rename to packages/hunk/src/ui/lib/sidebar.ts diff --git a/src/ui/lib/sidebarRenderWindow.test.ts b/packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts similarity index 100% rename from src/ui/lib/sidebarRenderWindow.test.ts rename to packages/hunk/src/ui/lib/sidebarRenderWindow.test.ts diff --git a/src/ui/lib/sidebarRenderWindow.ts b/packages/hunk/src/ui/lib/sidebarRenderWindow.ts similarity index 100% rename from src/ui/lib/sidebarRenderWindow.ts rename to packages/hunk/src/ui/lib/sidebarRenderWindow.ts diff --git a/src/ui/lib/stml/cli.ts b/packages/hunk/src/ui/lib/stml/cli.ts similarity index 100% rename from src/ui/lib/stml/cli.ts rename to packages/hunk/src/ui/lib/stml/cli.ts diff --git a/src/ui/lib/stml/colors.test.ts b/packages/hunk/src/ui/lib/stml/colors.test.ts similarity index 100% rename from src/ui/lib/stml/colors.test.ts rename to packages/hunk/src/ui/lib/stml/colors.test.ts diff --git a/src/ui/lib/stml/colors.ts b/packages/hunk/src/ui/lib/stml/colors.ts similarity index 100% rename from src/ui/lib/stml/colors.ts rename to packages/hunk/src/ui/lib/stml/colors.ts diff --git a/src/ui/lib/stml/guide.test.ts b/packages/hunk/src/ui/lib/stml/guide.test.ts similarity index 100% rename from src/ui/lib/stml/guide.test.ts rename to packages/hunk/src/ui/lib/stml/guide.test.ts diff --git a/src/ui/lib/stml/guide.ts b/packages/hunk/src/ui/lib/stml/guide.ts similarity index 100% rename from src/ui/lib/stml/guide.ts rename to packages/hunk/src/ui/lib/stml/guide.ts diff --git a/src/ui/lib/stml/layout.test.ts b/packages/hunk/src/ui/lib/stml/layout.test.ts similarity index 100% rename from src/ui/lib/stml/layout.test.ts rename to packages/hunk/src/ui/lib/stml/layout.test.ts diff --git a/src/ui/lib/stml/layout.ts b/packages/hunk/src/ui/lib/stml/layout.ts similarity index 100% rename from src/ui/lib/stml/layout.ts rename to packages/hunk/src/ui/lib/stml/layout.ts diff --git a/src/ui/lib/stml/parse.test.ts b/packages/hunk/src/ui/lib/stml/parse.test.ts similarity index 100% rename from src/ui/lib/stml/parse.test.ts rename to packages/hunk/src/ui/lib/stml/parse.test.ts diff --git a/src/ui/lib/stml/parse.ts b/packages/hunk/src/ui/lib/stml/parse.ts similarity index 100% rename from src/ui/lib/stml/parse.ts rename to packages/hunk/src/ui/lib/stml/parse.ts diff --git a/src/ui/lib/stml/render.test.ts b/packages/hunk/src/ui/lib/stml/render.test.ts similarity index 100% rename from src/ui/lib/stml/render.test.ts rename to packages/hunk/src/ui/lib/stml/render.test.ts diff --git a/src/ui/lib/stml/render.ts b/packages/hunk/src/ui/lib/stml/render.ts similarity index 100% rename from src/ui/lib/stml/render.ts rename to packages/hunk/src/ui/lib/stml/render.ts diff --git a/src/ui/lib/synchronousExtensionCallback.ts b/packages/hunk/src/ui/lib/synchronousExtensionCallback.ts similarity index 100% rename from src/ui/lib/synchronousExtensionCallback.ts rename to packages/hunk/src/ui/lib/synchronousExtensionCallback.ts diff --git a/src/ui/lib/syntheticKeyEvent.ts b/packages/hunk/src/ui/lib/syntheticKeyEvent.ts similarity index 100% rename from src/ui/lib/syntheticKeyEvent.ts rename to packages/hunk/src/ui/lib/syntheticKeyEvent.ts diff --git a/src/ui/lib/text.ts b/packages/hunk/src/ui/lib/text.ts similarity index 100% rename from src/ui/lib/text.ts rename to packages/hunk/src/ui/lib/text.ts diff --git a/src/ui/lib/ui-lib.test.ts b/packages/hunk/src/ui/lib/ui-lib.test.ts similarity index 99% rename from src/ui/lib/ui-lib.test.ts rename to packages/hunk/src/ui/lib/ui-lib.test.ts index 1292f255d..c582ff03a 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/packages/hunk/src/ui/lib/ui-lib.test.ts @@ -34,7 +34,7 @@ import { } from "../diff/diffSectionGeometry"; import { resizeSidebarWidth } from "./sidebar"; import { resolveTheme } from "../themes"; -import { createTestCustomThemes } from "../../../test/helpers/theme-helpers"; +import { createTestCustomThemes } from "../../../../../test/helpers/theme-helpers"; function lines(...values: string[]) { return `${values.join("\n")}\n`; diff --git a/src/ui/lib/viewportAnchor.test.ts b/packages/hunk/src/ui/lib/viewportAnchor.test.ts similarity index 97% rename from src/ui/lib/viewportAnchor.test.ts rename to packages/hunk/src/ui/lib/viewportAnchor.test.ts index 0bd6ef4fc..3b606f609 100644 --- a/src/ui/lib/viewportAnchor.test.ts +++ b/packages/hunk/src/ui/lib/viewportAnchor.test.ts @@ -3,7 +3,7 @@ import { resolveTheme } from "../themes"; import { buildInStreamFileHeaderHeights } from "./fileSectionLayout"; import { measureDiffSectionGeometry } from "../diff/diffSectionGeometry"; import { findViewportRowAnchor, resolveViewportRowAnchorTop } from "./viewportAnchor"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; describe("viewport row anchors", () => { const theme = resolveTheme("github-dark-default", null); diff --git a/src/ui/lib/viewportAnchor.ts b/packages/hunk/src/ui/lib/viewportAnchor.ts similarity index 100% rename from src/ui/lib/viewportAnchor.ts rename to packages/hunk/src/ui/lib/viewportAnchor.ts diff --git a/src/ui/lib/viewportSelection.test.ts b/packages/hunk/src/ui/lib/viewportSelection.test.ts similarity index 97% rename from src/ui/lib/viewportSelection.test.ts rename to packages/hunk/src/ui/lib/viewportSelection.test.ts index c5456fffc..e688bf7d9 100644 --- a/src/ui/lib/viewportSelection.test.ts +++ b/packages/hunk/src/ui/lib/viewportSelection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; import { measureDiffSectionGeometry } from "../diff/diffSectionGeometry"; import { buildFileSectionLayouts, buildInStreamFileHeaderHeights } from "./fileSectionLayout"; import { findViewportCenteredHunkTarget } from "./viewportSelection"; diff --git a/src/ui/lib/viewportSelection.ts b/packages/hunk/src/ui/lib/viewportSelection.ts similarity index 100% rename from src/ui/lib/viewportSelection.ts rename to packages/hunk/src/ui/lib/viewportSelection.ts diff --git a/src/ui/lib/viewportTiming.test.ts b/packages/hunk/src/ui/lib/viewportTiming.test.ts similarity index 100% rename from src/ui/lib/viewportTiming.test.ts rename to packages/hunk/src/ui/lib/viewportTiming.test.ts diff --git a/src/ui/lib/viewportTiming.ts b/packages/hunk/src/ui/lib/viewportTiming.ts similarity index 100% rename from src/ui/lib/viewportTiming.ts rename to packages/hunk/src/ui/lib/viewportTiming.ts diff --git a/src/ui/lib/workspaceWriteGuard.test.ts b/packages/hunk/src/ui/lib/workspaceWriteGuard.test.ts similarity index 100% rename from src/ui/lib/workspaceWriteGuard.test.ts rename to packages/hunk/src/ui/lib/workspaceWriteGuard.test.ts diff --git a/src/ui/lib/workspaceWriteGuard.ts b/packages/hunk/src/ui/lib/workspaceWriteGuard.ts similarity index 100% rename from src/ui/lib/workspaceWriteGuard.ts rename to packages/hunk/src/ui/lib/workspaceWriteGuard.ts diff --git a/src/ui/runInteractiveApp.tsx b/packages/hunk/src/ui/runInteractiveApp.tsx similarity index 100% rename from src/ui/runInteractiveApp.tsx rename to packages/hunk/src/ui/runInteractiveApp.tsx diff --git a/src/ui/staticDiffPager.test.ts b/packages/hunk/src/ui/staticDiffPager.test.ts similarity index 100% rename from src/ui/staticDiffPager.test.ts rename to packages/hunk/src/ui/staticDiffPager.test.ts diff --git a/src/ui/staticDiffPager.ts b/packages/hunk/src/ui/staticDiffPager.ts similarity index 100% rename from src/ui/staticDiffPager.ts rename to packages/hunk/src/ui/staticDiffPager.ts diff --git a/src/ui/themes.test.ts b/packages/hunk/src/ui/themes.test.ts similarity index 99% rename from src/ui/themes.test.ts rename to packages/hunk/src/ui/themes.test.ts index 2c6cbdb98..fc41aebe2 100644 --- a/src/ui/themes.test.ts +++ b/packages/hunk/src/ui/themes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestCustomThemes } from "../../test/helpers/theme-helpers"; +import { createTestCustomThemes } from "../../../../test/helpers/theme-helpers"; import { blendHex, contrastRatio, hexColorDistance } from "./lib/color"; import { BUNDLED_SHIKI_THEME_IDS, diff --git a/src/ui/themes.ts b/packages/hunk/src/ui/themes.ts similarity index 100% rename from src/ui/themes.ts rename to packages/hunk/src/ui/themes.ts diff --git a/src/ui/themes/types.ts b/packages/hunk/src/ui/themes/types.ts similarity index 100% rename from src/ui/themes/types.ts rename to packages/hunk/src/ui/themes/types.ts diff --git a/scripts/build-bin.ts b/scripts/build-bin.ts index b6e84498e..5d1455196 100644 --- a/scripts/build-bin.ts +++ b/scripts/build-bin.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun -import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; import path from "node:path"; +import { HUNK_PACKAGE_ROOT, HUNK_SOURCE_ROOT, REPO_ROOT } from "./package-paths"; /** * Resolves the Bun compile target for one host, or null to keep Bun's own host default. @@ -38,7 +39,7 @@ export function compileTargetForHost( } if (import.meta.main) { - const repoRoot = path.resolve(import.meta.dir, ".."); + const repoRoot = REPO_ROOT; const distDir = path.join(repoRoot, "dist"); const binaryName = process.platform === "win32" ? "hunk.exe" : "hunk"; const outfile = path.join(distDir, binaryName); @@ -56,8 +57,8 @@ if (import.meta.main) { "--compile", "--no-compile-autoload-bunfig", ...(target ? [`--target=${target}`] : []), - path.join(repoRoot, "src", "main.tsx"), - path.join(repoRoot, "src", "highlightWorkerEntry.ts"), + path.join(HUNK_SOURCE_ROOT, "main.tsx"), + path.join(HUNK_SOURCE_ROOT, "highlightWorkerEntry.ts"), "--outfile", outfile, ], @@ -83,5 +84,10 @@ if (import.meta.main) { throw new Error(`bun build --compile failed with exit ${proc.exitCode}.${offlineHint}`); } + const skillsOutdir = path.join(distDir, "skills"); + rmSync(skillsOutdir, { recursive: true, force: true }); + cpSync(path.join(HUNK_PACKAGE_ROOT, "skills"), skillsOutdir, { recursive: true }); + console.log(`Built ${outfile}${target ? ` for ${target}` : ""}`); + console.log(`Staged ${skillsOutdir}`); } diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index 4ad4d95f5..9b20c65c7 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -2,22 +2,23 @@ import { chmodSync, - copyFileSync, cpSync, mkdirSync, + readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs"; import path from "node:path"; +import { HUNK_NPM_DIST, HUNK_PACKAGE_ROOT, HUNK_SOURCE_ROOT, REPO_ROOT } from "./package-paths"; -const repoRoot = path.resolve(import.meta.dir, ".."); -const outdir = path.join(repoRoot, "dist", "npm"); -const typesOutdir = path.join(repoRoot, "dist", "npm-types"); +const repoRoot = REPO_ROOT; +const outdir = HUNK_NPM_DIST; +const typesOutdir = path.join(HUNK_PACKAGE_ROOT, "dist", "npm-types"); const opentuiOutdir = path.join(outdir, "opentui"); -const opentuiTypesDir = path.join(typesOutdir, "opentui"); +const opentuiTypesDir = path.join(typesOutdir, "hunk", "src", "opentui"); const extensionOutdir = path.join(outdir, "extension"); -const extensionTypesOutdir = path.join(repoRoot, "dist", "npm-extension-types"); +const extensionTypesOutdir = path.join(HUNK_PACKAGE_ROOT, "dist", "npm-extension-types"); const bunEnv = { ...process.env, @@ -25,6 +26,25 @@ const bunEnv = { BUN_INSTALL: path.join(repoRoot, ".bun-install"), }; +/** Rewrite emitted ESM declaration imports for NodeNext consumers. */ +function rewriteDeclarationSpecifiers(dir: string) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + rewriteDeclarationSpecifiers(entryPath); + continue; + } + if (!entry.name.endsWith(".d.ts")) continue; + const source = readFileSync(entryPath, "utf8"); + const rewritten = source.replace( + /(\bfrom\s+["']|\bimport\s*\(\s*["'])(\.{1,2}\/[^"']+?)(["'])/g, + (match, prefix: string, specifier: string, quote: string) => + /\.(?:js|json|d\.ts)$/.test(specifier) ? match : `${prefix}${specifier}.js${quote}`, + ); + if (rewritten !== source) writeFileSync(entryPath, rewritten); + } +} + function runBun(args: string[]) { const proc = Bun.spawnSync(["bun", ...args], { cwd: repoRoot, @@ -58,7 +78,7 @@ const opentuiNativePackages = [ runBun([ "build", - path.join(repoRoot, "src", "main.tsx"), + path.join(HUNK_SOURCE_ROOT, "main.tsx"), "--target", "bun", "--format", @@ -78,7 +98,7 @@ if (process.platform !== "win32") { runBun([ "build", - path.join(repoRoot, "src", "opentui", "index.ts"), + path.join(HUNK_SOURCE_ROOT, "opentui", "index.ts"), "--target", "node", "--format", @@ -107,17 +127,17 @@ runBun([ runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.opentui.json")]); -for (const entry of readdirSync(opentuiTypesDir)) { - if (entry.endsWith(".d.ts")) { - copyFileSync(path.join(opentuiTypesDir, entry), path.join(opentuiOutdir, entry)); - } -} +// Ship the complete declaration tree reached by the public OpenTUI entry. The +// compiler strips @internal adapters so this contains the public component +// surface without leaking the app's core model. +cpSync(opentuiTypesDir, opentuiOutdir, { recursive: true }); +rewriteDeclarationSpecifiers(opentuiOutdir); rmSync(typesOutdir, { recursive: true, force: true }); runBun([ "build", - path.join(repoRoot, "src", "extension-api", "index.ts"), + path.join(HUNK_SOURCE_ROOT, "extension-api", "index.ts"), "--target", "node", "--format", diff --git a/scripts/build-prebuilt-artifact.test.ts b/scripts/build-prebuilt-artifact.test.ts index 847c61b4f..ca337c98f 100644 --- a/scripts/build-prebuilt-artifact.test.ts +++ b/scripts/build-prebuilt-artifact.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } f import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; -import { BUNDLED_SKILL_NAMES } from "../src/core/run/paths"; +import { BUNDLED_SKILL_NAMES } from "../packages/hunk/src/core/run/paths"; import { stagePrebuiltArtifact } from "./build-prebuilt-artifact"; import { binaryFilenameForSpec, getHostPlatformPackageSpec } from "./prebuilt-package-helpers"; @@ -21,14 +21,16 @@ function createTestRepo() { }); for (const skillName of BUNDLED_SKILL_NAMES) { - mkdirSync(path.join(repoRoot, "skills", skillName), { recursive: true }); - writeFileSync(path.join(repoRoot, "skills", skillName, "SKILL.md"), `# ${skillName}\n`); + const directory = path.join(repoRoot, "packages", "hunk", "skills", skillName); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, "SKILL.md"), `# ${skillName}\n`); } // Maintainer-only skills the artifact must leave behind. for (const skillName of ["hunk-launch-video", "hunk-release"]) { - mkdirSync(path.join(repoRoot, "skills", skillName), { recursive: true }); - writeFileSync(path.join(repoRoot, "skills", skillName, "SKILL.md"), `# ${skillName}\n`); + const directory = path.join(repoRoot, "skills", skillName); + mkdirSync(directory, { recursive: true }); + writeFileSync(path.join(directory, "SKILL.md"), `# ${skillName}\n`); } return { repoRoot, spec, binaryName }; @@ -44,14 +46,16 @@ afterEach(() => { describe("stagePrebuiltArtifact", () => { test("rejects missing skills directory with an actionable error", () => { const { repoRoot } = createTestRepo(); - rmSync(path.join(repoRoot, "skills"), { recursive: true, force: true }); + rmSync(path.join(repoRoot, "packages", "hunk", "skills"), { recursive: true, force: true }); expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow("Missing skills directory"); }); test("rejects a missing bundled skill with an actionable error", () => { const { repoRoot } = createTestRepo(); - rmSync(path.join(repoRoot, "skills", "hunk-review", "SKILL.md"), { force: true }); + rmSync(path.join(repoRoot, "packages", "hunk", "skills", "hunk-review", "SKILL.md"), { + force: true, + }); expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow( "Missing bundled Hunk hunk-review skill", @@ -60,7 +64,9 @@ describe("stagePrebuiltArtifact", () => { test("rejects a missing bundled skill added after the first one", () => { const { repoRoot } = createTestRepo(); - rmSync(path.join(repoRoot, "skills", "hunk-extensions", "SKILL.md"), { force: true }); + rmSync(path.join(repoRoot, "packages", "hunk", "skills", "hunk-extensions", "SKILL.md"), { + force: true, + }); expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow( "Missing bundled Hunk hunk-extensions skill", diff --git a/scripts/build-prebuilt-artifact.ts b/scripts/build-prebuilt-artifact.ts index c5a109831..be75543b4 100644 --- a/scripts/build-prebuilt-artifact.ts +++ b/scripts/build-prebuilt-artifact.ts @@ -2,7 +2,7 @@ import { chmodSync, cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { BUNDLED_SKILL_NAMES } from "../src/core/run/paths"; +import { BUNDLED_SKILL_NAMES } from "../packages/hunk/src/core/run/paths"; import { binaryFilenameForSpec, getHostPlatformPackageSpec, @@ -73,7 +73,7 @@ export function stagePrebuiltArtifact(options: StagePrebuiltArtifactOptions = {} chmodSync(stagedBinary, 0o755); } - const skillsSource = path.join(repoRoot, "skills"); + const skillsSource = path.join(repoRoot, "packages", "hunk", "skills"); if (!existsSync(skillsSource)) { throw new Error(`Missing skills directory at ${skillsSource}.`); } diff --git a/scripts/check-comparison-catalog.test.ts b/scripts/check-comparison-catalog.test.ts index a4deab94f..0a6e6ace0 100644 --- a/scripts/check-comparison-catalog.test.ts +++ b/scripts/check-comparison-catalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { BUNDLED_SHIKI_THEME_IDS } from "../src/core/theme/catalog"; +import { BUNDLED_SHIKI_THEME_IDS } from "../packages/hunk/src/core/theme/catalog"; import { COMPARISONS, COMPARISONS_REVIEWED_ON } from "../website/src/data/comparisons"; /** diff --git a/scripts/check-extension-catalog.test.ts b/scripts/check-extension-catalog.test.ts index 46c445989..3b88d47b2 100644 --- a/scripts/check-extension-catalog.test.ts +++ b/scripts/check-extension-catalog.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { HUNK_EXTENSION_API_VERSION } from "../src/extension-api/types"; -import { parseExtensionInstallSource } from "../src/extensions/manage/source"; +import { HUNK_EXTENSION_API_VERSION } from "../packages/hunk/src/extension-api/types"; +import { parseExtensionInstallSource } from "../packages/hunk/src/extensions/manage/source"; import { EXTENSION_CATALOG, avatarUrl, diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index 0edcf5d9c..b2bc9cd00 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -4,7 +4,9 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { checkExtensionConsumerTypes } from "./extension-consumer-check"; import { buildDocExamples } from "./extension-doc-examples"; +import { checkOpenTuiConsumerTypes } from "./opentui-consumer-check"; import { npmCommand } from "./script-helpers"; +import { stageSourceNpmPackage } from "./stage-source-npm-package"; const repoRoot = path.resolve(import.meta.dir, ".."); @@ -364,8 +366,10 @@ interface PackResult { files: PackedFile[]; } -const proc = Bun.spawnSync([npmCommand, "pack", "--dry-run", "--json"], { - cwd: process.cwd(), +const appRoot = path.join(repoRoot, "packages", "hunk"); +const packRoot = stageSourceNpmPackage(); +const proc = Bun.spawnSync([npmCommand, "pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: packRoot, stdin: "ignore", stdout: "pipe", stderr: "pipe", @@ -460,7 +464,7 @@ for (const file of pack.files) { ) { throw new Error( `Unexpected file in the published extension surface: ${file.path}. ` + - "The hunkdiff/extension entry must only reach src/extension-api.", + "The hunkdiff/extension entry must only reach packages/hunk/src/extension-api.", ); } } @@ -469,8 +473,37 @@ if (pack.name !== "hunkdiff") { throw new Error(`Expected npm package name to be hunkdiff, got ${pack.name}.`); } +const sourceManifest = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")) as { + devDependencies?: Record; +}; +for (const packageName of [ + "@hunk/git", + "@hunk/jj", + "@hunk/sapling", + "@hunk/session-broker", + "@hunk/session-broker-bun", + "@hunk/session-broker-core", + "@hunk/vcs", +]) { + if (sourceManifest.devDependencies?.[packageName] !== "workspace:*") { + throw new Error(`packages/hunk must declare its bundled build dependency on ${packageName}.`); + } +} +const packedManifest = readFileSync(path.join(packRoot, "package.json"), "utf8"); +if (packedManifest.includes("workspace:") || packedManifest.includes('"@hunk/')) { + throw new Error("The published hunkdiff manifest must not contain private workspace references."); +} +for (const fileName of ["README.md", "LICENSE"]) { + if ( + readFileSync(path.join(repoRoot, fileName), "utf8") !== + readFileSync(path.join(appRoot, fileName), "utf8") + ) { + throw new Error(`${fileName} in packages/hunk must match the repository canonical copy.`); + } +} + const extensionTypes = readFileSync( - path.join(repoRoot, "dist", "npm", "extension", "extension-api", "types.d.ts"), + path.join(appRoot, "dist", "npm", "extension", "extension-api", "types.d.ts"), "utf8", ); if (/^\s*import\b/m.test(extensionTypes)) { @@ -498,6 +531,7 @@ for (const removedType of [ const docsMarkdown = readFileSync(path.join(repoRoot, "docs", "extensions.md"), "utf8"); const docExamples = buildDocExamples(docsMarkdown); +const opentuiModes = checkOpenTuiConsumerTypes(repoRoot); const { modes } = checkExtensionConsumerTypes({ repoRoot, sources: [ @@ -509,6 +543,11 @@ const { modes } = checkExtensionConsumerTypes({ console.log( `Verified npm pack output for ${pack.name}@${pack.version} (${pack.entryCount} files).`, ); +console.log( + `Verified hunkdiff/opentui typechecks for consumers using ${opentuiModes + .map((mode) => `moduleResolution: "${mode}"`) + .join(" and ")}.`, +); console.log( `Verified hunkdiff/extension typechecks for consumers using ${modes .map((mode) => `moduleResolution: "${mode}"`) diff --git a/scripts/check-prebuilt-pack.ts b/scripts/check-prebuilt-pack.ts index f35afc9f4..8d28f09b1 100644 --- a/scripts/check-prebuilt-pack.ts +++ b/scripts/check-prebuilt-pack.ts @@ -75,7 +75,7 @@ if (!existsSync(metaDir)) { throw new Error(`Missing staged top-level package at ${metaDir}`); } -const rootManifest = readPackageManifest(repoRoot); +const rootManifest = readPackageManifest(path.join(repoRoot, "packages", "hunk")); const stagedManifest = readPackageManifest(metaDir); assertOptionalPeerDependencyContract(rootManifest, stagedManifest, "@pierre/diffs"); assertNoMandatoryBunDependency(stagedManifest); diff --git a/scripts/check-release-version.ts b/scripts/check-release-version.ts index 0b981ac9d..4d75aac55 100644 --- a/scripts/check-release-version.ts +++ b/scripts/check-release-version.ts @@ -3,7 +3,9 @@ import path from "node:path"; const repoRoot = path.resolve(import.meta.dir, ".."); -const packageJson = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()) as { +const packageJson = JSON.parse( + await Bun.file(path.join(repoRoot, "packages", "hunk", "package.json")).text(), +) as { version: string; }; const refName = process.argv[2]; @@ -15,8 +17,10 @@ if (!refName) { const expectedTag = `v${packageJson.version}`; if (refName !== expectedTag) { throw new Error( - `Tag ${refName} does not match package.json version ${packageJson.version} (${expectedTag}).`, + `Tag ${refName} does not match packages/hunk/package.json version ${packageJson.version} (${expectedTag}).`, ); } -console.log(`Verified release tag ${refName} matches package.json version ${packageJson.version}.`); +console.log( + `Verified release tag ${refName} matches packages/hunk/package.json version ${packageJson.version}.`, +); diff --git a/scripts/compare-release-benchmarks.ts b/scripts/compare-release-benchmarks.ts index 0ffd579c6..e4664ad7e 100644 --- a/scripts/compare-release-benchmarks.ts +++ b/scripts/compare-release-benchmarks.ts @@ -38,7 +38,9 @@ export function releaseBenchmarkDir(root = repoRoot) { /** Parse the package version used by release benchmark filenames. */ export async function readPackageVersion(root = repoRoot) { - const packageJson = JSON.parse(await Bun.file(path.join(root, "package.json")).text()) as { + const packageJson = JSON.parse( + await Bun.file(path.join(root, "packages", "hunk", "package.json")).text(), + ) as { version: string; }; return packageJson.version; diff --git a/scripts/daemon-memory-check.ts b/scripts/daemon-memory-check.ts index 60645b9e1..27081853c 100644 --- a/scripts/daemon-memory-check.ts +++ b/scripts/daemon-memory-check.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createServer } from "node:net"; import { SESSION_BROKER_REGISTRATION_VERSION } from "@hunk/session-broker-core"; -import { HUNK_SESSION_API_PATH } from "../src/session/protocol"; +import { HUNK_SESSION_API_PATH } from "../packages/hunk/src/session/protocol"; type MemorySample = { label: string; @@ -498,7 +498,7 @@ async function main() { const options = parseArgs(process.argv.slice(2)); const port = await reserveLoopbackPort(); const scratch = mkdtempSync(join(tmpdir(), "hunk-daemon-memory-")); - const child = Bun.spawn([process.execPath, "src/main.tsx", "daemon", "serve"], { + const child = Bun.spawn([process.execPath, "packages/hunk/src/main.tsx", "daemon", "serve"], { cwd: process.cwd(), env: { ...process.env, diff --git a/scripts/extension-consumer-check.ts b/scripts/extension-consumer-check.ts index 3d022f385..de18b8bec 100644 --- a/scripts/extension-consumer-check.ts +++ b/scripts/extension-consumer-check.ts @@ -113,7 +113,7 @@ function writeConsumerTsconfig( export function checkExtensionConsumerTypes(options: CheckExtensionConsumerOptions) { const { repoRoot, sources } = options; const modes = options.moduleResolutions ?? (["nodenext", "bundler"] as const); - const extensionDist = path.join(repoRoot, "dist", "npm", "extension"); + const extensionDist = path.join(repoRoot, "packages", "hunk", "dist", "npm", "extension"); if (!existsSync(path.join(extensionDist, "index.d.ts"))) { throw new Error( diff --git a/scripts/generate-docs.test.ts b/scripts/generate-docs.test.ts index 11fb0d7d2..cea1e3046 100644 --- a/scripts/generate-docs.test.ts +++ b/scripts/generate-docs.test.ts @@ -2,17 +2,17 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { CONFIG_REFERENCE_OPTIONS } from "../src/core/run/config"; -import { renderHunkReviewSkill } from "../src/hunk-review/skillDocument"; -import { SESSION_AGENT_COMMAND_LIST } from "../src/session/agent/surface"; +import { CONFIG_REFERENCE_OPTIONS } from "../packages/hunk/src/core/run/config"; +import { renderHunkReviewSkill } from "../packages/hunk/src/hunk-review/skillDocument"; +import { SESSION_AGENT_COMMAND_LIST } from "../packages/hunk/src/session/agent/surface"; import { DEFAULT_SESSION_BROKER_HOST, DEFAULT_SESSION_BROKER_PORT, SESSION_BROKER_HOST_ENV, SESSION_BROKER_PORT_ENV, UNSAFE_ALLOW_REMOTE_SESSION_BROKER_ENV, -} from "../src/session/broker/brokerConfig"; -import { LEGACY_THEME_ID_ALIASES } from "../src/core/theme/catalog"; +} from "../packages/hunk/src/session/broker/brokerConfig"; +import { LEGACY_THEME_ID_ALIASES } from "../packages/hunk/src/core/theme/catalog"; import { generateDocsArtifacts, GENERATED_DOC_PATHS, diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 75fb29470..5b8430319 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -6,23 +6,26 @@ import { type CliReferenceCommand, type CliReferenceOption, WATCH_OPTION, -} from "../src/app/cli"; +} from "../packages/hunk/src/app/cli"; import { BUILT_IN_THEME_IDS, CONFIG_COMMAND_SECTIONS, CONFIG_REFERENCE_CUSTOM_THEME, CONFIG_REFERENCE_EXTENSIONS, CONFIG_REFERENCE_OPTIONS, -} from "../src/core/run/config"; -import { renderHunkReviewSkill } from "../src/hunk-review/skillDocument"; -import { type AgentCommandOption, SESSION_AGENT_COMMAND_LIST } from "../src/session/agent/surface"; +} from "../packages/hunk/src/core/run/config"; +import { renderHunkReviewSkill } from "../packages/hunk/src/hunk-review/skillDocument"; +import { + type AgentCommandOption, + SESSION_AGENT_COMMAND_LIST, +} from "../packages/hunk/src/session/agent/surface"; import { DEFAULT_SESSION_BROKER_HOST, DEFAULT_SESSION_BROKER_PORT, SESSION_BROKER_HOST_ENV, SESSION_BROKER_PORT_ENV, UNSAFE_ALLOW_REMOTE_SESSION_BROKER_ENV, -} from "../src/session/broker/brokerConfig"; +} from "../packages/hunk/src/session/broker/brokerConfig"; const REPO_ROOT = resolve(import.meta.dir, ".."); const GENERATED_NOTICE = @@ -285,7 +288,7 @@ description: Exhaustive generated reference for Hunk TOML keys, defaults, aliase ${GENERATED_NOTICE} -Hunk reads TOML preferences from the user config and an optional repository config. This reference is generated from the same catalog that \`src/core/run/config.ts\` uses to parse preference keys. +Hunk reads TOML preferences from the user config and an optional repository config. This reference is generated from the same catalog that \`packages/hunk/src/core/run/config.ts\` uses to parse preference keys. ## Resolution and scope diff --git a/scripts/generate-skill.ts b/scripts/generate-skill.ts index 130b9fd54..2c3d0743c 100644 --- a/scripts/generate-skill.ts +++ b/scripts/generate-skill.ts @@ -1,11 +1,19 @@ import { join } from "node:path"; -import { renderHunkReviewSkill } from "../src/hunk-review/skillDocument"; +import { renderHunkReviewSkill } from "../packages/hunk/src/hunk-review/skillDocument"; /** - * Regenerate `skills/hunk-review/SKILL.md` from the typed agent surface. The checked-in file is - * the published artifact; `src/hunk-review/skillDocument.test.ts` fails when it drifts from the + * Regenerate `packages/hunk/skills/hunk-review/SKILL.md` from the typed agent surface. The checked-in + * file is the published artifact; the colocated skillDocument test fails when it drifts from the * renderer, so run this after changing session commands, agent errors, or the skill prose. */ -const skillPath = join(import.meta.dir, "..", "skills", "hunk-review", "SKILL.md"); +const skillPath = join( + import.meta.dir, + "..", + "packages", + "hunk", + "skills", + "hunk-review", + "SKILL.md", +); await Bun.write(skillPath, renderHunkReviewSkill()); console.log(`Wrote ${skillPath}`); diff --git a/scripts/generate-theme-diff-colors.test.ts b/scripts/generate-theme-diff-colors.test.ts index 460af4397..4198c43cd 100644 --- a/scripts/generate-theme-diff-colors.test.ts +++ b/scripts/generate-theme-diff-colors.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { dirname, join } from "node:path"; -import { BUNDLED_SHIKI_THEME_DIFF_COLORS } from "../src/core/theme/catalog"; +import { dirname, join, resolve } from "node:path"; +import { BUNDLED_SHIKI_THEME_DIFF_COLORS } from "../packages/hunk/src/core/theme/catalog"; import { harvestBundledThemeDiffColors, harvestThemeDiffColors, normalizeTokenColor, + resolveThemeCatalogPath, } from "./generate-theme-diff-colors"; describe("normalizeTokenColor", () => { @@ -109,6 +110,21 @@ describe("harvestThemeDiffColors", () => { }); describe("checked-in catalog table", () => { + test("resolves the catalog inside packages/hunk", async () => { + const expected = resolve( + import.meta.dir, + "..", + "packages", + "hunk", + "src", + "core", + "theme", + "catalog.ts", + ); + expect(resolveThemeCatalogPath()).toBe(expected); + expect(await Bun.file(resolveThemeCatalogPath()).exists()).toBe(true); + }); + test("matches a fresh harvest of the installed @shikijs/themes", async () => { expect(BUNDLED_SHIKI_THEME_DIFF_COLORS).toEqual(await harvestBundledThemeDiffColors()); }); diff --git a/scripts/generate-theme-diff-colors.ts b/scripts/generate-theme-diff-colors.ts index 63fd472b2..f06787e6e 100644 --- a/scripts/generate-theme-diff-colors.ts +++ b/scripts/generate-theme-diff-colors.ts @@ -4,11 +4,12 @@ import { BUNDLED_SHIKI_THEME_BACKGROUNDS, type BundledShikiThemeDiffColors, type BundledShikiThemeId, -} from "../src/core/theme/catalog"; +} from "../packages/hunk/src/core/theme/catalog"; /** - * Regenerates `BUNDLED_SHIKI_THEME_DIFF_COLORS` in `src/core/theme/catalog.ts` from the bundled - * Shiki theme JSONs. Run it with `bun run generate:theme-colors`. + * Regenerates `BUNDLED_SHIKI_THEME_DIFF_COLORS` in + * `packages/hunk/src/core/theme/catalog.ts` from the bundled Shiki theme JSONs. Run it with + * `bun run generate:theme-colors`. */ type DiffColorSlot = "added" | "removed" | "modified"; @@ -228,9 +229,14 @@ function renderDiffColorTable( return lines.join("\n"); } +/** Resolves the Hunk package's checked-in theme catalog from this generator's directory. */ +export function resolveThemeCatalogPath(scriptDirectory = import.meta.dir) { + return join(scriptDirectory, "..", "packages", "hunk", "src", "core", "theme", "catalog.ts"); +} + /** Rewrites the generated table region of catalog.ts in place. */ async function writeCatalog(rendered: string) { - const catalogPath = join(import.meta.dir, "..", "src", "core", "theme", "catalog.ts"); + const catalogPath = resolveThemeCatalogPath(); const source = await Bun.file(catalogPath).text(); const startIndex = source.indexOf(GENERATED_START); const endIndex = source.indexOf(GENERATED_END); diff --git a/scripts/inline-edit-extension.test.ts b/scripts/inline-edit-extension.test.ts index 4098a950a..14db47c41 100644 --- a/scripts/inline-edit-extension.test.ts +++ b/scripts/inline-edit-extension.test.ts @@ -9,10 +9,10 @@ import type { ExtensionKeyEvent, ExtensionWorkspaceWriteResult, HunkExtensionAPI, -} from "../src/extension-api/types"; -import { validateFileViewLayout } from "../src/ui/fileViews/layout"; -import { buildFileViewRenderPlan } from "../src/ui/fileViews/renderPlan"; -import { createVisibleAgentNote } from "../src/ui/lib/agentAnnotations"; +} from "../packages/hunk/src/extension-api/types"; +import { validateFileViewLayout } from "../packages/hunk/src/ui/fileViews/layout"; +import { buildFileViewRenderPlan } from "../packages/hunk/src/ui/fileViews/renderPlan"; +import { createVisibleAgentNote } from "../packages/hunk/src/ui/lib/agentAnnotations"; import inlineEditExtension from "../examples/extensions/inline-edit"; const TEST_FILE = { diff --git a/scripts/install-bin.ts b/scripts/install-bin.ts index e531d0881..419ccd731 100644 --- a/scripts/install-bin.ts +++ b/scripts/install-bin.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { chmodSync, copyFileSync, mkdirSync, rmSync } from "node:fs"; +import { chmodSync, copyFileSync, cpSync, mkdirSync, rmSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,6 +9,7 @@ const isWindows = process.platform === "win32"; const binaryName = isWindows ? "hunk.exe" : "hunk"; const legacyBinaryName = isWindows ? "otdiff.exe" : "otdiff"; const binaryPath = path.join(repoRoot, "dist", binaryName); +const builtSkillsDir = path.join(repoRoot, "dist", "skills"); function defaultInstallDir() { if (isWindows) { @@ -43,6 +44,13 @@ if (!isWindows) { } rmSync(legacyInstallPath, { force: true }); +// Keep source installs compatible with npm/prebuilt skill discovery without placing +// generic skill names directly beside every executable in the user's bin directory. +const installedSkillsDir = path.join(installDir, "hunkdiff", "skills"); +rmSync(installedSkillsDir, { recursive: true, force: true }); +mkdirSync(path.dirname(installedSkillsDir), { recursive: true }); +cpSync(builtSkillsDir, installedSkillsDir, { recursive: true }); + console.log(`Installed ${installPath}`); const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean); diff --git a/scripts/jsx-file-view-gallery.test.ts b/scripts/jsx-file-view-gallery.test.ts index 71bc07242..92555e396 100644 --- a/scripts/jsx-file-view-gallery.test.ts +++ b/scripts/jsx-file-view-gallery.test.ts @@ -6,7 +6,7 @@ import type { ExtensionCommandHandler, ExtensionFileView, HunkExtensionAPI, -} from "../src/extension-api/types"; +} from "../packages/hunk/src/extension-api/types"; import { createTestDiffFile, createTestSourceFetcher } from "../test/helpers/diff-helpers"; import galleryExtension, { createChangeAtlasLayout, @@ -15,8 +15,8 @@ import galleryExtension, { impactMeter, versionChangeHighlights, } from "../examples/extensions/jsx-file-view-gallery"; -import { createFileViewInput, fileViewHunkCount } from "../src/ui/fileViews/host"; -import { validateFileViewLayout } from "../src/ui/fileViews/layout"; +import { createFileViewInput, fileViewHunkCount } from "../packages/hunk/src/ui/fileViews/host"; +import { validateFileViewLayout } from "../packages/hunk/src/ui/fileViews/layout"; const galleryRoot = join(import.meta.dir, "../examples/extensions/jsx-file-view-gallery"); diff --git a/scripts/launch-video/capture.ts b/scripts/launch-video/capture.ts index 277a245ce..c75a55356 100644 --- a/scripts/launch-video/capture.ts +++ b/scripts/launch-video/capture.ts @@ -26,7 +26,7 @@ import { } from "@hunk/term-video/capture"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); -const hunkEntrypoint = join(repoRoot, "src/main.tsx"); +const hunkEntrypoint = join(repoRoot, "packages/hunk/src/main.tsx"); const outDir = resolve(process.argv[2] ?? join(repoRoot, ".video-work")); diff --git a/scripts/nix-package.test.ts b/scripts/nix-package.test.ts new file mode 100644 index 000000000..9b4d8ec87 --- /dev/null +++ b/scripts/nix-package.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { REPO_ROOT } from "./package-paths"; + +/** Read the Nix derivation as the static build contract validated without a Nix executable. */ +function readNixPackageDefinition() { + return readFileSync(join(REPO_ROOT, "nix", "package.nix"), "utf8"); +} + +describe("Nix compiled binary contract", () => { + test("selects baseline x64 runtimes while leaving arm64 on the host runtime", () => { + const definition = readNixPackageDefinition(); + expect(definition).toContain("if !stdenv.hostPlatform.isx86_64"); + expect(definition).toContain('then "bun-darwin-x64-baseline"'); + expect(definition).toContain('then "bun-linux-x64-musl-baseline"'); + expect(definition).toContain('else "bun-linux-x64-baseline"'); + expect(definition).toContain("lib.optionalString (compileTarget != null)"); + }); + + test("embeds both the application and syntax-highlight worker entries", () => { + const definition = readNixPackageDefinition(); + expect(definition).toContain('"./packages/hunk/src/main.tsx"'); + expect(definition).toContain('"./packages/hunk/src/highlightWorkerEntry.ts"'); + }); +}); diff --git a/scripts/opentui-consumer-check.ts b/scripts/opentui-consumer-check.ts new file mode 100644 index 000000000..46d2a51e9 --- /dev/null +++ b/scripts/opentui-consumer-check.ts @@ -0,0 +1,107 @@ +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const MODES = { + nodenext: { module: "nodenext", moduleResolution: "nodenext" }, + bundler: { module: "esnext", moduleResolution: "bundler" }, +} as const; + +/** Typecheck the built OpenTUI subpath exactly as an installed package resolves it. */ +export function checkOpenTuiConsumerTypes(repoRoot: string) { + const sourcePackage = path.join(repoRoot, "packages", "hunk"); + const consumerRoot = mkdtempSync(path.join(tmpdir(), "hunk-opentui-consumer-")); + try { + const packageRoot = path.join(consumerRoot, "node_modules", "hunkdiff"); + mkdirSync(path.join(packageRoot, "dist", "npm"), { recursive: true }); + cpSync( + path.join(sourcePackage, "dist", "npm", "opentui"), + path.join(packageRoot, "dist", "npm", "opentui"), + { recursive: true }, + ); + writeFileSync( + path.join(packageRoot, "package.json"), + `${JSON.stringify({ + name: "hunkdiff", + version: "0.0.0-consumer-check", + type: "module", + exports: { + "./opentui": { + types: "./dist/npm/opentui/index.d.ts", + import: "./dist/npm/opentui/index.js", + }, + }, + })}\n`, + ); + + // Link only declared public peers; no Hunk source tree is available to hide + // a missing or private declaration import. + for (const name of [ + "react", + "@types/react", + "@types/bun", + "@pierre/diffs", + "@opentui/core", + "@opentui/react", + ]) { + const source = path.join(repoRoot, "node_modules", ...name.split("/")); + if (!existsSync(source)) throw new Error(`Missing installed peer ${name}.`); + const destination = path.join(consumerRoot, "node_modules", ...name.split("/")); + mkdirSync(path.dirname(destination), { recursive: true }); + symlinkSync(source, destination, process.platform === "win32" ? "junction" : "dir"); + } + + writeFileSync( + path.join(consumerRoot, "consumer.tsx"), + `import type { HunkDiffFileInput } from "hunkdiff/opentui";\n` + + `import { HunkDiffView, createHunkDiffFile } from "hunkdiff/opentui";\n` + + `declare const input: HunkDiffFileInput;\n` + + `createHunkDiffFile(input);\n` + + `void ;\n`, + ); + writeFileSync( + path.join(consumerRoot, "package.json"), + `${JSON.stringify({ name: "consumer", private: true, type: "module" })}\n`, + ); + + for (const [mode, resolution] of Object.entries(MODES)) { + const config = path.join(consumerRoot, `tsconfig.${mode}.json`); + writeFileSync( + config, + `${JSON.stringify({ + compilerOptions: { + target: "ES2022", + lib: ["ESNext", "DOM"], + ...resolution, + jsx: "react-jsx", + strict: true, + noEmit: true, + skipLibCheck: false, + }, + files: ["consumer.tsx"], + })}\n`, + ); + const proc = Bun.spawnSync(["bun", "x", "tsc", "-p", config], { + cwd: repoRoot, + stdout: "pipe", + stderr: "pipe", + }); + if (proc.exitCode !== 0) { + throw new Error( + `hunkdiff/opentui failed ${mode} consumer typecheck:\n${Buffer.from(proc.stdout).toString()}${Buffer.from(proc.stderr).toString()}`, + ); + } + } + return Object.keys(MODES); + } finally { + rmSync(consumerRoot, { recursive: true, force: true }); + } +} diff --git a/scripts/package-paths.ts b/scripts/package-paths.ts new file mode 100644 index 000000000..39cce836b --- /dev/null +++ b/scripts/package-paths.ts @@ -0,0 +1,7 @@ +import path from "node:path"; + +/** Repository and application paths shared by build, pack, and release tooling. */ +export const REPO_ROOT = path.resolve(import.meta.dir, ".."); +export const HUNK_PACKAGE_ROOT = path.join(REPO_ROOT, "packages", "hunk"); +export const HUNK_SOURCE_ROOT = path.join(HUNK_PACKAGE_ROOT, "src"); +export const HUNK_NPM_DIST = path.join(HUNK_PACKAGE_ROOT, "dist", "npm"); diff --git a/scripts/probe-terminal-theme.ts b/scripts/probe-terminal-theme.ts index 66a659b1d..e1f5689a0 100644 --- a/scripts/probe-terminal-theme.ts +++ b/scripts/probe-terminal-theme.ts @@ -6,7 +6,7 @@ import { detectTerminalThemeModeFromBackground, parseOsc11BackgroundColor, themeModeForBackgroundColor, -} from "../src/core/theme/detection"; +} from "../packages/hunk/src/core/theme/detection"; const inputFd = fs.openSync("/dev/tty", "r"); const input = new tty.ReadStream(inputFd); diff --git a/scripts/rendered-markdown-extension.test.ts b/scripts/rendered-markdown-extension.test.ts index 5bec14df6..a84be988f 100644 --- a/scripts/rendered-markdown-extension.test.ts +++ b/scripts/rendered-markdown-extension.test.ts @@ -4,7 +4,7 @@ import type { ExtensionCommandHandler, ExtensionFileView, HunkExtensionAPI, -} from "../src/extension-api/types"; +} from "../packages/hunk/src/extension-api/types"; import renderedMarkdownExtension from "../examples/extensions/rendered-markdown"; function registerMarkdownTestView() { diff --git a/scripts/review-vocabulary.test.ts b/scripts/review-vocabulary.test.ts index e0c2c5e93..3af8c8de1 100644 --- a/scripts/review-vocabulary.test.ts +++ b/scripts/review-vocabulary.test.ts @@ -25,23 +25,24 @@ import { describe, expect, test } from "bun:test"; import { readdirSync, readFileSync } from "node:fs"; import { join, resolve, sep } from "node:path"; import { MAX_WS_MESSAGE_BYTES } from "@hunk/session-broker-core"; -import { REVIEW_INTENT_TYPES } from "../src/core/review/intents"; -import { REVIEW_RESOURCE_CHUNK_BYTES } from "../src/core/review/resources"; +import { REVIEW_INTENT_TYPES } from "../packages/hunk/src/core/review/intents"; +import { REVIEW_RESOURCE_CHUNK_BYTES } from "../packages/hunk/src/core/review/resources"; import { MAX_REVIEW_EVENT_CHUNKS, MAX_REVIEW_EVENT_PAYLOAD_BYTES, REVIEW_EVENT_CHUNK_BYTES, -} from "../src/session/reviewEventProtocol"; +} from "../packages/hunk/src/session/reviewEventProtocol"; import { HUNK_REVIEW_ACTION_TYPES, MAX_HUNK_REVIEW_ENVELOPE_BYTES, parseHunkReviewAction, -} from "../src/session/reviewProtocol"; +} from "../packages/hunk/src/session/reviewProtocol"; const REPO_ROOT = resolve(import.meta.dir, ".."); -const REVIEW_MODEL_ROOT = join(REPO_ROOT, "src", "core", "review"); -const SESSION_ROOT = join(REPO_ROOT, "src", "session"); -const PRODUCER_ROOT = join(REPO_ROOT, "src", "app"); +const APP_SOURCE_ROOT = join(REPO_ROOT, "packages", "hunk", "src"); +const REVIEW_MODEL_ROOT = join(APP_SOURCE_ROOT, "core", "review"); +const SESSION_ROOT = join(APP_SOURCE_ROOT, "session"); +const PRODUCER_ROOT = join(APP_SOURCE_ROOT, "app"); /** Every production TypeScript file below one directory. */ function sourceFiles(directory: string): string[] { @@ -123,7 +124,7 @@ describe("review constant derivation", () => { ...sourceFiles(REVIEW_MODEL_ROOT), ...sourceFiles(PRODUCER_ROOT), ] - .filter((path) => repoPath(path) !== "src/core/review/validation.ts") + .filter((path) => repoPath(path) !== "packages/hunk/src/core/review/validation.ts") .filter((path) => pattern.test(readFileSync(path, "utf8"))) .map(repoPath); diff --git a/scripts/run-test-suite.ts b/scripts/run-test-suite.ts index c01bd76eb..fce869e50 100644 --- a/scripts/run-test-suite.ts +++ b/scripts/run-test-suite.ts @@ -13,7 +13,6 @@ import { availableParallelism } from "node:os"; export const DEFAULT_TEST_PATTERNS = [ - "./src", "./packages", "./scripts", "./examples", diff --git a/scripts/smoke-prebuilt-install.ts b/scripts/smoke-prebuilt-install.ts index 21624e0c8..f67eaa7c0 100644 --- a/scripts/smoke-prebuilt-install.ts +++ b/scripts/smoke-prebuilt-install.ts @@ -87,8 +87,9 @@ function commandDirectory(command: string) { } const repoRoot = path.resolve(import.meta.dir, ".."); -const packageVersion = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()) - .version as string; +const packageVersion = JSON.parse( + await Bun.file(path.join(repoRoot, "packages", "hunk", "package.json")).text(), +).version as string; const releaseRoot = releaseNpmDir(repoRoot); const hostSpec = getHostPlatformPackageSpec(); const tempRoot = path.join(repoRoot, "tmp"); diff --git a/scripts/source-boundaries.test.ts b/scripts/source-boundaries.test.ts index f5561c311..751d100bd 100644 --- a/scripts/source-boundaries.test.ts +++ b/scripts/source-boundaries.test.ts @@ -3,10 +3,13 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; const REPO_ROOT = resolve(import.meta.dir, ".."); -const SRC_ROOT = join(REPO_ROOT, "src"); +const SRC_ROOT = join(REPO_ROOT, "packages", "hunk", "src"); const CORE_ROOT = join(SRC_ROOT, "core"); const EXTENSIONS_ROOT = join(SRC_ROOT, "extensions"); -const BUNDLED_PROVIDER_ROOT = join(EXTENSIONS_ROOT, "default", "vcs"); +const BUNDLED_PROVIDER_ROOTS = ["hunk-git", "hunk-jj", "hunk-sapling"].map((name) => + join(REPO_ROOT, "packages", name, "src"), +); +const VCS_DOMAIN_ROOT = join(REPO_ROOT, "packages", "hunk-vcs", "src"); const REVIEW_MODEL_ROOT = join(CORE_ROOT, "review"); // The published extension contract, which the review model may name for the annotation shapes // that are simultaneously internal model types and part of `hunkdiff/extension`. It cannot widen @@ -157,7 +160,7 @@ function unexpectedExternalImports( /** Find bundled provider imports that bypass the published extension barrel. */ function privateProviderApiImports() { - return sourceFiles(BUNDLED_PROVIDER_ROOT).flatMap((path) => + return BUNDLED_PROVIDER_ROOTS.flatMap((root) => sourceFiles(root)).flatMap((path) => importSpecifiers(path).some((specifier) => specifier.includes("extension-api")) ? [repoPath(path)] : [], @@ -169,7 +172,7 @@ function privateProviderApiImports() { // with the finding id, and this gate keeps them deleted — a reappearing path means the // duplication came back. Entries are repo-relative with forward slashes. const EXTRACTED_DUPLICATE_TOMBSTONES: readonly string[] = [ - "src/ui/lib/hunks.ts", // B1: replaced by core/review selection/move planning + "packages/hunk/src/ui/lib/hunks.ts", // B1: replaced by core/review selection/move planning ]; // Function-level deletions the file tombstones cannot see: each entry bans one named @@ -181,33 +184,69 @@ const EXTRACTED_DUPLICATE_SYMBOLS: ReadonlyArray<{ symbol: string; finding: string; }> = [ - { file: "src/ui/diff/diffRows.ts", symbol: "leadingCollapsedRanges", finding: "A1" }, - { file: "src/ui/diff/diffRows.ts", symbol: "trailingCollapsedRanges", finding: "A1" }, - { file: "src/ui/diff/diffRows.ts", symbol: "trailingCollapsedLines", finding: "A2" }, - { file: "src/ui/diff/expandCollapsedRows.ts", symbol: "sliceLines", finding: "A4" }, - { file: "src/ui/diff/expandCollapsedRows.ts", symbol: "gapKey", finding: "A1" }, - { file: "src/core/liveComments.ts", symbol: "hunkLineRange", finding: "A3" }, - { file: "src/core/liveComments.ts", symbol: "firstCommentTargetForHunk", finding: "A10" }, - { file: "src/core/review/state.ts", symbol: "reviewLineAnchor", finding: "A3" }, - { file: "src/ui/lib/files.ts", symbol: "filterReviewFiles", finding: "B5" }, - { file: "src/ui/lib/reviewState.ts", symbol: "findNextAnnotatedFile", finding: "B2" }, - { file: "src/ui/lib/reviewState.ts", symbol: "resolveSelectedFile", finding: "B4" }, - { file: "src/ui/lib/agentAnnotations.ts", symbol: "alwaysShowReviewNote", finding: "B9" }, { - file: "src/ui/diff/expandCollapsedRows.ts", + file: "packages/hunk/src/ui/diff/diffRows.ts", + symbol: "leadingCollapsedRanges", + finding: "A1", + }, + { + file: "packages/hunk/src/ui/diff/diffRows.ts", + symbol: "trailingCollapsedRanges", + finding: "A1", + }, + { + file: "packages/hunk/src/ui/diff/diffRows.ts", + symbol: "trailingCollapsedLines", + finding: "A2", + }, + { file: "packages/hunk/src/ui/diff/expandCollapsedRows.ts", symbol: "sliceLines", finding: "A4" }, + { file: "packages/hunk/src/ui/diff/expandCollapsedRows.ts", symbol: "gapKey", finding: "A1" }, + { file: "packages/hunk/src/core/liveComments.ts", symbol: "hunkLineRange", finding: "A3" }, + { + file: "packages/hunk/src/core/liveComments.ts", + symbol: "firstCommentTargetForHunk", + finding: "A10", + }, + { file: "packages/hunk/src/core/review/state.ts", symbol: "reviewLineAnchor", finding: "A3" }, + { file: "packages/hunk/src/ui/lib/files.ts", symbol: "filterReviewFiles", finding: "B5" }, + { + file: "packages/hunk/src/ui/lib/reviewState.ts", + symbol: "findNextAnnotatedFile", + finding: "B2", + }, + { file: "packages/hunk/src/ui/lib/reviewState.ts", symbol: "resolveSelectedFile", finding: "B4" }, + { + file: "packages/hunk/src/ui/lib/agentAnnotations.ts", + symbol: "alwaysShowReviewNote", + finding: "B9", + }, + { + file: "packages/hunk/src/ui/diff/expandCollapsedRows.ts", symbol: "selectGapForKeyboardToggle", finding: "F2", }, - { file: "src/ui/lib/agentAnnotations.ts", symbol: "annotationOverlapsHunk", finding: "B1" }, - { file: "src/ui/lib/agentAnnotations.ts", symbol: "getAnnotatedHunkIndices", finding: "B1" }, - { file: "src/ui/lib/reviewState.ts", symbol: "buildReviewAnnotationIndex", finding: "B1" }, { - file: "src/extensions/cliCommandRuntime.ts", + file: "packages/hunk/src/ui/lib/agentAnnotations.ts", + symbol: "annotationOverlapsHunk", + finding: "B1", + }, + { + file: "packages/hunk/src/ui/lib/agentAnnotations.ts", + symbol: "getAnnotatedHunkIndices", + finding: "B1", + }, + { + file: "packages/hunk/src/ui/lib/reviewState.ts", + symbol: "buildReviewAnnotationIndex", + finding: "B1", + }, + { + file: "packages/hunk/src/extensions/cliCommandRuntime.ts", symbol: "validateReviewDescriptor", finding: "delegated-review-descriptor", }, { - file: "src/extensions/cliCommandRuntime.ts", + file: "packages/hunk/src/extensions/cliCommandRuntime.ts", symbol: "validateDescriptorString", finding: "delegated-review-descriptor", }, @@ -249,9 +288,29 @@ describe("source architecture boundaries", () => { }); test("keeps bundled providers on their public host contract", () => { - expect(forbiddenImports(BUNDLED_PROVIDER_ROOT, CORE_ROOT)).toEqual([]); + expect(BUNDLED_PROVIDER_ROOTS.flatMap((root) => forbiddenImports(root, CORE_ROOT))).toEqual([]); expect(privateProviderApiImports()).toEqual([]); }); + + test("keeps the actual hunk-vcs workspace provider-neutral", () => { + expect(existsSync(join(VCS_DOMAIN_ROOT, "diffRange.ts"))).toBe(true); + expect(forbiddenImports(VCS_DOMAIN_ROOT, SRC_ROOT)).toEqual([]); + for (const root of BUNDLED_PROVIDER_ROOTS) { + expect(forbiddenImports(VCS_DOMAIN_ROOT, root)).toEqual([]); + } + expect( + sourceFiles(VCS_DOMAIN_ROOT).flatMap((path) => + importSpecifiers(path) + .filter( + (specifier) => + !specifier.startsWith(".") && + !specifier.startsWith("node:") && + specifier !== "hunkdiff/extension", + ) + .map((specifier) => `${repoPath(path)} -> ${specifier}`), + ), + ).toEqual([]); + }); }); // The seam contract for sharing the review experience with a browser surface: the semantic @@ -271,7 +330,7 @@ describe("shared review primitives seam", () => { // handling at all, so those three entries are gone for good. // Repaid in Phase 2: the last entry, `jsonStream.ts`, is gone. Serializing and hashing a // review resource needs a platform encoder, so that work lives in the producer tier - // (`src/app/review/`) instead, and core takes hashing as an injected `ReviewDigestFn` + // (`packages/hunk/src/app/review/`) instead, and core takes hashing as an injected `ReviewDigestFn` // (`core/review/validation.ts`) — it names the algorithm, validates the digest shape, and // compares two values without ever computing one. The map is now empty and stays that way. const REVIEW_MODEL_NODE_DEBT = new Map(); diff --git a/scripts/stage-prebuilt-npm.ts b/scripts/stage-prebuilt-npm.ts index a001a41a1..9aaf142bd 100644 --- a/scripts/stage-prebuilt-npm.ts +++ b/scripts/stage-prebuilt-npm.ts @@ -64,7 +64,9 @@ function parseArgs(argv: string[]) { } function loadRootPackage(repoRoot: string) { - return JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")) as RootPackageJson; + return JSON.parse( + readFileSync(path.join(repoRoot, "packages", "hunk", "package.json"), "utf8"), + ) as RootPackageJson; } function ensureDirectory(directory: string) { @@ -82,14 +84,15 @@ function stageMetaPackage( specs: readonly PlatformPackageSpec[], ) { const metaDir = path.join(releaseRoot, rootPackage.name); + const appRoot = path.join(repoRoot, "packages", "hunk"); ensureDirectory(path.join(metaDir, "bin")); - cpSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); - cpSync(path.join(repoRoot, "dist", "npm"), path.join(metaDir, "dist", "npm"), { + cpSync(path.join(appRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); + cpSync(path.join(appRoot, "dist", "npm"), path.join(metaDir, "dist", "npm"), { recursive: true, }); - cpSync(path.join(repoRoot, "skills"), path.join(metaDir, "skills"), { recursive: true }); - cpSync(path.join(repoRoot, "README.md"), path.join(metaDir, "README.md")); - cpSync(path.join(repoRoot, "LICENSE"), path.join(metaDir, "LICENSE")); + cpSync(path.join(appRoot, "skills"), path.join(metaDir, "skills"), { recursive: true }); + cpSync(path.join(appRoot, "README.md"), path.join(metaDir, "README.md")); + cpSync(path.join(appRoot, "LICENSE"), path.join(metaDir, "LICENSE")); writeJson(path.join(metaDir, "package.json"), { name: rootPackage.name, @@ -136,7 +139,7 @@ function stagePlatformPackage( const stagedBinary = path.join(packageDir, "bin", binaryName); cpSync(compiledBinary, stagedBinary); chmodSync(stagedBinary, 0o755); - cpSync(path.join(repoRoot, "LICENSE"), path.join(packageDir, "LICENSE")); + cpSync(path.join(repoRoot, "packages", "hunk", "LICENSE"), path.join(packageDir, "LICENSE")); writeJson(path.join(packageDir, "package.json"), buildPlatformPackageManifest(rootPackage, spec)); } diff --git a/scripts/stage-source-npm-package.test.ts b/scripts/stage-source-npm-package.test.ts new file mode 100644 index 000000000..8f817ab96 --- /dev/null +++ b/scripts/stage-source-npm-package.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { stageSourceNpmPackage } from "./stage-source-npm-package"; + +const tempRoots: string[] = []; + +afterEach(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +/** Write one minimal package tree accepted by the source npm stager. */ +function createTestPackage() { + const root = mkdtempSync(path.join(tmpdir(), "hunk-source-pack-")); + tempRoots.push(root); + for (const directory of ["bin", "dist/npm", "skills"]) { + mkdirSync(path.join(root, directory), { recursive: true }); + writeFileSync(path.join(root, directory, "fixture"), directory); + } + for (const file of ["README.md", "LICENSE"]) writeFileSync(path.join(root, file), file); + writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ + name: "hunkdiff", + version: "1.0.0", + scripts: { prepack: "build" }, + devDependencies: { + "@hunk/git": "workspace:*", + "@hunk/session-broker": "workspace:*", + "@hunk/session-broker-bun": "workspace:*", + "@hunk/session-broker-core": "workspace:*", + typescript: "5.9.3", + }, + }), + ); + return root; +} + +describe("source npm package staging", () => { + test("keeps the workspace build graph out of the published manifest", () => { + const source = createTestPackage(); + const destination = path.join(source, "staged"); + + stageSourceNpmPackage(destination, source); + + const manifest = JSON.parse(readFileSync(path.join(destination, "package.json"), "utf8")); + expect(manifest.devDependencies).toEqual({ typescript: "5.9.3" }); + expect(manifest.scripts).toBeUndefined(); + expect(readFileSync(path.join(destination, "dist", "npm", "fixture"), "utf8")).toBe("dist/npm"); + }); +}); diff --git a/scripts/stage-source-npm-package.ts b/scripts/stage-source-npm-package.ts new file mode 100644 index 000000000..b8491f05a --- /dev/null +++ b/scripts/stage-source-npm-package.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env bun + +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { HUNK_PACKAGE_ROOT, REPO_ROOT } from "./package-paths"; + +export const SOURCE_NPM_STAGE = path.join(REPO_ROOT, "dist", "source-npm", "hunkdiff"); + +/** Stage the publishable source npm package without private workspace build dependencies. */ +export function stageSourceNpmPackage( + destination = SOURCE_NPM_STAGE, + packageRoot = HUNK_PACKAGE_ROOT, +) { + rmSync(destination, { recursive: true, force: true }); + mkdirSync(destination, { recursive: true }); + + for (const entry of ["bin", "dist/npm", "skills", "README.md", "LICENSE"] as const) { + cpSync(path.join(packageRoot, entry), path.join(destination, entry), { recursive: true }); + } + + const manifest = JSON.parse( + readFileSync(path.join(packageRoot, "package.json"), "utf8"), + ) as Record; + const devDependencies = { ...(manifest.devDependencies as Record) }; + for (const packageName of Object.keys(devDependencies)) { + if (packageName.startsWith("@hunk/")) delete devDependencies[packageName]; + } + manifest.devDependencies = devDependencies; + delete manifest.scripts; + writeFileSync(path.join(destination, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); + return destination; +} + +if (import.meta.main) { + console.log(`Staged ${stageSourceNpmPackage()}`); +} diff --git a/scripts/test-large-untracked-render.tsx b/scripts/test-large-untracked-render.tsx index fecb6ce89..c756fe88b 100644 --- a/scripts/test-large-untracked-render.tsx +++ b/scripts/test-large-untracked-render.tsx @@ -3,8 +3,8 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { act } from "react"; -import { loadAppBootstrap } from "../src/core/changeset/loaders"; -import { AppHost } from "../src/ui/AppHost"; +import { loadAppBootstrap } from "../packages/hunk/src/core/changeset/loaders"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; function runGit(cwd: string, ...args: string[]) { const proc = Bun.spawnSync(["git", ...args], { diff --git a/scripts/verify-pr-release-notes.test.ts b/scripts/verify-pr-release-notes.test.ts index dca493e69..77f8b6078 100644 --- a/scripts/verify-pr-release-notes.test.ts +++ b/scripts/verify-pr-release-notes.test.ts @@ -15,7 +15,7 @@ const generatedPaths = [ ".changeset/pre.json", ".changeset/old-fix.md", "CHANGELOG.md", - "package.json", + "packages/hunk/package.json", "benchmarks/release/bench-0.18.0-beta.0.json", ]; @@ -53,16 +53,20 @@ function createTestRepo() { const root = mkdtempSync(path.join(os.tmpdir(), "hunk-pr-release-notes-")); tempRoots.push(root); mkdirSync(path.join(root, ".changeset")); + mkdirSync(path.join(root, "packages", "hunk"), { recursive: true }); runGit(root, ["init", "--quiet"]); runGit(root, ["config", "user.email", "test@example.com"]); runGit(root, ["config", "user.name", "Hunk Test"]); writeJson(path.join(root, "package.json"), { - name: "hunkdiff", - version: "0.17.7", + name: "@hunk/workspace", private: true, scripts: { "changeset:status": "bun run ./record-status.ts" }, }); + writeJson(path.join(root, "packages", "hunk", "package.json"), { + name: "hunkdiff", + version: "0.17.7", + }); writeFileSync(path.join(root, "CHANGELOG.md"), "# Changelog\n"); writeFileSync(path.join(root, ".changeset", "new-feature.md"), "---\n---\n"); writeFileSync( @@ -75,9 +79,11 @@ function createTestRepo() { } function writeGeneratedPrerelease(root: string, initialVersion = "0.17.7") { - const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); + const packageJson = JSON.parse( + readFileSync(path.join(root, "packages", "hunk", "package.json"), "utf8"), + ); packageJson.version = "0.18.0-beta.0"; - writeJson(path.join(root, "package.json"), packageJson); + writeJson(path.join(root, "packages", "hunk", "package.json"), packageJson); writeJson(path.join(root, ".changeset", "pre.json"), { mode: "pre", tag: "beta", @@ -99,7 +105,7 @@ describe("isGeneratedReleasePath", () => { expect(isGeneratedReleasePath(filePath)).toBe(true); } - expect(isGeneratedReleasePath("src/main.tsx")).toBe(false); + expect(isGeneratedReleasePath("packages/hunk/src/main.tsx")).toBe(false); expect(isGeneratedReleasePath("benchmarks/run.ts")).toBe(false); expect(isGeneratedReleasePath("bun.lock")).toBe(false); }); @@ -111,12 +117,18 @@ describe("isGeneratedPrereleasePreparation", () => { }); test("keeps ordinary changesets on the standard status path", () => { - expect(isGeneratedPrereleasePreparation(["src/main.tsx", ".changeset/fix.md"])).toBe(false); - expect(isGeneratedPrereleasePreparation(["CHANGELOG.md", "package.json"])).toBe(false); + expect( + isGeneratedPrereleasePreparation(["packages/hunk/src/main.tsx", ".changeset/fix.md"]), + ).toBe(false); + expect(isGeneratedPrereleasePreparation(["CHANGELOG.md", "packages/hunk/package.json"])).toBe( + false, + ); }); test("does not exempt release preparation mixed with source changes", () => { - expect(isGeneratedPrereleasePreparation([...generatedPaths, "src/main.tsx"])).toBe(false); + expect( + isGeneratedPrereleasePreparation([...generatedPaths, "packages/hunk/src/main.tsx"]), + ).toBe(false); }); }); @@ -202,9 +214,11 @@ describe("verifyPrReleaseNotes", () => { const { root } = createTestRepo(); writeGeneratedPrerelease(root); const prereleaseBase = runGit(root, ["rev-parse", "HEAD"]); - const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); + const packageJson = JSON.parse( + readFileSync(path.join(root, "packages", "hunk", "package.json"), "utf8"), + ); packageJson.version = "0.18.0"; - writeJson(path.join(root, "package.json"), packageJson); + writeJson(path.join(root, "packages", "hunk", "package.json"), packageJson); writeFileSync(path.join(root, "CHANGELOG.md"), "# Changelog\n\n## 0.18.0\n\n## 0.17.7\n"); rmSync(path.join(root, ".changeset", "pre.json")); rmSync(path.join(root, ".changeset", "new-feature.md")); diff --git a/scripts/verify-pr-release-notes.ts b/scripts/verify-pr-release-notes.ts index 300738da8..dfbf4552f 100644 --- a/scripts/verify-pr-release-notes.ts +++ b/scripts/verify-pr-release-notes.ts @@ -31,7 +31,7 @@ export function isGeneratedReleasePath(filePath: string) { return ( filePath === ".changeset/pre.json" || filePath === "CHANGELOG.md" || - filePath === "package.json" || + filePath === "packages/hunk/package.json" || CHANGESET_PATTERN.test(filePath) || RELEASE_BENCHMARK_PATTERN.test(filePath) ); @@ -179,7 +179,9 @@ export async function verifyPrReleaseNotes( } const [packageJson, pre, changelog] = await Promise.all([ - Bun.file(path.join(root, "package.json")).json() as Promise, + Bun.file( + path.join(root, "packages", "hunk", "package.json"), + ).json() as Promise, Bun.file(prePath).json() as Promise, Bun.file(path.join(root, "CHANGELOG.md")).text(), ]); diff --git a/src/app/vcsCatalog.test.ts b/src/app/vcsCatalog.test.ts deleted file mode 100644 index e2e865d0e..000000000 --- a/src/app/vcsCatalog.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { getBundledVcsCatalog } from "./vcsCatalog"; - -describe("app VCS catalog composition", () => { - test("owns bundled ordering, fallback, and reserved ids at the app boundary", () => { - const catalog = getBundledVcsCatalog(); - - expect(catalog.defaultAdapterId).toBe("git"); - expect(catalog.adapters.map((adapter) => adapter.id)).toEqual(["jj", "sl", "git"]); - expect(catalog.reservedIds).toEqual(new Set(["jj", "sl", "git"])); - }); -}); diff --git a/src/extensions/default/vcs/index.test.ts b/src/extensions/default/vcs/index.test.ts deleted file mode 100644 index 4a828903f..000000000 --- a/src/extensions/default/vcs/index.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { getBundledVcsAdapters, loadBundledExtensions } from "."; - -describe("bundled extension tier", () => { - test("loads every shipped VCS backend through the public registration API", () => { - const { registry, issues } = loadBundledExtensions(); - - expect(issues).toEqual([]); - expect(registry.extensions.map((extension) => extension.id)).toEqual(["jj", "sl", "git"]); - expect(registry.extensions.every((extension) => extension.origin === "bundled")).toBe(true); - // Registered, not hand-assembled: each adapter is tagged with the bundled - // extension that called `hunk.registerVcsAdapter`, exactly like a user one. - // Git included — there are no core-registered adapters left. - expect(registry.vcsAdapters.map((entry) => [entry.extensionId, entry.adapter.id])).toEqual([ - ["jj", "jj"], - ["sl", "sl"], - ["git", "git"], - ]); - }); - - test("normalizes bundled adapters through the same host conversion as user adapters", () => { - for (const adapter of getBundledVcsAdapters()) { - // The public shape leaves `operations` optional; the internal one does not. - expect(adapter.operations).toBeDefined(); - expect(adapter.operations["working-tree-diff"]).toBeDefined(); - expect(adapter.operations["revision-show"]).toBeDefined(); - } - }); - - test("keeps stash review to the one backend that has stashes", () => { - const byId = new Map(getBundledVcsAdapters().map((adapter) => [adapter.id, adapter])); - - expect(byId.get("git")?.operations["stash-show"]).toBeDefined(); - // Neither jj nor Sapling has a stash, so the command must report that rather - // than crash on a missing operation. - expect(byId.get("jj")?.operations["stash-show"]).toBeUndefined(); - expect(byId.get("sl")?.operations["stash-show"]).toBeUndefined(); - }); - - test("loads once per process so every resolution path sees one adapter identity", () => { - const first = loadBundledExtensions(); - - expect(loadBundledExtensions()).toBe(first); - expect(getBundledVcsAdapters()[0]).toBe(first.registry.vcsAdapters[0]?.adapter); - }); - - test("registers Jujutsu and Sapling above the Git baseline", () => { - const byId = new Map( - getBundledVcsAdapters().map((adapter) => [adapter.id, adapter.detectionPriority ?? 0]), - ); - - // A colocated jj or Sapling checkout carries Git metadata too, so both must - // outrank Git for the same directory. - expect(byId.get("git")).toBe(0); - expect(byId.get("sl")).toBeGreaterThan(byId.get("git")!); - expect(byId.get("jj")).toBeGreaterThan(byId.get("sl")!); - }); -}); diff --git a/src/extensions/default/vcs/index.ts b/src/extensions/default/vcs/index.ts deleted file mode 100644 index 7cdc221ce..000000000 --- a/src/extensions/default/vcs/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -import gitExtension from "./git"; -import jjExtension from "./jujutsu"; -import slExtension from "./sapling"; -import { runExtensionFactory } from "../../runExtension"; -import { - createEmptyExtensionRegistry, - type ExtensionFactory, - type ExtensionLoadIssue, - type ExtensionMetadata, - type ExtensionRegistry, -} from "../../types"; - -/** - * The bundled extension tier. - * - * Every VCS backend Hunk ships — Git, Jujutsu, Sapling — is an extension, - * registered through the same `registerVcsAdapter` a third-party author calls. - * There are no core-registered adapters left, which is the point: a capability - * Hunk ships on cannot quietly outgrow the API it publishes, and Git is the - * backend that exercises every integration point there is. - * - * Three things separate this tier from user extensions: - * - * - The factories are **statically imported**, so they compile into the binary - * and load synchronously. Adapter resolution happens during config - * resolution, long before the async user-extension pass, and the session's - * backend has to be there for it. - * - They are **implicitly trusted**: they are Hunk's own code, so there is no - * discovery, no trust prompt, and no `[extension.]` config table. - * - They stay loaded under `--no-extensions` and `[extensions] enabled = - * false`. Those switches exist to triage *user* extensions; losing VCS - * support from a debugging flag would break every workflow there is. - * - * Failure isolation still applies — a throwing factory becomes a load issue - * rather than a crash — even though these factories are Hunk's own and that - * path should be unreachable. - * - * VCS backends are the only registration kind this tier uses today. The app - * composition root reads `getBundledVcsAdapters` and builds the core catalog. - * A bundled extension that registered a theme or a changeset transform would - * also have to be threaded through `applyExtensionRegistrations`, which today - * only sees the user-extension load result. - */ - -interface BundledExtensionDefinition { - id: string; - factory: ExtensionFactory; -} - -/** - * Every bundled extension, in load order. - * - * Load order only breaks ties: detection order comes from each adapter's - * `detectionPriority`, assembled by the app-owned catalog. - */ -const BUNDLED_EXTENSIONS: readonly BundledExtensionDefinition[] = [ - { id: "jj", factory: jjExtension }, - { id: "sl", factory: slExtension }, - { id: "git", factory: gitExtension }, -]; - -/** Everything the bundled tier contributed, plus any factory that failed. */ -export interface BundledExtensionLoad { - registry: ExtensionRegistry; - issues: readonly ExtensionLoadIssue[]; -} - -let bundledLoad: BundledExtensionLoad | undefined; - -/** - * Build the bundled extension metadata for one definition. - * - * `sourcePath` names a module inside the binary rather than a file on disk, so - * notices keyed by it stay stable and never point at a path a user could edit. - */ -function bundledMetadata(id: string): ExtensionMetadata { - return { id, sourcePath: `hunk:bundled/${id}`, origin: "bundled" }; -} - -/** - * Load every bundled extension once per process, synchronously. - * - * Memoized because adapter resolution asks for it from config resolution, the - * static pager, watch planning, and the interactive app — all of which must see - * the same adapter identities. - */ -export function loadBundledExtensions(): BundledExtensionLoad { - if (bundledLoad) { - return bundledLoad; - } - - const registry = createEmptyExtensionRegistry(); - const issues: ExtensionLoadIssue[] = []; - - for (const { id, factory } of BUNDLED_EXTENSIONS) { - // Bundled factories are synchronous by construction, so `runExtensionFactory` - // has fully applied (or rolled back) each one before it returns. - runExtensionFactory({ metadata: bundledMetadata(id), registry, issues, factory }); - } - - bundledLoad = { registry, issues }; - return bundledLoad; -} - -/** Return the VCS backends the bundled tier registered, in registration order. */ -export function getBundledVcsAdapters() { - return loadBundledExtensions().registry.vcsAdapters.map((entry) => entry.adapter); -} diff --git a/src/extensions/manage/cli.ts b/src/extensions/manage/cli.ts deleted file mode 100644 index 1bc9728af..000000000 --- a/src/extensions/manage/cli.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { HunkUserError } from "../../core/run/errors"; -import { resolveInstalledExtensionsRoot } from "../../core/run/paths"; -import type { ExtensionManageCommandInput } from "../../core/run/commandInputs"; -import { - installExtension, - listExtensions, - removeExtension, - updateExtension, - type ExtensionManageContext, -} from "./install"; -import { parseExtensionInstallSource } from "./source"; - -/** - * The I/O one `hunk extension` command runs against. - * - * Everything the runner touches outside the managed install root arrives - * through this seam, so tests can drive install confirmations and read output - * without owning a terminal. - */ -export interface ExtensionManageIo { - stdout: (text: string) => void; - stderr: (text: string) => void; - /** Ask one yes/no question on a real terminal; absent when there is none. */ - confirm?: (question: string) => Promise; - env?: NodeJS.ProcessEnv; -} - -/** Shorten one commit sha for display. */ -function shortCommit(commit: string) { - return commit.slice(0, 7); -} - -/** Phrase one recorded source with its pinned ref, when it has one. */ -function describeSource(source: string, ref: string | undefined) { - return ref !== undefined ? `${source} @ ${ref}` : source; -} - -/** Resolve the managed install root or explain why there is none. */ -function requireInstalledRoot(env: NodeJS.ProcessEnv) { - const installedRoot = resolveInstalledExtensionsRoot(env); - if (!installedRoot) { - throw new HunkUserError( - "Could not resolve the extension install directory because HOME/XDG_CONFIG_HOME is unset.", - ); - } - - return installedRoot; -} - -/** - * Run one `hunk extension` command and return its exit code. - * - * Install is the only interactive step: extensions execute with the user's - * full permissions, so a fresh install requires either a terminal confirmation - * or an explicit `--yes`. Everything else operates on what is already - * recorded and just prints what it did. - */ -export async function runExtensionManageCommand( - input: ExtensionManageCommandInput, - io: ExtensionManageIo, -): Promise { - const env = io.env ?? process.env; - const context: ExtensionManageContext = { - installedRoot: requireInstalledRoot(env), - log: (line) => io.stderr(`${line}\n`), - }; - - if (input.action === "install") { - const source = parseExtensionInstallSource(input.source); - - if (!input.yes) { - if (!io.confirm) { - throw new HunkUserError( - "Installing an extension needs a confirmation, and there is no terminal to ask on.", - [`Re-run with --yes after reviewing ${source.cloneUrl}.`], - ); - } - - io.stdout( - `Install ${describeSource(source.cloneUrl, source.ref)}?\n` + - "Extensions run with your full user permissions. Only install repositories you trust.\n", - ); - if (!(await io.confirm("Proceed? [y/N] "))) { - io.stdout("Install cancelled.\n"); - return 1; - } - } - - const outcome = installExtension(context, source); - io.stdout( - `Installed ${outcome.name}${outcome.version ? ` v${outcome.version}` : ""} at ${shortCommit(outcome.commit)} into ${outcome.directory}.\n`, - ); - if (outcome.dependencyWarning) { - io.stderr(`warning: ${outcome.dependencyWarning}\n`); - } - io.stdout("New Hunk sessions will load it automatically.\n"); - return 0; - } - - if (input.action === "list") { - const entries = listExtensions(context); - if (entries.length === 0) { - io.stdout( - "No managed extension installs.\nInstall one with `hunk extension install /`.\n", - ); - return 0; - } - - for (const entry of entries) { - const version = entry.version ? `v${entry.version}` : shortCommit(entry.record.commit); - const missing = entry.present ? "" : " (missing on disk — reinstall or remove)"; - // The clone URL plus ref, not the raw spec: a spec like `acme/x@v1` - // already embeds the ref, and printing both would repeat it. - io.stdout( - `${entry.name} ${version} ${describeSource(entry.record.cloneUrl, entry.record.ref)}${missing}\n`, - ); - } - return 0; - } - - if (input.action === "update") { - const names = - input.name !== undefined ? [input.name] : listExtensions(context).map((entry) => entry.name); - if (names.length === 0) { - io.stdout("No managed extension installs to update.\n"); - return 0; - } - - for (const name of names) { - const outcome = updateExtension(context, name); - io.stdout( - outcome.changed - ? `Updated ${outcome.name}${outcome.version ? ` to v${outcome.version}` : ""}: ${shortCommit(outcome.previousCommit)} -> ${shortCommit(outcome.commit)}.\n` - : `${outcome.name} is already up to date (${shortCommit(outcome.commit)}).\n`, - ); - if (outcome.dependencyWarning) { - io.stderr(`warning: ${outcome.dependencyWarning}\n`); - } - } - return 0; - } - - removeExtension(context, input.name); - io.stdout(`Removed ${input.name}.\n`); - return 0; -} diff --git a/src/extensions/manage/install.test.ts b/src/extensions/manage/install.test.ts deleted file mode 100644 index e5135ec64..000000000 --- a/src/extensions/manage/install.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { discoverExtensions } from "../discovery"; -import { runExtensionManageCommand } from "./cli"; -import { - installExtension, - listExtensions, - removeExtension, - updateExtension, - type ExtensionManageContext, -} from "./install"; -import { readInstallRecords } from "./records"; -import { parseExtensionInstallSource } from "./source"; - -// Every test here spawns real Git processes — a fixture repo, usually a clone, -// sometimes a second one for an update. Hosted Windows runners can stall a -// single clone past Bun's five-second default, so bound the suite generously. -setDefaultTimeout(30_000); - -const tempDirs: string[] = []; - -function createTempDir(prefix: string) { - const dir = mkdtempSync(join(tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) { - rmSync(dir, { recursive: true, force: true }); - } - } -}); - -/** Run one git command in a fixture repo, failing the test on error. */ -function runFixtureGit(cwd: string, args: string[]) { - const proc = Bun.spawnSync(["git", ...args], { - cwd, - stdout: "pipe", - stderr: "pipe", - stdin: "ignore", - }); - if (proc.exitCode !== 0) { - throw new Error(`git ${args.join(" ")} failed: ${proc.stderr?.toString()}`); - } - return proc.stdout?.toString() ?? ""; -} - -/** Create one commit-able extension repository fixture and return its path. */ -function createExtensionRepoFixture(name: string) { - const repo = join(createTempDir("hunk-manage-fixture-"), name); - mkdirSync(repo, { recursive: true }); - runFixtureGit(repo, ["init", "--quiet"]); - runFixtureGit(repo, ["config", "user.email", "test@example.com"]); - runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); - writeFileSync( - join(repo, "package.json"), - JSON.stringify({ name, version: "1.0.0", hunk: { extensions: ["./index.ts"] } }), - ); - writeFileSync(join(repo, "index.ts"), "export default () => {};\n"); - runFixtureGit(repo, ["add", "."]); - runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); - return repo; -} - -/** Build one manage context against a fresh managed root. */ -function createTestContext(): ExtensionManageContext & { logs: string[] } { - const logs: string[] = []; - return { - installedRoot: join(createTempDir("hunk-manage-root-"), "installed"), - log: (line) => logs.push(line), - logs, - }; -} - -describe("managed extension installs", () => { - test("installs, records, and discovers a local git repository", () => { - const repo = createExtensionRepoFixture("word-diff"); - const context = createTestContext(); - - const outcome = installExtension(context, parseExtensionInstallSource(repo)); - - expect(outcome.name).toBe("word-diff"); - expect(outcome.version).toBe("1.0.0"); - expect(existsSync(join(outcome.directory, "index.ts"))).toBe(true); - - const records = readInstallRecords(context.installedRoot); - expect(records["word-diff"]?.cloneUrl).toBe(repo); - expect(records["word-diff"]?.commit).toBe(outcome.commit); - - // Discovery picks the install up through the global group, one level below - // the global extensions dir. - const globalDir = join(context.installedRoot, ".."); - const candidates = discoverExtensions({ - cwd: globalDir, - repoRoot: undefined, - globalExtensionsDir: globalDir, - }); - expect(candidates).toEqual([ - { - id: "word-diff", - path: join(context.installedRoot, "word-diff", "index.ts"), - origin: "global", - }, - ]); - }); - - test("installs a pinned tag and stays put until updated", () => { - const repo = createExtensionRepoFixture("pinned-ext"); - runFixtureGit(repo, ["tag", "v1"]); - const context = createTestContext(); - - const outcome = installExtension(context, parseExtensionInstallSource(`${repo}@v1`)); - expect(readInstallRecords(context.installedRoot)["pinned-ext"]?.ref).toBe("v1"); - - // A new commit on the default branch must not move a tag-pinned install. - writeFileSync(join(repo, "extra.ts"), "export const later = true;\n"); - runFixtureGit(repo, ["add", "."]); - runFixtureGit(repo, ["commit", "--quiet", "-m", "later"]); - - const update = updateExtension(context, "pinned-ext"); - expect(update.changed).toBe(false); - expect(update.commit).toBe(outcome.commit); - }); - - test("updates a branch-tracking install to the new commit", () => { - const repo = createExtensionRepoFixture("tracking-ext"); - const context = createTestContext(); - const installed = installExtension(context, parseExtensionInstallSource(repo)); - - writeFileSync( - join(repo, "package.json"), - JSON.stringify({ - name: "tracking-ext", - version: "1.1.0", - hunk: { extensions: ["./index.ts"] }, - }), - ); - runFixtureGit(repo, ["add", "."]); - runFixtureGit(repo, ["commit", "--quiet", "-m", "bump"]); - - const update = updateExtension(context, "tracking-ext"); - expect(update.changed).toBe(true); - expect(update.previousCommit).toBe(installed.commit); - expect(update.commit).not.toBe(installed.commit); - expect(update.version).toBe("1.1.0"); - expect(readInstallRecords(context.installedRoot)["tracking-ext"]?.commit).toBe(update.commit); - }); - - test("refuses a repository that contains no extension", () => { - const repo = join(createTempDir("hunk-manage-empty-"), "not-an-ext"); - mkdirSync(repo, { recursive: true }); - runFixtureGit(repo, ["init", "--quiet"]); - runFixtureGit(repo, ["config", "user.email", "test@example.com"]); - runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); - writeFileSync(join(repo, "README.md"), "not an extension\n"); - runFixtureGit(repo, ["add", "."]); - runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); - const context = createTestContext(); - - expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( - /does not contain a Hunk extension/, - ); - expect(existsSync(join(context.installedRoot, "not-an-ext"))).toBe(false); - expect(readInstallRecords(context.installedRoot)).toEqual({}); - }); - - test("refuses to install over an existing record or an unmanaged directory", () => { - const repo = createExtensionRepoFixture("twice-ext"); - const context = createTestContext(); - installExtension(context, parseExtensionInstallSource(repo)); - - expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( - /already installed/, - ); - - mkdirSync(join(context.installedRoot, "hand-copied"), { recursive: true }); - expect(() => - installExtension(context, { - ...parseExtensionInstallSource(repo), - name: "hand-copied", - }), - ).toThrow(/not a managed install/); - }); - - test("removes a managed install's directory and record", () => { - const repo = createExtensionRepoFixture("removable-ext"); - const context = createTestContext(); - const outcome = installExtension(context, parseExtensionInstallSource(repo)); - - removeExtension(context, "removable-ext"); - - expect(existsSync(outcome.directory)).toBe(false); - expect(readInstallRecords(context.installedRoot)).toEqual({}); - expect(() => removeExtension(context, "removable-ext")).toThrow(/not a managed install/); - }); - - test("lists installs with version, source, and missing-directory state", () => { - const repo = createExtensionRepoFixture("listed-ext"); - const context = createTestContext(); - installExtension(context, parseExtensionInstallSource(repo)); - - const entries = listExtensions(context); - expect(entries).toHaveLength(1); - expect(entries[0]?.name).toBe("listed-ext"); - expect(entries[0]?.version).toBe("1.0.0"); - expect(entries[0]?.present).toBe(true); - - rmSync(join(context.installedRoot, "listed-ext"), { recursive: true, force: true }); - expect(listExtensions(context)[0]?.present).toBe(false); - }); -}); - -describe("hunk extension command runner", () => { - /** Drive the runner against a temp config dir, capturing output. */ - function createRunnerIo(confirmAnswer?: boolean) { - const configDir = createTempDir("hunk-manage-config-"); - const out: string[] = []; - const err: string[] = []; - return { - configDir, - out, - err, - io: { - stdout: (text: string) => out.push(text), - stderr: (text: string) => err.push(text), - ...(confirmAnswer !== undefined ? { confirm: async () => confirmAnswer } : {}), - env: { XDG_CONFIG_HOME: configDir } as NodeJS.ProcessEnv, - }, - }; - } - - test("install --yes runs end to end and list reports it", async () => { - const repo = createExtensionRepoFixture("runner-ext"); - const runner = createRunnerIo(); - - const exitCode = await runExtensionManageCommand( - { kind: "extension-manage", action: "install", source: repo, yes: true }, - runner.io, - ); - - expect(exitCode).toBe(0); - expect(runner.out.join("")).toContain("Installed runner-ext v1.0.0"); - - const listExit = await runExtensionManageCommand( - { kind: "extension-manage", action: "list" }, - runner.io, - ); - expect(listExit).toBe(0); - expect(runner.out.join("")).toContain("runner-ext v1.0.0"); - }); - - test("install without --yes needs a confirmation and honors a refusal", async () => { - const repo = createExtensionRepoFixture("prompted-ext"); - const noTerminal = createRunnerIo(); - - await expect( - runExtensionManageCommand( - { kind: "extension-manage", action: "install", source: repo, yes: false }, - noTerminal.io, - ), - ).rejects.toThrow(/no terminal/); - - const refused = createRunnerIo(false); - const exitCode = await runExtensionManageCommand( - { kind: "extension-manage", action: "install", source: repo, yes: false }, - refused.io, - ); - expect(exitCode).toBe(1); - expect(refused.out.join("")).toContain("full user permissions"); - expect(refused.out.join("")).toContain("Install cancelled."); - }); -}); - -describe("install validation strictness", () => { - test("refuses a repository whose only entry is an incidental src/index.ts", () => { - // The shape of nearly every JavaScript project — and of pi extensions, - // whose manifests use a `pi` field instead of `hunk`. - const repo = join(createTempDir("hunk-manage-incidental-"), "pi-shaped"); - mkdirSync(join(repo, "src"), { recursive: true }); - runFixtureGit(repo, ["init", "--quiet"]); - runFixtureGit(repo, ["config", "user.email", "test@example.com"]); - runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); - writeFileSync( - join(repo, "package.json"), - JSON.stringify({ name: "pi-shaped", pi: { extensions: ["./src/index.ts"] } }), - ); - writeFileSync(join(repo, "src", "index.ts"), "export default () => {};\n"); - runFixtureGit(repo, ["add", "."]); - runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); - const context = createTestContext(); - - expect(() => installExtension(context, parseExtensionInstallSource(repo))).toThrow( - /does not contain a Hunk extension/, - ); - }); - - test("accepts a collection repository of subfolders with hunk manifests", () => { - const repo = join(createTempDir("hunk-manage-collection-"), "ext-pack"); - mkdirSync(join(repo, "one"), { recursive: true }); - runFixtureGit(repo, ["init", "--quiet"]); - runFixtureGit(repo, ["config", "user.email", "test@example.com"]); - runFixtureGit(repo, ["config", "user.name", "Hunk Test"]); - writeFileSync( - join(repo, "one", "package.json"), - JSON.stringify({ name: "one", hunk: { extensions: ["./entry.ts"] } }), - ); - writeFileSync(join(repo, "one", "entry.ts"), "export default () => {};\n"); - runFixtureGit(repo, ["add", "."]); - runFixtureGit(repo, ["commit", "--quiet", "-m", "initial"]); - const context = createTestContext(); - - const outcome = installExtension(context, parseExtensionInstallSource(repo)); - expect(outcome.name).toBe("ext-pack"); - }); -}); diff --git a/test/README.md b/test/README.md index 05325d2b4..8f55606f5 100644 --- a/test/README.md +++ b/test/README.md @@ -1,6 +1,6 @@ # Test layout -Most Hunk tests are colocated in `src/` beside the code they cover. +Most Hunk tests are colocated in `packages/hunk/src/` beside the code they cover. The top-level `test/` tree is reserved for cases that intentionally exercise the product across module, process, repo, or terminal boundaries. @@ -33,5 +33,5 @@ These tests do not belong to a single source file. They usually verify product-l - PTY / terminal rendering behavior - full review-flow interactions across multiple modules -If a test mainly targets one module or helper, keep it colocated in `src/`. +If a test mainly targets one module or helper, keep it colocated in `packages/hunk/src/`. If it needs a real repo, subprocess, daemon, PTY, or transcript-level assertion, it likely belongs under `test/`. diff --git a/test/cli/compiled-headless-native-lib.test.ts b/test/cli/compiled-headless-native-lib.test.ts index 8d260beed..f77dc9e15 100644 --- a/test/cli/compiled-headless-native-lib.test.ts +++ b/test/cli/compiled-headless-native-lib.test.ts @@ -45,10 +45,10 @@ function buildCompiledControls() { name: "highlight worker control", entries: [ resolve(import.meta.dir, "fixtures", "compiled-highlight-worker-control.ts"), - resolve(import.meta.dir, "..", "..", "src", "highlightWorkerEntry.ts"), + resolve(import.meta.dir, "..", "..", "packages", "hunk", "src", "highlightWorkerEntry.ts"), ], executable: highlightWorkerControlExecutable, - root: resolve(import.meta.dir, "..", "..", "src"), + root: resolve(import.meta.dir, "..", "..", "packages", "hunk", "src"), }, ]; diff --git a/test/cli/entrypoint.test.ts b/test/cli/entrypoint.test.ts index 758bf0ff9..31690fd5c 100644 --- a/test/cli/entrypoint.test.ts +++ b/test/cli/entrypoint.test.ts @@ -39,7 +39,7 @@ function uncapturedPagerEnv() { describe("CLI entrypoint contracts", () => { test("bare hunk prints standard help without terminal takeover sequences", () => { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx"], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx"], { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", @@ -74,7 +74,7 @@ describe("CLI entrypoint contracts", () => { }); test("prints daemon help without terminal takeover sequences", () => { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "daemon", "--help"], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "daemon", "--help"], { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", @@ -92,7 +92,7 @@ describe("CLI entrypoint contracts", () => { }); test("prints session help with the review command without terminal takeover sequences", () => { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "session", "--help"], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "session", "--help"], { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", @@ -114,12 +114,15 @@ describe("CLI entrypoint contracts", () => { }); test("prints session reload help without terminal takeover sequences", () => { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "session", "reload", "--help"], { - cwd: process.cwd(), - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const proc = Bun.spawnSync( + ["bun", "run", "packages/hunk/src/main.tsx", "session", "reload", "--help"], + { + cwd: process.cwd(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ); const stdout = Buffer.from(proc.stdout).toString("utf8"); const stderr = Buffer.from(proc.stderr).toString("utf8"); @@ -132,8 +135,8 @@ describe("CLI entrypoint contracts", () => { }); test("prints the package version for --version without terminal takeover sequences", () => { - const expectedVersion = require("../../package.json").version; - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "--version"], { + const expectedVersion = require("../../packages/hunk/package.json").version; + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "--version"], { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", @@ -150,7 +153,7 @@ describe("CLI entrypoint contracts", () => { }); test("prints the bundled skill path for hunk skill path without terminal takeover sequences", () => { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "skill", "path"], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "skill", "path"], { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", @@ -169,7 +172,7 @@ describe("CLI entrypoint contracts", () => { }); test("bin wrapper prints the bundled skill path for hunk skill path", () => { - const proc = Bun.spawnSync(["node", "bin/hunk.cjs", "skill", "path"], { + const proc = Bun.spawnSync(["node", "packages/hunk/bin/hunk.cjs", "skill", "path"], { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", @@ -188,7 +191,7 @@ describe("CLI entrypoint contracts", () => { }); test("package manifest exposes hunkdiff as an npm exec alias", () => { - const packageJson = require("../../package.json"); + const packageJson = require("../../packages/hunk/package.json"); expect(packageJson.bin).toEqual({ hunk: "./bin/hunk.cjs", hunkdiff: "./bin/hunk.cjs", @@ -202,7 +205,7 @@ describe("CLI entrypoint contracts", () => { try { mkdirSync(tempBinDir, { recursive: true }); - copyFileSync(join(process.cwd(), "bin", "hunk.cjs"), tempWrapperPath); + copyFileSync(join(process.cwd(), "packages", "hunk", "bin", "hunk.cjs"), tempWrapperPath); const proc = Bun.spawnSync(["node", tempWrapperPath, "skill", "path"], { cwd: tempDir, @@ -225,7 +228,7 @@ describe("CLI entrypoint contracts", () => { }); test("general pager mode falls back to plain text for non-diff stdin", () => { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "pager"], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "pager"], { cwd: process.cwd(), stdin: Buffer.from("* main\n feature/demo\n"), stdout: "pipe", @@ -246,7 +249,7 @@ describe("CLI entrypoint contracts", () => { test("general pager mode keeps Git colors in non-diff stdin for captured pager hosts", () => { const coloredLog = "\u001b[33m*\u001b[m \u001b[32mabc1234\u001b[m feat: thing\n"; - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "pager"], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "pager"], { cwd: process.cwd(), stdin: Buffer.from(coloredLog), stdout: "pipe", @@ -263,7 +266,7 @@ describe("CLI entrypoint contracts", () => { test("general pager mode strips colors from non-diff stdin outside captured pager hosts", () => { const coloredLog = "\u001b[33m*\u001b[m \u001b[32mabc1234\u001b[m feat: thing\n"; - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "pager"], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "pager"], { cwd: process.cwd(), stdin: Buffer.from(coloredLog), stdout: "pipe", @@ -280,7 +283,7 @@ describe("CLI entrypoint contracts", () => { test("general pager mode passes diff stdin through when stdout is not a terminal", () => { const patchText = "diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-old\n+new\n"; - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "pager"], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "pager"], { cwd: process.cwd(), stdin: Buffer.from(patchText), stdout: "pipe", @@ -299,7 +302,7 @@ describe("CLI entrypoint contracts", () => { test("prints a friendly git-repo error without a Bun stack trace", () => { const nonRepoDir = mkdtempSync(join(tmpdir(), "hunk-nonrepo-")); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { const proc = Bun.spawnSync(["bun", "run", sourceEntrypoint, "diff"], { @@ -328,7 +331,7 @@ describe("CLI entrypoint contracts", () => { test("runs an explicit generic extension CLI command with raw args and stdin", () => { const root = mkdtempSync(join(tmpdir(), "hunk-extension-cli-")); const extensionPath = join(root, "tools.ts"); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { writeFileSync( @@ -370,7 +373,7 @@ describe("CLI entrypoint contracts", () => { test("runs the self-contained GitHub PR extension help through generic CLI discovery", () => { const root = mkdtempSync(join(tmpdir(), "hunk-github-pr-help-")); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); const extensionPath = join(process.cwd(), "examples/extensions/github-pr"); try { @@ -395,7 +398,7 @@ describe("CLI entrypoint contracts", () => { test("discovers the installed-shape GitHub extension for literal hunk gh", () => { const root = mkdtempSync(join(tmpdir(), "hunk-github-pr-global-")); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); const extensionPath = join(process.cwd(), "examples/extensions/github-pr"); const installedPath = join(root, "config", "hunk", "extensions", "github-pr"); @@ -421,7 +424,7 @@ describe("CLI entrypoint contracts", () => { test("warns before repo config steers an extension CLI provider", () => { const root = mkdtempSync(join(tmpdir(), "hunk-extension-cli-config-")); const extensionPath = join(root, "tools.ts"); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { mkdirSync(join(root, ".git"), { recursive: true }); @@ -460,7 +463,7 @@ describe("CLI entrypoint contracts", () => { test("allows stderr progress before delegating to a built-in command", () => { const root = mkdtempSync(join(tmpdir(), "hunk-extension-delegate-")); const extensionPath = join(root, "delegate.ts"); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { writeFileSync( @@ -496,7 +499,7 @@ describe("CLI entrypoint contracts", () => { test("cancels a pending stdin read when an extension command exits", async () => { const root = mkdtempSync(join(tmpdir(), "hunk-extension-pending-stdin-")); const extensionPath = join(root, "stdin.ts"); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { writeFileSync( @@ -534,7 +537,7 @@ describe("CLI entrypoint contracts", () => { test("rejects delegation when both the extension and built-in need stdin", () => { const root = mkdtempSync(join(tmpdir(), "hunk-extension-stdin-delegate-")); const extensionPath = join(root, "stdin.ts"); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { writeFileSync( @@ -573,7 +576,7 @@ describe("CLI entrypoint contracts", () => { const root = mkdtempSync(join(tmpdir(), "hunk-extension-review-delegate-")); const extensionPath = join(root, "delegate.ts"); const countPath = join(root, "factory-count"); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { writeFileSync( @@ -614,7 +617,7 @@ export default function (hunk) { const root = mkdtempSync(join(tmpdir(), "hunk-extension-invalid-command-")); const extensionPath = join(root, "provider.ts"); const markerPath = join(root, "imported"); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { writeFileSync( @@ -644,7 +647,7 @@ export default function (hunk) { const root = mkdtempSync(join(tmpdir(), "hunk-extension-missing-path-")); const extensionPath = join(root, "provider.ts"); const markerPath = join(root, "imported"); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { writeFileSync( @@ -685,7 +688,7 @@ export default function (hunk) { const root = mkdtempSync(join(tmpdir(), "hunk-extension-disabled-")); const extensionPath = join(root, "disabled.ts"); const markerPath = join(root, "imported"); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { writeFileSync( @@ -724,7 +727,7 @@ export default function (hunk) { test("prints a friendly invalid-ref error without a Bun stack trace", () => { const repoDir = mkdtempSync(join(tmpdir(), "hunk-show-cli-")); - const sourceEntrypoint = join(process.cwd(), "src/main.tsx"); + const sourceEntrypoint = join(process.cwd(), "packages/hunk/src/main.tsx"); try { git(repoDir, "init"); diff --git a/test/cli/fixtures/compiled-highlight-worker-control.ts b/test/cli/fixtures/compiled-highlight-worker-control.ts index 1525504cd..a1ac55219 100644 --- a/test/cli/fixtures/compiled-highlight-worker-control.ts +++ b/test/cli/fixtures/compiled-highlight-worker-control.ts @@ -1,7 +1,7 @@ import { createHighlightWorker, supportsHighlightWorkerOffload, -} from "../../../src/highlightWorkerClient"; +} from "../../../packages/hunk/src/highlightWorkerClient"; if (!supportsHighlightWorkerOffload()) { process.stdout.write("compiled highlight worker disabled\n"); diff --git a/test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts b/test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts index 78f89a7f3..d77ad5607 100644 --- a/test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts +++ b/test/cli/install-vm/prepare-daemon-upgrade-fixtures.ts @@ -15,6 +15,7 @@ import { import path from "node:path"; import { delimiter } from "node:path"; import { createHash } from "node:crypto"; +import { resolveHunkProtocolPath } from "./repo-layout"; export const DAEMON_UPGRADE_VERSION_A = "899.0.0"; export const DAEMON_UPGRADE_VERSION_B = "899.0.1"; @@ -57,7 +58,19 @@ function checkoutFiles(repoRoot: string) { `Unable to enumerate daemon fixture checkout: ${new TextDecoder().decode(listed.stderr).trim()}`, ); } - return new TextDecoder().decode(listed.stdout).split("\0").filter(Boolean); + return new TextDecoder() + .decode(listed.stdout) + .split("\0") + .filter(Boolean) + .filter((relativePath) => { + try { + lstatSync(path.join(repoRoot, relativePath)); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }); } /** Frame one build-input value so paths and contents cannot concatenate ambiguously. */ @@ -79,14 +92,26 @@ export function computeDaemonUpgradeBuildInputIdentity( } = {}, ) { const realRepoRoot = realpathSync(repoRoot); - const dependenciesRoot = options.dependenciesRoot ?? path.join(repoRoot, "node_modules"); + const dependencyRoots = options.dependenciesRoot + ? [{ path: options.dependenciesRoot, label: "node_modules" }] + : [ + { path: path.join(repoRoot, "node_modules"), label: "node_modules" }, + ...readdirSync(path.join(repoRoot, "packages"), { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => ({ + path: path.join(repoRoot, "packages", entry.name, "node_modules"), + label: path.posix.join("packages", entry.name, "node_modules"), + })) + .filter((entry) => existsSync(entry.path)), + ]; const bunExecutable = options.bunExecutable ?? process.execPath; const bunVersion = options.bunVersion ?? Bun.version; - if (!existsSync(dependenciesRoot) || !existsSync(bunExecutable)) { + if (dependencyRoots.some((entry) => !existsSync(entry.path)) || !existsSync(bunExecutable)) { throw new Error("Daemon upgrade build-input attestation requires dependencies and Bun."); } const hash = createHash("sha256"); - updateFramed(hash, "hunk-daemon-upgrade-build-input-v1"); + updateFramed(hash, "hunk-daemon-upgrade-build-input-v2"); updateFramed(hash, bunVersion); updateFramed(hash, readFileSync(bunExecutable)); const walk = (directory: string, relativeDirectory: string) => { @@ -117,7 +142,10 @@ export function computeDaemonUpgradeBuildInputIdentity( } } }; - walk(dependenciesRoot, "node_modules"); + for (const dependencyRoot of dependencyRoots) { + updateFramed(hash, `root:${dependencyRoot.label}`); + walk(dependencyRoot.path, dependencyRoot.label); + } return hash.digest("hex"); } @@ -271,8 +299,11 @@ export function rewriteDaemonUpgradeVariantSources( packageVersion: string, daemonRevision: number, ) { - const packagePath = daemonUpgradeRewriteFile(destination, "package.json"); - const protocolPath = daemonUpgradeRewriteFile(destination, "src/session/protocol.ts"); + const packagePath = daemonUpgradeRewriteFile(destination, "packages/hunk/package.json"); + const protocolPath = daemonUpgradeRewriteFile( + destination, + "packages/hunk/src/session/protocol.ts", + ); const packageManifest = JSON.parse(readFileSync(packagePath, "utf8")) as Record; packageManifest.version = packageVersion; writeFileSync(packagePath, `${JSON.stringify(packageManifest, null, 2)}\n`); @@ -287,6 +318,15 @@ export function rewriteDaemonUpgradeVariantSources( function copyCheckout(repoRoot: string, destination: string) { copyDaemonUpgradeCheckoutFiles(repoRoot, destination); snapshotDaemonUpgradeDependencies(repoRoot, path.join(destination, "node_modules")); + for (const entry of readdirSync(path.join(repoRoot, "packages"), { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const workspaceRoot = path.join(repoRoot, "packages", entry.name); + if (!existsSync(path.join(workspaceRoot, "node_modules"))) continue; + snapshotDaemonUpgradeDependencies( + workspaceRoot, + path.join(destination, "packages", entry.name, "node_modules"), + ); + } } /** Build one fully functional fixture binary with only version/revision bytes changed. */ @@ -340,7 +380,7 @@ export async function prepareDaemonUpgradeBinaries(repoRoot: string, buildRoot: rmSync(buildRoot, { recursive: true, force: true }); mkdirSync(buildRoot, { recursive: true }); const daemonUpgradeBuildInputIdentity = computeDaemonUpgradeBuildInputIdentity(repoRoot); - const protocolSource = readFileSync(path.join(repoRoot, "src", "session", "protocol.ts"), "utf8"); + const protocolSource = readFileSync(resolveHunkProtocolPath(repoRoot), "utf8"); const revisionB = readDaemonRevision(protocolSource); const revisionA = revisionB - 1; const binaryA = await buildVariant( diff --git a/test/cli/install-vm/prepare-fixtures.test.ts b/test/cli/install-vm/prepare-fixtures.test.ts index d0d77f84a..08ab606ec 100644 --- a/test/cli/install-vm/prepare-fixtures.test.ts +++ b/test/cli/install-vm/prepare-fixtures.test.ts @@ -38,6 +38,12 @@ import { rewriteDaemonUpgradeVariantSources, snapshotDaemonUpgradeDependencies, } from "./prepare-daemon-upgrade-fixtures"; +import { + resolveHunkBinWrapperPath, + resolveHunkPackagePath, + resolveHunkProtocolPath, + resolveHunkSkillPath, +} from "./repo-layout"; /** Initialize the minimal Git checkout required by source-identity discovery. */ function initializeTestGitRepo(repo: string) { @@ -68,11 +74,11 @@ function writeTestFixtures(repo: string, fixtures: string) { const httpRoot = path.join(fixtures, "http"); mkdirSync(packageRoot, { recursive: true }); mkdirSync(httpRoot, { recursive: true }); - mkdirSync(path.join(repo, "src", "session"), { recursive: true }); + mkdirSync(path.join(repo, "packages", "hunk", "src", "session"), { recursive: true }); mkdirSync(path.join(repo, "node_modules"), { recursive: true }); writeFileSync(path.join(repo, "node_modules", "fixture-dependency"), "dependency\n"); writeFileSync( - path.join(repo, "src", "session", "protocol.ts"), + path.join(repo, "packages", "hunk", "src", "session", "protocol.ts"), "export const HUNK_SESSION_DAEMON_VERSION = 11;\n", ); const versions = [ @@ -145,6 +151,23 @@ function writeTestFixtures(repo: string, fixtures: string) { } describe("install VM package fixtures", () => { + test("resolves real release inputs from the package-first checkout topology", () => { + const repoRoot = path.resolve(import.meta.dir, "../../.."); + + expect(readFileSync(resolveHunkPackagePath(repoRoot, "package.json"), "utf8")).toContain( + '"name": "hunkdiff"', + ); + expect( + readDaemonRevision(readFileSync(resolveHunkProtocolPath(repoRoot), "utf8")), + ).toBeGreaterThan(1); + expect(readFileSync(resolveHunkBinWrapperPath(repoRoot), "utf8")).toContain("node"); + for (const skill of ["hunk-review", "hunk-extensions"]) { + expect( + readFileSync(path.join(resolveHunkSkillPath(repoRoot, skill), "SKILL.md"), "utf8"), + ).toContain("---"); + } + }); + test("derives trusted daemon binary digests from the actual platform tarballs", async () => { if (process.platform !== "linux") return; const root = mkdtempSync(path.join(tmpdir(), "hunk-daemon-tarball-digests-")); @@ -259,16 +282,22 @@ describe("install VM package fixtures", () => { const externalPackage = path.join(root, "external-package.json"); const externalSource = path.join(root, "external-src"); try { - mkdirSync(path.join(repo, "src", "session"), { recursive: true }); + mkdirSync(path.join(repo, "packages", "hunk", "src", "session"), { recursive: true }); initializeTestGitRepo(repo); writeFileSync(path.join(repo, "package.json"), '{"version":"1.0.0"}\n'); writeFileSync( - path.join(repo, "src", "session", "protocol.ts"), + path.join(repo, "packages", "hunk", "src", "session", "protocol.ts"), "export const HUNK_SESSION_DAEMON_VERSION = 11;\n", ); writeFileSync(path.join(repo, "AGENTS.md"), "instructions\n"); symlinkSync("AGENTS.md", path.join(repo, "CLAUDE.md"), "file"); - addTestGitFiles(repo, "package.json", "src/session/protocol.ts", "AGENTS.md", "CLAUDE.md"); + addTestGitFiles( + repo, + "package.json", + "packages/hunk/src/session/protocol.ts", + "AGENTS.md", + "CLAUDE.md", + ); writeFileSync(externalPackage, '{"version":"outside"}\n'); rmSync(path.join(repo, "package.json")); @@ -285,16 +314,17 @@ describe("install VM package fixtures", () => { path.join(externalSource, "session", "protocol.ts"), "export const HUNK_SESSION_DAEMON_VERSION = 99;\n", ); - rmSync(path.join(repo, "src"), { recursive: true }); - symlinkSync(path.relative(repo, externalSource), path.join(repo, "src"), "dir"); + const packageRoot = path.join(repo, "packages", "hunk"); + rmSync(path.join(packageRoot, "src"), { recursive: true }); + symlinkSync(path.relative(packageRoot, externalSource), path.join(packageRoot, "src"), "dir"); expect(() => copyDaemonUpgradeCheckoutFiles(repo, path.join(root, "parent-copy"))).toThrow( "entry escapes the checkout", ); - unlinkSync(path.join(repo, "src")); - mkdirSync(path.join(repo, "src", "session"), { recursive: true }); + unlinkSync(path.join(packageRoot, "src")); + mkdirSync(path.join(repo, "packages", "hunk", "src", "session"), { recursive: true }); writeFileSync( - path.join(repo, "src", "session", "protocol.ts"), + path.join(repo, "packages", "hunk", "src", "session", "protocol.ts"), "export const HUNK_SESSION_DAEMON_VERSION = 11;\n", ); const containedCopy = path.join(root, "contained-copy"); @@ -320,11 +350,15 @@ describe("install VM package fixtures", () => { const packageTarget = path.join(checkout, "package-target.json"); const externalSource = path.join(root, "external-src"); try { - mkdirSync(path.join(checkout, "src", "session"), { recursive: true }); + mkdirSync(path.join(checkout, "packages", "hunk", "src", "session"), { recursive: true }); writeFileSync(packageTarget, '{"version":"unchanged"}\n'); - symlinkSync("package-target.json", path.join(checkout, "package.json"), "file"); + symlinkSync( + "../../package-target.json", + path.join(checkout, "packages", "hunk", "package.json"), + "file", + ); writeFileSync( - path.join(checkout, "src", "session", "protocol.ts"), + path.join(checkout, "packages", "hunk", "src", "session", "protocol.ts"), "export const HUNK_SESSION_DAEMON_VERSION = 11;\n", ); expect(() => rewriteDaemonUpgradeVariantSources(checkout, "899.0.0", 10)).toThrow( @@ -332,17 +366,23 @@ describe("install VM package fixtures", () => { ); expect(readFileSync(packageTarget, "utf8")).toBe('{"version":"unchanged"}\n'); - rmSync(path.join(checkout, "package.json")); - writeFileSync(path.join(checkout, "package.json"), '{"version":"unchanged"}\n'); + rmSync(path.join(checkout, "packages", "hunk", "package.json")); + writeFileSync( + path.join(checkout, "packages", "hunk", "package.json"), + '{"version":"unchanged"}\n', + ); mkdirSync(path.join(externalSource, "session"), { recursive: true }); const externalProtocol = path.join(externalSource, "session", "protocol.ts"); writeFileSync(externalProtocol, "export const HUNK_SESSION_DAEMON_VERSION = 99;\n"); - rmSync(path.join(checkout, "src"), { recursive: true }); - symlinkSync(path.relative(checkout, externalSource), path.join(checkout, "src"), "dir"); + const packageRoot = path.join(checkout, "packages", "hunk"); + rmSync(path.join(packageRoot, "src"), { recursive: true }); + symlinkSync(path.relative(packageRoot, externalSource), path.join(packageRoot, "src"), "dir"); expect(() => rewriteDaemonUpgradeVariantSources(checkout, "899.0.0", 10)).toThrow( "rewrite path may not contain a symlink", ); - expect(readFileSync(path.join(checkout, "package.json"), "utf8")).toContain("unchanged"); + expect( + readFileSync(path.join(checkout, "packages", "hunk", "package.json"), "utf8"), + ).toContain("unchanged"); expect(readFileSync(externalProtocol, "utf8")).toContain("VERSION = 99"); } finally { rmSync(root, { recursive: true, force: true }); @@ -510,7 +550,12 @@ describe("install VM package fixtures", () => { mkdirSync(path.join(repo, "test", "cli", "install-vm"), { recursive: true, }); - writeFileSync(path.join(repo, "package.json"), '{"name":"fixture","version":"1.0.0"}\n'); + writeFileSync(path.join(repo, "package.json"), '{"name":"@hunk/workspace","private":true}\n'); + mkdirSync(path.join(repo, "packages", "hunk"), { recursive: true }); + writeFileSync( + path.join(repo, "packages", "hunk", "package.json"), + '{"name":"fixture","version":"1.0.0"}\n', + ); writeFileSync(path.join(repo, "test", "cli", "install-vm", "source.txt"), "source\n"); const manifest = writeTestFixtures(repo, fixtures); expect(verifyInstallVmFixtures(repo, fixtures).sourceIdentity).toBe(manifest.sourceIdentity); diff --git a/test/cli/install-vm/prepare-fixtures.ts b/test/cli/install-vm/prepare-fixtures.ts index 5f08d99f9..6eac6e200 100644 --- a/test/cli/install-vm/prepare-fixtures.ts +++ b/test/cli/install-vm/prepare-fixtures.ts @@ -32,6 +32,11 @@ import { prepareDaemonUpgradeBinaries, readDaemonRevision, } from "./prepare-daemon-upgrade-fixtures"; +import { + resolveHunkBinWrapperPath, + resolveHunkProtocolPath, + resolveHunkSkillPath, +} from "./repo-layout"; export const FIXTURE_VERSION_A = "900.0.0"; export const FIXTURE_VERSION_B = "900.0.1"; @@ -203,7 +208,7 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { } const rootVersion = ( - JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")) as { + JSON.parse(readFileSync(path.join(repoRoot, "packages", "hunk", "package.json"), "utf8")) as { version?: unknown; } ).version; @@ -221,7 +226,7 @@ export function verifyInstallVmFixtures(repoRoot: string, outputRoot: string) { ); } const expectedRevisionB = readDaemonRevision( - readFileSync(path.join(repoRoot, "src", "session", "protocol.ts"), "utf8"), + readFileSync(resolveHunkProtocolPath(repoRoot), "utf8"), ); const daemonUpgrade = manifest.daemonUpgrade; if ( @@ -443,7 +448,7 @@ function writeSyntheticBinary(binaryPath: string, version: string) { /** Copy bundled skills into a synthetic install fixture. */ function copyFixtureSkills(repoRoot: string, destination: string) { for (const skill of ["hunk-review", "hunk-extensions"]) { - cpSync(path.join(repoRoot, "skills", skill), path.join(destination, "skills", skill), { + cpSync(resolveHunkSkillPath(repoRoot, skill), path.join(destination, "skills", skill), { recursive: true, }); } @@ -466,7 +471,7 @@ async function stageSyntheticPackage( const metaDir = path.join(stageRoot, `hunkdiff-${version}`); mkdirSync(path.join(metaDir, "bin"), { recursive: true }); mkdirSync(path.join(metaDir, "dist", "npm"), { recursive: true }); - copyFileSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); + copyFileSync(resolveHunkBinWrapperPath(repoRoot), path.join(metaDir, "bin", "hunk.cjs")); chmodSync(path.join(metaDir, "bin", "hunk.cjs"), 0o755); copyFixtureSkills(repoRoot, metaDir); writeFileSync( @@ -512,7 +517,7 @@ async function stageDaemonUpgradePackage( const metaDir = path.join(stageRoot, `hunkdiff-daemon-${version}`); mkdirSync(path.join(metaDir, "bin"), { recursive: true }); mkdirSync(path.join(metaDir, "dist", "npm"), { recursive: true }); - copyFileSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); + copyFileSync(resolveHunkBinWrapperPath(repoRoot), path.join(metaDir, "bin", "hunk.cjs")); chmodSync(path.join(metaDir, "bin", "hunk.cjs"), 0o755); copyFixtureSkills(repoRoot, metaDir); writeFileSync( diff --git a/test/cli/install-vm/repo-layout.ts b/test/cli/install-vm/repo-layout.ts new file mode 100644 index 000000000..ee5655aa5 --- /dev/null +++ b/test/cli/install-vm/repo-layout.ts @@ -0,0 +1,21 @@ +import path from "node:path"; + +/** Resolve a path owned by the publishable Hunk package in the workspace checkout. */ +export function resolveHunkPackagePath(repoRoot: string, ...segments: string[]) { + return path.join(repoRoot, "packages", "hunk", ...segments); +} + +/** Resolve the daemon protocol source used to build upgrade fixtures. */ +export function resolveHunkProtocolPath(repoRoot: string) { + return resolveHunkPackagePath(repoRoot, "src", "session", "protocol.ts"); +} + +/** Resolve the shipped npm wrapper used in synthetic package fixtures. */ +export function resolveHunkBinWrapperPath(repoRoot: string) { + return resolveHunkPackagePath(repoRoot, "bin", "hunk.cjs"); +} + +/** Resolve one shipped skill directory in the publishable package. */ +export function resolveHunkSkillPath(repoRoot: string, skill: string) { + return resolveHunkPackagePath(repoRoot, "skills", skill); +} diff --git a/test/cli/install-vm/validate-release-result.ts b/test/cli/install-vm/validate-release-result.ts index 90808e00f..e7b8e670e 100644 --- a/test/cli/install-vm/validate-release-result.ts +++ b/test/cli/install-vm/validate-release-result.ts @@ -15,6 +15,7 @@ import { verifyInstallVmFixtures, } from "./prepare-fixtures"; import { validateInstallVmReleaseResult } from "./results"; +import { resolveHunkProtocolPath } from "./repo-layout"; const repoRoot = path.resolve(import.meta.dir, "../../.."); const resultPath = process.argv[2]; @@ -59,9 +60,7 @@ if (scenarios.some((scenario) => scenario.id === "authenticated-daemon-upgrade") ); } daemonUpgradeBuildInputIdentity = computeDaemonUpgradeBuildInputIdentity(repoRoot); - daemonRevision = readDaemonRevision( - readFileSync(path.join(repoRoot, "src", "session", "protocol.ts"), "utf8"), - ); + daemonRevision = readDaemonRevision(readFileSync(resolveHunkProtocolPath(repoRoot), "utf8")); } const result = validateInstallVmReleaseResult( JSON.parse(readFileSync(resolvedResultPath, "utf8")), diff --git a/test/cli/non-interactive-stdin.test.ts b/test/cli/non-interactive-stdin.test.ts index aeb77be47..d96670e1e 100644 --- a/test/cli/non-interactive-stdin.test.ts +++ b/test/cli/non-interactive-stdin.test.ts @@ -40,19 +40,22 @@ describe("non-interactive stdin contracts", () => { writeFileSync(before, "export const value = 1;\n"); writeFileSync(after, "export const value = 2;\n"); - const proc = Bun.spawn(["bun", "run", "src/main.tsx", "--", "diff", "--files", before, after], { - cwd: process.cwd(), - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - TERM: "xterm-256color", - HUNK_MCP_DISABLE: "1", - HUNK_DISABLE_UPDATE_NOTICE: "1", - XDG_CONFIG_HOME: dir, + const proc = Bun.spawn( + ["bun", "run", "packages/hunk/src/main.tsx", "--", "diff", "--files", before, after], + { + cwd: process.cwd(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + TERM: "xterm-256color", + HUNK_MCP_DISABLE: "1", + HUNK_DISABLE_UPDATE_NOTICE: "1", + XDG_CONFIG_HOME: dir, + }, }, - }); + ); try { const bytes = await readUntilRendered(proc.stdout, MINIMUM_RENDERED_BYTES, 15_000); diff --git a/test/cli/pager-pipe-output.test.ts b/test/cli/pager-pipe-output.test.ts index 439fcb93c..8d79d61f5 100644 --- a/test/cli/pager-pipe-output.test.ts +++ b/test/cli/pager-pipe-output.test.ts @@ -38,7 +38,7 @@ describe("pager output through a pipe", () => { const document = createGitLogDocument(6_000); expect(document.length).toBeGreaterThan(PIPE_BUFFER_BYTES * 3); - const proc = Bun.spawn(["bun", "run", "src/main.tsx", "--", "pager"], { + const proc = Bun.spawn(["bun", "run", "packages/hunk/src/main.tsx", "--", "pager"], { cwd: process.cwd(), stdin: new TextEncoder().encode(document), stdout: "pipe", diff --git a/test/cli/startup-graph.test.ts b/test/cli/startup-graph.test.ts index ad6b19467..fb4ff6e00 100644 --- a/test/cli/startup-graph.test.ts +++ b/test/cli/startup-graph.test.ts @@ -14,7 +14,7 @@ import { dirname, join, relative, resolve } from "node:path"; */ const REPO_ROOT = resolve(import.meta.dir, "../.."); -const ENTRYPOINT = join(REPO_ROOT, "src/main.tsx"); +const ENTRYPOINT = join(REPO_ROOT, "packages/hunk/src/main.tsx"); /** * Package prefixes the entrypoint must not load before a command selects an interactive plan. @@ -103,7 +103,7 @@ describe("CLI startup graph", () => { // The entrypoint resolves once the app is mounted, so disposing from there would terminate the // worker before the first large diff requested it. const interactiveAppSource = readFileSync( - join(REPO_ROOT, "src/ui/runInteractiveApp.tsx"), + join(REPO_ROOT, "packages/hunk/src/ui/runInteractiveApp.tsx"), "utf8", ); diff --git a/test/cli/update.test.ts b/test/cli/update.test.ts index 9c372dce2..6efdb2ef9 100644 --- a/test/cli/update.test.ts +++ b/test/cli/update.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { InstallSource } from "../../src/core/install/installSource"; +import type { InstallSource } from "../../packages/hunk/src/core/install/installSource"; /** * Runs `hunk update` as a black box with a forced install source. @@ -9,7 +9,7 @@ import type { InstallSource } from "../../src/core/install/installSource"; * paths reports and stops before any release lookup. */ function runUpdate(args: string[], installSource?: InstallSource) { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "update", ...args], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "update", ...args], { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", @@ -28,7 +28,7 @@ function runUpdate(args: string[], installSource?: InstallSource) { describe("hunk update CLI contract", () => { test("top-level help lists the update command", () => { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "--help"], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "--help"], { cwd: process.cwd(), stdin: "ignore", stdout: "pipe", diff --git a/test/helpers/app-bootstrap.ts b/test/helpers/app-bootstrap.ts index b79a3744f..429c89d7e 100644 --- a/test/helpers/app-bootstrap.ts +++ b/test/helpers/app-bootstrap.ts @@ -1,6 +1,9 @@ -import type { AppBootstrap } from "../../src/core/bootstrap"; -import type { DiffFile } from "../../src/core/changeset/model"; -import type { VcsDiffCommandInput, LayoutMode } from "../../src/core/run/commandInputs"; +import type { AppBootstrap } from "../../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../../packages/hunk/src/core/changeset/model"; +import type { + VcsDiffCommandInput, + LayoutMode, +} from "../../packages/hunk/src/core/run/commandInputs"; export function createTestVcsAppBootstrap({ agentSummary, diff --git a/test/helpers/diff-helpers.ts b/test/helpers/diff-helpers.ts index 93b626de8..9787eb4fd 100644 --- a/test/helpers/diff-helpers.ts +++ b/test/helpers/diff-helpers.ts @@ -1,7 +1,13 @@ import { parseDiffFromFile } from "@pierre/diffs"; -import type { FileSourceFetcher, FileSourceSide } from "../../src/core/changeset/fileSource"; -import type { DiffFile } from "../../src/core/changeset/model"; -import type { AgentAnnotation, AgentFileContext } from "../../src/extension-api/types"; +import type { + FileSourceFetcher, + FileSourceSide, +} from "../../packages/hunk/src/core/changeset/fileSource"; +import type { DiffFile } from "../../packages/hunk/src/core/changeset/model"; +import type { + AgentAnnotation, + AgentFileContext, +} from "../../packages/hunk/src/extension-api/types"; function collectChangeStats(metadata: DiffFile["metadata"]) { let additions = 0; diff --git a/test/helpers/review-session-harness.ts b/test/helpers/review-session-harness.ts index f20a2f6ec..f98faa302 100644 --- a/test/helpers/review-session-harness.ts +++ b/test/helpers/review-session-harness.ts @@ -9,19 +9,22 @@ * asynchronously exactly as the websocket does. Nothing else is stubbed, so concurrency * and ordering are real rather than collapsed into synchronous calls. */ -import { ReviewProducer } from "../../src/app/review/producer"; -import { createReviewStore } from "../../src/core/review/store"; -import { createHunkSessionBridge } from "../../src/app/session/bridge"; +import { ReviewProducer } from "../../packages/hunk/src/app/review/producer"; +import { createReviewStore } from "../../packages/hunk/src/core/review/store"; +import { createHunkSessionBridge } from "../../packages/hunk/src/app/session/bridge"; import { createInitialSessionSnapshot, createSessionRegistration, updateSessionRegistration, -} from "../../src/app/session/registration"; -import { HunkSessionBrokerState } from "../../src/session/broker/state"; -import { ReviewResourceCache } from "../../src/session/broker/reviewResourceCache"; -import type { AppBootstrap } from "../../src/core/bootstrap"; -import type { DiffFile } from "../../src/core/changeset/model"; -import type { HunkSessionRegistration, HunkSessionServerMessage } from "../../src/session/types"; +} from "../../packages/hunk/src/app/session/registration"; +import { HunkSessionBrokerState } from "../../packages/hunk/src/session/broker/state"; +import { ReviewResourceCache } from "../../packages/hunk/src/session/broker/reviewResourceCache"; +import type { AppBootstrap } from "../../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../../packages/hunk/src/core/changeset/model"; +import type { + HunkSessionRegistration, + HunkSessionServerMessage, +} from "../../packages/hunk/src/session/types"; import { createTestDiffFile } from "./diff-helpers"; export interface ReviewSessionHarnessOptions { diff --git a/test/helpers/review-store-helpers.ts b/test/helpers/review-store-helpers.ts index ce3724e03..643d706fc 100644 --- a/test/helpers/review-store-helpers.ts +++ b/test/helpers/review-store-helpers.ts @@ -4,13 +4,17 @@ * Builders stay minimal on purpose: a test states only the facts it cares about, so a * later phase widening the document shape does not rewrite every expectation. */ -import { reviewLineAnchor } from "../../src/core/review/anchors"; +import { reviewLineAnchor } from "../../packages/hunk/src/core/review/anchors"; import { createInitialReviewState, type ReviewState, type ReviewStoredNote, -} from "../../src/core/review/state"; -import type { ReviewDocumentV1, ReviewFileV1, ReviewHunkV1 } from "../../src/core/review/types"; +} from "../../packages/hunk/src/core/review/state"; +import type { + ReviewDocumentV1, + ReviewFileV1, + ReviewHunkV1, +} from "../../packages/hunk/src/core/review/types"; export interface TestReviewFileInput { key: string; diff --git a/test/helpers/session-daemon-fixtures.ts b/test/helpers/session-daemon-fixtures.ts index ccfe79bce..8fa4e97ff 100644 --- a/test/helpers/session-daemon-fixtures.ts +++ b/test/helpers/session-daemon-fixtures.ts @@ -9,7 +9,7 @@ import type { SessionReview, SessionReviewFile, SessionReviewHunk, -} from "../../src/session/types"; +} from "../../packages/hunk/src/session/types"; export function createTestSessionFileSummary( overrides: Partial = {}, diff --git a/test/helpers/theme-helpers.ts b/test/helpers/theme-helpers.ts index c3d44104e..5112c97e3 100644 --- a/test/helpers/theme-helpers.ts +++ b/test/helpers/theme-helpers.ts @@ -1,4 +1,7 @@ -import type { CustomThemeConfig, NamedCustomThemeConfig } from "../../src/extension-api/types"; +import type { + CustomThemeConfig, + NamedCustomThemeConfig, +} from "../../packages/hunk/src/extension-api/types"; /** * Name one custom palette so it can be passed to the theme APIs, which take the diff --git a/test/helpers/watchTest.ts b/test/helpers/watchTest.ts index 0b4f910e0..2dc8de395 100644 --- a/test/helpers/watchTest.ts +++ b/test/helpers/watchTest.ts @@ -1,8 +1,8 @@ import type { WatchControllerClock, WatchEventSourceCallbacks, -} from "../../src/core/watch/controller"; -import type { WatchedInputRuntime } from "../../src/ui/hooks/useWatchedInput"; +} from "../../packages/hunk/src/core/watch/controller"; +import type { WatchedInputRuntime } from "../../packages/hunk/src/ui/hooks/useWatchedInput"; interface ScheduledWatchTestTimer { callback: () => void; diff --git a/test/pty/chrome.test.ts b/test/pty/chrome.test.ts index 0eea9d935..d0b48a861 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { availableThemes } from "../../src/ui/themes"; +import { availableThemes } from "../../packages/hunk/src/ui/themes"; import { createPtyHarness, lineIndexOf, rowCellBackgrounds, sleep } from "./harness"; const harness = createPtyHarness(); diff --git a/test/pty/harness.ts b/test/pty/harness.ts index 133466829..36cca8d6d 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -7,7 +7,7 @@ import type { Key, Session } from "tuistory"; const integrationDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(integrationDir, "../.."); -const sourceEntrypoint = join(repoRoot, "src/main.tsx"); +const sourceEntrypoint = join(repoRoot, "packages/hunk/src/main.tsx"); // Hunk renders atomically and tests wait on concrete UI predicates, so the safer 200ms default is unnecessary. const tuistoryIdleDelayMs = 60; diff --git a/test/pty/moved-lines.test.ts b/test/pty/moved-lines.test.ts index 3a78c2b73..ae25b3a0b 100644 --- a/test/pty/moved-lines.test.ts +++ b/test/pty/moved-lines.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { DEFAULT_DARK_THEME_ID, resolveTheme } from "../../src/ui/themes"; +import { DEFAULT_DARK_THEME_ID, resolveTheme } from "../../packages/hunk/src/ui/themes"; import { createPtyHarness } from "./harness"; const harness = createPtyHarness(); diff --git a/test/pty/session-attention-integration.test.ts b/test/pty/session-attention-integration.test.ts index 31e000ada..f90f02159 100644 --- a/test/pty/session-attention-integration.test.ts +++ b/test/pty/session-attention-integration.test.ts @@ -81,7 +81,7 @@ async function reserveLoopbackPort() { /** Run one `hunk session ...` CLI invocation against the test daemon port. */ function runSessionCli(args: string[], port: number, configHome: string) { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "session", ...args], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "session", ...args], { cwd: repoRoot, stdin: "ignore", stdout: "pipe", diff --git a/test/review-conformance/conformance.test.ts b/test/review-conformance/conformance.test.ts index 5485e0f15..b04e9f3c2 100644 --- a/test/review-conformance/conformance.test.ts +++ b/test/review-conformance/conformance.test.ts @@ -1,13 +1,16 @@ import { describe, expect, test } from "bun:test"; -import { ReviewProducer } from "../../src/app/review/producer"; +import { ReviewProducer } from "../../packages/hunk/src/app/review/producer"; import { classifyReviewPublication, type ReviewPublicationAddress, -} from "../../src/core/review/generationOrder"; -import { isBlankReviewNoteBody, planReviewIntent } from "../../src/core/review/intents"; -import { reviewNoteWithinSizeLimit } from "../../src/core/review/noteSize"; -import { createInitialReviewState } from "../../src/core/review/state"; -import { createReviewStore } from "../../src/core/review/store"; +} from "../../packages/hunk/src/core/review/generationOrder"; +import { + isBlankReviewNoteBody, + planReviewIntent, +} from "../../packages/hunk/src/core/review/intents"; +import { reviewNoteWithinSizeLimit } from "../../packages/hunk/src/core/review/noteSize"; +import { createInitialReviewState } from "../../packages/hunk/src/core/review/state"; +import { createReviewStore } from "../../packages/hunk/src/core/review/store"; import { createTestDiffFile } from "../helpers/diff-helpers"; import { createTestReviewDocument } from "../helpers/review-store-helpers"; import { diff --git a/test/review-conformance/consumers/brokerMirror.ts b/test/review-conformance/consumers/brokerMirror.ts index 09b51e707..ba3d57c60 100644 --- a/test/review-conformance/consumers/brokerMirror.ts +++ b/test/review-conformance/consumers/brokerMirror.ts @@ -10,10 +10,13 @@ * Driving the shared fixtures through the mirror's own update path is what proves it has * no comparison rules of its own (`docs/browser-review-seam-audit.md`, C1). */ -import type { ReviewPublicationAddress } from "../../../src/core/review/generationOrder"; -import { ReviewMirror } from "../../../src/session/broker/reviewMirror"; -import { REVIEW_PATCH_CONTENT_TYPE, reviewResourceId } from "../../../src/core/review/resources"; -import type { HunkReviewResourceCatalogV1 } from "../../../src/session/reviewProtocol"; +import type { ReviewPublicationAddress } from "../../../packages/hunk/src/core/review/generationOrder"; +import { ReviewMirror } from "../../../packages/hunk/src/session/broker/reviewMirror"; +import { + REVIEW_PATCH_CONTENT_TYPE, + reviewResourceId, +} from "../../../packages/hunk/src/core/review/resources"; +import type { HunkReviewResourceCatalogV1 } from "../../../packages/hunk/src/session/reviewProtocol"; import type { ReviewOrderingConsumer } from "../types"; const FILE_KEY = "file:0123456789abcdef"; diff --git a/test/review-conformance/consumers/browserReviewSurface.ts b/test/review-conformance/consumers/browserReviewSurface.ts index 2c5ac944d..0a6ed85dd 100644 --- a/test/review-conformance/consumers/browserReviewSurface.ts +++ b/test/review-conformance/consumers/browserReviewSurface.ts @@ -10,21 +10,21 @@ * on a client's screen (`docs/browser-review-seam-audit.md`, C4). */ import { SESSION_BROKER_REGISTRATION_VERSION } from "@hunk/session-broker-core"; -import { reviewProcessCapability } from "../../../src/app/review/capability"; -import { nodeReviewDigest } from "../../../src/core/reviewDigest"; -import { BrowserReviewServer } from "../../../src/session/broker/browserReviewServer"; -import { HunkSessionBrokerState } from "../../../src/session/broker/state"; +import { reviewProcessCapability } from "../../../packages/hunk/src/app/review/capability"; +import { nodeReviewDigest } from "../../../packages/hunk/src/core/reviewDigest"; +import { BrowserReviewServer } from "../../../packages/hunk/src/session/broker/browserReviewServer"; +import { HunkSessionBrokerState } from "../../../packages/hunk/src/session/broker/state"; import { parseReviewEventBegin, parseReviewEventChunk, parseReviewEventEnd, parseReviewEventFrame, ReviewEventAssembler, -} from "../../../src/session/reviewEventProtocol"; +} from "../../../packages/hunk/src/session/reviewEventProtocol"; import { HUNK_REVIEW_CAPABILITY_HEADER, reviewHttpPath, -} from "../../../src/session/reviewHttpProtocol"; +} from "../../../packages/hunk/src/session/reviewHttpProtocol"; import { EVENT_FIXTURE_SESSION_ID } from "../eventFixtures"; import { collapseChunkRun, resolveFixtureChunkBytes } from "../eventFraming"; import type { ReviewEventConsumer, ReviewEventFixture } from "../types"; diff --git a/test/review-conformance/consumers/coreModel.ts b/test/review-conformance/consumers/coreModel.ts index 600ccfe02..e0f317833 100644 --- a/test/review-conformance/consumers/coreModel.ts +++ b/test/review-conformance/consumers/coreModel.ts @@ -6,7 +6,10 @@ * against the primitives first, so a renderer failure means the renderer diverged, not * that the fixture drifted. */ -import { projectReviewDocument, reviewEmptyDiffReason } from "../../../src/core/review/document"; +import { + projectReviewDocument, + reviewEmptyDiffReason, +} from "../../../packages/hunk/src/core/review/document"; import { reviewExpansionSide, reviewGapAddress, @@ -14,13 +17,13 @@ import { reviewGapSourceForFile, reviewLeadingGap, reviewTrailingGap, -} from "../../../src/core/review/expansion"; +} from "../../../packages/hunk/src/core/review/expansion"; import { normalizedReviewSourceLines, reviewDefaultHunkLineTarget, reviewHunkRanges, -} from "../../../src/core/review/geometry"; -import type { ReviewFileV1 } from "../../../src/core/review/types"; +} from "../../../packages/hunk/src/core/review/geometry"; +import type { ReviewFileV1 } from "../../../packages/hunk/src/core/review/types"; import type { ConformanceExpandedRow, ConformanceGap, diff --git a/test/review-conformance/consumers/coreOrdering.ts b/test/review-conformance/consumers/coreOrdering.ts index 7212e24e1..951b2e195 100644 --- a/test/review-conformance/consumers/coreOrdering.ts +++ b/test/review-conformance/consumers/coreOrdering.ts @@ -5,7 +5,7 @@ * indirectly: whatever a mirror, a client, or a server does with a publication, this is * what the rule says. */ -import { classifyReviewPublication } from "../../../src/core/review/generationOrder"; +import { classifyReviewPublication } from "../../../packages/hunk/src/core/review/generationOrder"; import type { ReviewOrderingConsumer } from "../types"; export const coreOrderingConsumer: ReviewOrderingConsumer = { diff --git a/test/review-conformance/consumers/extensionReviewSnapshot.ts b/test/review-conformance/consumers/extensionReviewSnapshot.ts index 4e9685572..d1530913d 100644 --- a/test/review-conformance/consumers/extensionReviewSnapshot.ts +++ b/test/review-conformance/consumers/extensionReviewSnapshot.ts @@ -1,4 +1,4 @@ -import { buildExtensionReviewSnapshot } from "../../../src/extensions/reviewSnapshot"; +import { buildExtensionReviewSnapshot } from "../../../packages/hunk/src/extensions/reviewSnapshot"; import type { ReviewSnapshotConsumer } from "../types"; /** Drive fixtures through the real public extension-snapshot projection. */ diff --git a/test/review-conformance/consumers/intentPlanner.ts b/test/review-conformance/consumers/intentPlanner.ts index 63ce925e9..3a5b8f81c 100644 --- a/test/review-conformance/consumers/intentPlanner.ts +++ b/test/review-conformance/consumers/intentPlanner.ts @@ -6,12 +6,18 @@ * than by calling the walk directly, so a planner that stopped consulting the shared * selectors would show up here. */ -import { projectReviewDocument } from "../../../src/core/review/document"; -import { planReviewIntent } from "../../../src/core/review/intents"; -import type { ReviewAnnotationIndex } from "../../../src/core/review/navigation"; -import { selectNormalizedSelection, selectRevealTarget } from "../../../src/core/review/selectors"; -import { createInitialReviewState, type ReviewState } from "../../../src/core/review/state"; -import type { ReviewDocumentV1 } from "../../../src/core/review/types"; +import { projectReviewDocument } from "../../../packages/hunk/src/core/review/document"; +import { planReviewIntent } from "../../../packages/hunk/src/core/review/intents"; +import type { ReviewAnnotationIndex } from "../../../packages/hunk/src/core/review/navigation"; +import { + selectNormalizedSelection, + selectRevealTarget, +} from "../../../packages/hunk/src/core/review/selectors"; +import { + createInitialReviewState, + type ReviewState, +} from "../../../packages/hunk/src/core/review/state"; +import type { ReviewDocumentV1 } from "../../../packages/hunk/src/core/review/types"; import type { ConformanceSelection, ConformanceSelectionInput, diff --git a/test/review-conformance/consumers/reviewEventProtocol.ts b/test/review-conformance/consumers/reviewEventProtocol.ts index 6cb823cf4..fef553084 100644 --- a/test/review-conformance/consumers/reviewEventProtocol.ts +++ b/test/review-conformance/consumers/reviewEventProtocol.ts @@ -6,7 +6,7 @@ * reference every other tier is compared against: a surface that framed events its own way * would disagree with this consumer on the fixtures the C4 finding contributed. */ -import { nodeReviewDigest } from "../../../src/core/reviewDigest"; +import { nodeReviewDigest } from "../../../packages/hunk/src/core/reviewDigest"; import { parseReviewEventBegin, parseReviewEventChunk, @@ -14,7 +14,7 @@ import { parseReviewEventFrame, planReviewEventFrames, ReviewEventAssembler, -} from "../../../src/session/reviewEventProtocol"; +} from "../../../packages/hunk/src/session/reviewEventProtocol"; import type { ReviewEventConsumer, ReviewEventFixture } from "../types"; import { collapseChunkRun, resolveFixtureChunkBytes } from "../eventFraming"; diff --git a/test/review-conformance/consumers/reviewProducer.ts b/test/review-conformance/consumers/reviewProducer.ts index fe28a595f..83e7d95fd 100644 --- a/test/review-conformance/consumers/reviewProducer.ts +++ b/test/review-conformance/consumers/reviewProducer.ts @@ -10,18 +10,18 @@ * Each fixture is also self-checked at the boundary the producer actually serves: every * canonical file it would hand out is compared against the manifest entry for it (D4). */ -import { ReviewProducer } from "../../../src/app/review/producer"; -import { assertCanonicalFileMatchesManifest } from "../../../src/core/review/canonicalFile"; +import { ReviewProducer } from "../../../packages/hunk/src/app/review/producer"; +import { assertCanonicalFileMatchesManifest } from "../../../packages/hunk/src/core/review/canonicalFile"; import type { ReviewContentManifestFile, ReviewContentManifestGap, -} from "../../../src/core/review/contentManifest"; +} from "../../../packages/hunk/src/core/review/contentManifest"; import { ReviewIntentPlanningError, type ReviewExpansionToggledOutcome, -} from "../../../src/core/review/intents"; -import { normalizedReviewSourceLines } from "../../../src/core/review/geometry"; -import { createReviewStore } from "../../../src/core/review/store"; +} from "../../../packages/hunk/src/core/review/intents"; +import { normalizedReviewSourceLines } from "../../../packages/hunk/src/core/review/geometry"; +import { createReviewStore } from "../../../packages/hunk/src/core/review/store"; import type { ConformanceExpandedRow, ConformanceGap, diff --git a/test/review-conformance/consumers/reviewWire.ts b/test/review-conformance/consumers/reviewWire.ts index aff99be75..c2ac6a80a 100644 --- a/test/review-conformance/consumers/reviewWire.ts +++ b/test/review-conformance/consumers/reviewWire.ts @@ -10,8 +10,11 @@ * It also runs the note-size corpus, because "may this note cross a boundary" is a wire * question as much as a producer one, and both must answer it the same way (D1). */ -import { reviewNoteWithinSizeLimit } from "../../../src/core/review/noteSize"; -import { parseHunkReviewAction, toReviewIntent } from "../../../src/session/reviewProtocol"; +import { reviewNoteWithinSizeLimit } from "../../../packages/hunk/src/core/review/noteSize"; +import { + parseHunkReviewAction, + toReviewIntent, +} from "../../../packages/hunk/src/session/reviewProtocol"; import type { ReviewWireConsumer } from "../types"; export const reviewWireConsumer: ReviewWireConsumer = { diff --git a/test/review-conformance/consumers/terminalRenderPlan.ts b/test/review-conformance/consumers/terminalRenderPlan.ts index 2d922cb21..6575cb52a 100644 --- a/test/review-conformance/consumers/terminalRenderPlan.ts +++ b/test/review-conformance/consumers/terminalRenderPlan.ts @@ -7,14 +7,14 @@ * builder ever re-derives a gap range or a note target on its own, this adapter reports * the divergence. */ -import { resolveCommentTarget } from "../../../src/core/liveComments"; -import { reviewGapId } from "../../../src/core/review/expansion"; -import type { DiffFile } from "../../../src/core/changeset/model"; -import { buildDiffSectionRowPlan } from "../../../src/ui/diff/diffSectionRowPlan"; -import { DIFF_MESSAGES, diffMessage } from "../../../src/ui/diff/plannedRowText"; -import type { DiffRow } from "../../../src/ui/diff/diffRows"; -import { buildSelectedHunkSummary } from "../../../src/ui/lib/reviewState"; -import { resolveTheme } from "../../../src/ui/themes"; +import { resolveCommentTarget } from "../../../packages/hunk/src/core/liveComments"; +import { reviewGapId } from "../../../packages/hunk/src/core/review/expansion"; +import type { DiffFile } from "../../../packages/hunk/src/core/changeset/model"; +import { buildDiffSectionRowPlan } from "../../../packages/hunk/src/ui/diff/diffSectionRowPlan"; +import { DIFF_MESSAGES, diffMessage } from "../../../packages/hunk/src/ui/diff/plannedRowText"; +import type { DiffRow } from "../../../packages/hunk/src/ui/diff/diffRows"; +import { buildSelectedHunkSummary } from "../../../packages/hunk/src/ui/lib/reviewState"; +import { resolveTheme } from "../../../packages/hunk/src/ui/themes"; import type { ConformanceExpandedRow, ConformanceGap, diff --git a/test/review-conformance/consumers/terminalReview.ts b/test/review-conformance/consumers/terminalReview.ts index b51106ac9..29cea6e63 100644 --- a/test/review-conformance/consumers/terminalReview.ts +++ b/test/review-conformance/consumers/terminalReview.ts @@ -6,14 +6,17 @@ * planning paths without mounting OpenTUI, so the shared corpus catches a terminal * lifecycle regression as well as a core-planner regression. */ -import type { ReviewAction } from "../../../src/core/review/actions"; -import { projectReviewDocument } from "../../../src/core/review/document"; -import { applyReviewIntent } from "../../../src/core/review/intents"; -import { reduceReviewState } from "../../../src/core/review/reducer"; -import { selectRevealTarget } from "../../../src/core/review/selectors"; -import { createInitialReviewState, type ReviewState } from "../../../src/core/review/state"; -import type { ReviewStore } from "../../../src/core/review/store"; -import { planTerminalSelectionReconciliation } from "../../../src/ui/lib/reviewState"; +import type { ReviewAction } from "../../../packages/hunk/src/core/review/actions"; +import { projectReviewDocument } from "../../../packages/hunk/src/core/review/document"; +import { applyReviewIntent } from "../../../packages/hunk/src/core/review/intents"; +import { reduceReviewState } from "../../../packages/hunk/src/core/review/reducer"; +import { selectRevealTarget } from "../../../packages/hunk/src/core/review/selectors"; +import { + createInitialReviewState, + type ReviewState, +} from "../../../packages/hunk/src/core/review/state"; +import type { ReviewStore } from "../../../packages/hunk/src/core/review/store"; +import { planTerminalSelectionReconciliation } from "../../../packages/hunk/src/ui/lib/reviewState"; import { toAnnotationIndex, toConformanceSelection, toSemanticSelection } from "./intentPlanner"; import type { ReviewNavigationConsumer, ReviewNavigationFixture } from "../types"; diff --git a/test/review-conformance/eventFixtures.ts b/test/review-conformance/eventFixtures.ts index 00545ce85..0e2864632 100644 --- a/test/review-conformance/eventFixtures.ts +++ b/test/review-conformance/eventFixtures.ts @@ -13,9 +13,12 @@ * byte count; how many chunks a payload needs is arithmetic, and pinning it would only * make the corpus brittle about sizes it is not about. */ -import { REVIEW_PATCH_CONTENT_TYPE, reviewResourceId } from "../../src/core/review/resources"; -import { HUNK_REVIEW_PROTOCOL_VERSION } from "../../src/session/reviewProtocol"; -import type { HunkReviewPublicationBodyV1 } from "../../src/session/reviewHttpProtocol"; +import { + REVIEW_PATCH_CONTENT_TYPE, + reviewResourceId, +} from "../../packages/hunk/src/core/review/resources"; +import { HUNK_REVIEW_PROTOCOL_VERSION } from "../../packages/hunk/src/session/reviewProtocol"; +import type { HunkReviewPublicationBodyV1 } from "../../packages/hunk/src/session/reviewHttpProtocol"; import type { ReviewEventFixture } from "./types"; export const EVENT_FIXTURE_SESSION_ID = "session-conformance"; diff --git a/test/review-conformance/geometryFixtures.ts b/test/review-conformance/geometryFixtures.ts index bd0c21e32..47704900c 100644 --- a/test/review-conformance/geometryFixtures.ts +++ b/test/review-conformance/geometryFixtures.ts @@ -7,7 +7,7 @@ * they are the inputs the deleted copies got wrong. */ import { createTestDiffFile, lines } from "../helpers/diff-helpers"; -import type { DiffFile } from "../../src/core/changeset/model"; +import type { DiffFile } from "../../packages/hunk/src/core/changeset/model"; import type { ReviewGeometryFixture } from "./types"; /** Twelve numbered lines, the base every geometry fixture edits. */ diff --git a/test/review-conformance/navigationFixtures.ts b/test/review-conformance/navigationFixtures.ts index e84d242d5..622513f24 100644 --- a/test/review-conformance/navigationFixtures.ts +++ b/test/review-conformance/navigationFixtures.ts @@ -8,7 +8,7 @@ * disagreed about, so a captured expectation would preserve the disagreement. */ import { createTestDiffFile, lines } from "../helpers/diff-helpers"; -import type { DiffFile } from "../../src/core/changeset/model"; +import type { DiffFile } from "../../packages/hunk/src/core/changeset/model"; import type { ReviewNavigationFixture } from "./types"; /** Twelve numbered lines, the base every navigation fixture edits. */ diff --git a/test/review-conformance/noteSize.ts b/test/review-conformance/noteSize.ts index c39628aa2..2f657bcf3 100644 --- a/test/review-conformance/noteSize.ts +++ b/test/review-conformance/noteSize.ts @@ -9,8 +9,8 @@ * Sizes are stated relative to the shared bound rather than as literals, so the corpus * still means the same thing if the bound moves. */ -import { MAX_REVIEW_NOTE_BYTES } from "../../src/core/review/noteSize"; -import type { ReviewNoteV1 } from "../../src/core/review/types"; +import { MAX_REVIEW_NOTE_BYTES } from "../../packages/hunk/src/core/review/noteSize"; +import type { ReviewNoteV1 } from "../../packages/hunk/src/core/review/types"; export interface ReviewNoteSizeFixture { id: string; diff --git a/test/review-conformance/orderingFixtures.ts b/test/review-conformance/orderingFixtures.ts index d00e4bf96..f476f5b49 100644 --- a/test/review-conformance/orderingFixtures.ts +++ b/test/review-conformance/orderingFixtures.ts @@ -7,7 +7,7 @@ * * Verdicts are written by hand from the invariant, never captured from the classifier. */ -import type { ReviewPublicationOrder } from "../../src/core/review/generationOrder"; +import type { ReviewPublicationOrder } from "../../packages/hunk/src/core/review/generationOrder"; /** One arriving publication judged against the position a receiver already holds. */ export interface ReviewPublicationOrderFixture { diff --git a/test/review-conformance/types.ts b/test/review-conformance/types.ts index fd7909304..c262a351c 100644 --- a/test/review-conformance/types.ts +++ b/test/review-conformance/types.ts @@ -19,13 +19,13 @@ import type { ReviewPublicationAddress, ReviewPublicationOrder, -} from "../../src/core/review/generationOrder"; -import type { ReviewIntent } from "../../src/core/review/intents"; -import type { ReviewSelectionScope } from "../../src/core/review/navigation"; -import type { ReviewState } from "../../src/core/review/state"; -import type { ReviewNoteV1 } from "../../src/core/review/types"; -import type { HunkReviewPublicationBodyV1 } from "../../src/session/reviewHttpProtocol"; -import type { DiffFile } from "../../src/core/changeset/model"; +} from "../../packages/hunk/src/core/review/generationOrder"; +import type { ReviewIntent } from "../../packages/hunk/src/core/review/intents"; +import type { ReviewSelectionScope } from "../../packages/hunk/src/core/review/navigation"; +import type { ReviewState } from "../../packages/hunk/src/core/review/state"; +import type { ReviewNoteV1 } from "../../packages/hunk/src/core/review/types"; +import type { HunkReviewPublicationBodyV1 } from "../../packages/hunk/src/session/reviewHttpProtocol"; +import type { DiffFile } from "../../packages/hunk/src/core/changeset/model"; export interface ConformanceGap { gapId: string; diff --git a/test/session-broker-runtime/connectionFixture.ts b/test/session-broker-runtime/connectionFixture.ts index 0a8991eb8..743f33fd0 100644 --- a/test/session-broker-runtime/connectionFixture.ts +++ b/test/session-broker-runtime/connectionFixture.ts @@ -12,8 +12,11 @@ import { type SessionRegistration, type SessionSnapshot, } from "@hunk/session-broker"; -import { SessionBrokerClient } from "../../src/session/broker/brokerClient"; -import type { HunkSessionRegistration, HunkSessionSnapshot } from "../../src/session/types"; +import { SessionBrokerClient } from "../../packages/hunk/src/session/broker/brokerClient"; +import type { + HunkSessionRegistration, + HunkSessionSnapshot, +} from "../../packages/hunk/src/session/types"; interface RunningDaemon { stop(): void | Promise; diff --git a/test/session/broker-e2e.test.ts b/test/session/broker-e2e.test.ts index 30858d5d3..1ca8deacb 100644 --- a/test/session/broker-e2e.test.ts +++ b/test/session/broker-e2e.test.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { cleanupTestConfigHomes, createTestConfigHome } from "../helpers/config-home"; const repoRoot = process.cwd(); -const sourceEntrypoint = join(repoRoot, "src/main.tsx"); +const sourceEntrypoint = join(repoRoot, "packages/hunk/src/main.tsx"); // Spawned hunk processes must assert built-in defaults, not the developer's ambient user config. const testConfigHome = createTestConfigHome(); @@ -183,7 +183,7 @@ async function cleanupHunkSession(proc: HunkSessionProcess) { } function runSessionCli(args: string[], port: number) { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "session", ...args], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "session", ...args], { cwd: repoRoot, stdin: "ignore", stdout: "pipe", diff --git a/test/session/cli.test.ts b/test/session/cli.test.ts index afdc2a68e..f16c8b91d 100644 --- a/test/session/cli.test.ts +++ b/test/session/cli.test.ts @@ -7,7 +7,7 @@ import { cleanupTestConfigHomes, createTestConfigHome } from "../helpers/config- import { removeTestDirectory } from "../helpers/filesystem"; const repoRoot = process.cwd(); -const sourceEntrypoint = join(repoRoot, "src/main.tsx"); +const sourceEntrypoint = join(repoRoot, "packages/hunk/src/main.tsx"); // Spawned hunk processes must assert built-in defaults, not the developer's ambient user config. const testConfigHome = createTestConfigHome(); const testRuntimeDir = mkdtempSync(join(tmpdir(), "hunk-session-cli-runtime-")); @@ -307,7 +307,7 @@ async function cleanupHunkSession( } function runSessionCli(args: string[], port: number, stdinText?: string) { - const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "session", ...args], { + const proc = Bun.spawnSync(["bun", "run", "packages/hunk/src/main.tsx", "session", ...args], { cwd: repoRoot, stdin: stdinText === undefined ? "ignore" : Buffer.from(stdinText), stdout: "pipe", diff --git a/test/session/daemon.test.ts b/test/session/daemon.test.ts index 7ccd8d903..23f0ad997 100644 --- a/test/session/daemon.test.ts +++ b/test/session/daemon.test.ts @@ -77,7 +77,7 @@ describe("session daemon lifecycle", () => { const port = await reserveLoopbackPort(); // Invoke the Bun executable directly so this handle owns the daemon on Windows instead of a // `bun run` launcher that can exit before its child releases the listening socket. - const proc = Bun.spawn([process.execPath, "src/main.tsx", "daemon", "serve"], { + const proc = Bun.spawn([process.execPath, "packages/hunk/src/main.tsx", "daemon", "serve"], { cwd: repoRoot, stdin: "ignore", stdout: "pipe", diff --git a/test/smoke/tty.test.ts b/test/smoke/tty.test.ts index 73a7c0538..e144d0c97 100644 --- a/test/smoke/tty.test.ts +++ b/test/smoke/tty.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { cleanupTestConfigHomes, createTestConfigHome } from "../helpers/config-home"; const repoRoot = process.cwd(); -const sourceEntrypoint = join(repoRoot, "src/main.tsx"); +const sourceEntrypoint = join(repoRoot, "packages/hunk/src/main.tsx"); // Spawned hunk processes must assert built-in defaults, not the developer's ambient user config. const testConfigHome = createTestConfigHome(); diff --git a/tsconfig.extension.json b/tsconfig.extension.json index cd10020df..6ad263ac3 100644 --- a/tsconfig.extension.json +++ b/tsconfig.extension.json @@ -4,9 +4,9 @@ "noEmit": false, "declaration": true, "emitDeclarationOnly": true, - "outDir": "./dist/npm-extension-types", - "rootDir": "./src" + "outDir": "./packages/hunk/dist/npm-extension-types", + "rootDir": "./packages/hunk/src" }, "include": [], - "files": ["src/extension-api/index.ts"] + "files": ["packages/hunk/src/extension-api/index.ts"] } diff --git a/tsconfig.json b/tsconfig.json index d10526797..0eeaafd79 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,15 @@ "@hunk/session-broker-bun": ["packages/session-broker-bun/src/index.ts"], "@hunk/session-broker-core": ["packages/session-broker-core/src/index.ts"], "@hunk/session-broker-node": ["packages/session-broker-node/src/index.ts"], - "hunkdiff/extension": ["src/extension-api/index.ts"] + "@hunk/git": ["packages/hunk-git/src/index.ts"], + "@hunk/jj": ["packages/hunk-jj/src/index.ts"], + "@hunk/sapling": ["packages/hunk-sapling/src/index.ts"], + "@hunk/vcs": ["packages/hunk-vcs/src/index.ts"], + "@hunk/vcs/diff-range": ["packages/hunk-vcs/src/diffRange.ts"], + "@hunk/vcs/large-file": ["packages/hunk-vcs/src/largeFile.ts"], + "@hunk/vcs/os-path": ["packages/hunk-vcs/src/osPath.ts"], + "@hunk/vcs/source-text": ["packages/hunk-vcs/src/sourceText.ts"], + "hunkdiff/extension": ["packages/hunk/src/extension-api/index.ts"] }, "strict": true, "skipLibCheck": true, @@ -29,8 +37,6 @@ "noUnusedParameters": false }, "include": [ - "src/**/*.ts", - "src/**/*.tsx", "scripts/**/*.ts", "packages/**/*.ts", "test/**/*.ts", diff --git a/tsconfig.opentui.json b/tsconfig.opentui.json index e1b363a39..a4f21a2f5 100644 --- a/tsconfig.opentui.json +++ b/tsconfig.opentui.json @@ -4,9 +4,10 @@ "noEmit": false, "declaration": true, "emitDeclarationOnly": true, - "outDir": "./dist/npm-types", - "rootDir": "./src" + "stripInternal": true, + "outDir": "./packages/hunk/dist/npm-types", + "rootDir": "./packages" }, "include": [], - "files": ["src/opentui/index.ts"] + "files": ["packages/hunk/src/opentui/index.ts"] } diff --git a/website/MEDIA.md b/website/MEDIA.md index 5f798418b..c772f53e0 100644 --- a/website/MEDIA.md +++ b/website/MEDIA.md @@ -2,7 +2,7 @@ ## Feature showcase captures -`public/feature-*.{webp,mp4,webm}` are captured from the real TUI by `scripts/capture-media.ts`: it drives `bun run src/main.tsx` inside a PTY (tuistory), renders styled terminal frames to retina images at devicePixelRatio 2 (ghostty-opentui), composites a synthetic mouse pointer where the storyboard moves one (`scripts/assets/pointer.png`), and assembles clips into looping mp4 + webm with ffmpeg. Regenerate after user-visible changes to the review stream, layouts, mouse affordances, or themes: +`public/feature-*.{webp,mp4,webm}` are captured from the real TUI by `scripts/capture-media.ts`: it drives `bun run packages/hunk/src/main.tsx` inside a PTY (tuistory), renders styled terminal frames to retina images at devicePixelRatio 2 (ghostty-opentui), composites a synthetic mouse pointer where the storyboard moves one (`scripts/assets/pointer.png`), and assembles clips into looping mp4 + webm with ffmpeg. Regenerate after user-visible changes to the review stream, layouts, mouse affordances, or themes: ```bash bun run website/scripts/capture-media.ts # everything diff --git a/website/bun.lock b/website/bun.lock index 36bdee570..590566311 100644 --- a/website/bun.lock +++ b/website/bun.lock @@ -16,7 +16,9 @@ "devDependencies": { "@astrojs/check": "^0.9.10", "@axe-core/playwright": "^4.10.2", - "@playwright/test": "^1.56.1", + "@playwright/test": "1.56.1", + "playwright": "1.56.1", + "playwright-core": "1.56.1", "typescript": "^5.9.3", }, }, @@ -262,7 +264,7 @@ "@pagefind/windows-x64": ["@pagefind/windows-x64@1.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg=="], - "@playwright/test": ["@playwright/test@1.62.0", "", { "dependencies": { "playwright": "1.62.0" }, "bin": { "playwright": "cli.js" } }, "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA=="], + "@playwright/test": ["@playwright/test@1.56.1", "", { "dependencies": { "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" } }, "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], @@ -884,9 +886,9 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - "playwright": ["playwright@1.62.0", "", { "dependencies": { "playwright-core": "1.62.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw=="], + "playwright": ["playwright@1.56.1", "", { "dependencies": { "playwright-core": "1.56.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw=="], - "playwright-core": ["playwright-core@1.62.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA=="], + "playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="], "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], diff --git a/website/package.json b/website/package.json index dbb24c4c2..f863b0118 100644 --- a/website/package.json +++ b/website/package.json @@ -21,7 +21,9 @@ "devDependencies": { "@astrojs/check": "^0.9.10", "@axe-core/playwright": "^4.10.2", - "@playwright/test": "^1.56.1", + "@playwright/test": "1.56.1", + "playwright": "1.56.1", + "playwright-core": "1.56.1", "typescript": "^5.9.3" } } diff --git a/website/scripts/capture-media.ts b/website/scripts/capture-media.ts index eee82df1b..e1876f3ac 100644 --- a/website/scripts/capture-media.ts +++ b/website/scripts/capture-media.ts @@ -1,7 +1,7 @@ /** * Capture the landing page's feature media from the real Hunk TUI. * - * Drives `bun run src/main.tsx` inside a PTY (tuistory), renders styled + * Drives `bun run packages/hunk/src/main.tsx` inside a PTY (tuistory), renders styled * terminal frames to retina images (ghostty-opentui), and assembles the * animated captures into looping mp4 + webm clips with ffmpeg. * @@ -60,7 +60,7 @@ async function launchHunkForCapture(options: { args: string[]; cols: number; row const configHome = mkdtempSync(join(tmpdir(), "hunk-capture-config-")); const session = await launchTerminal({ command: process.execPath, - args: ["run", join(repoRoot, "src/main.tsx"), "--", ...options.args], + args: ["run", join(repoRoot, "packages/hunk/src/main.tsx"), "--", ...options.args], cwd: repoRoot, cols: options.cols, rows: options.rows, diff --git a/website/src/components/marketing/ThemeShot.astro b/website/src/components/marketing/ThemeShot.astro index 49f4ed98d..34b183f10 100644 --- a/website/src/components/marketing/ThemeShot.astro +++ b/website/src/components/marketing/ThemeShot.astro @@ -2,7 +2,7 @@ // The count comes from Hunk's own catalog so the "more" pill can never drift // from what ships. The module is a bare id array with no imports, so reaching // across into the app costs the site build nothing. -import { BUNDLED_SHIKI_THEME_IDS } from "../../../../src/core/theme/catalog"; +import { BUNDLED_SHIKI_THEME_IDS } from "../../../../packages/hunk/src/core/theme/catalog"; /** * The picker's spread of the bundled catalog: one neutral default, the popular diff --git a/website/src/content/docs/docs/agents/review-skill.md b/website/src/content/docs/docs/agents/review-skill.md index 7c3a72c17..46968aea3 100644 --- a/website/src/content/docs/docs/agents/review-skill.md +++ b/website/src/content/docs/docs/agents/review-skill.md @@ -17,9 +17,9 @@ For agents that need a stable web-readable URL, use the [generated Hunk review s ## Why it is generated -The checked-in `skills/hunk-review/SKILL.md` is rendered from typed command metadata and agent error definitions in Hunk's source. Parser help, examples, constraints, and common remedies therefore share ownership instead of drifting as separate handwritten copies. +The checked-in `packages/hunk/skills/hunk-review/SKILL.md` is rendered from typed command metadata and agent error definitions in Hunk's source. Parser help, examples, constraints, and common remedies therefore share ownership instead of drifting as separate handwritten copies. -Do not edit the generated skill directly. Contributors change `src/hunk-review/skillDocument.ts`, `src/session/agent/surface.ts`, or `src/session/agent/errors.ts`, then run: +Do not edit the generated skill directly. Contributors change `packages/hunk/src/hunk-review/skillDocument.ts`, `packages/hunk/src/session/agent/surface.ts`, or `packages/hunk/src/session/agent/errors.ts`, then run: ```bash bun run generate:skill diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index 0c801d613..d7c3ae087 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -168,7 +168,7 @@ That is enough to window a long list yourself: the built-in files pane renders o One honest caveat: this contract rides on OpenTUI's renderable API, served at whatever version Hunk pins — a wider surface than `hunkdiff/extension` itself. The built-in files pane exercising the exact same calls is the compatibility guarantee: a change that breaks your scroll code breaks Hunk's own files pane first. Still, keep scroll handling small and behind your own helpers. -The built-in files pane is itself a bundled extension (`src/extensions/default/ui/sidebar/` in the Hunk repository): it registers through this exact call, its component consumes exactly the props documented above, and its windowing and selection follow run on exactly the ref contract above — so it doubles as the reference implementation for everything a third-party pane can build, from grouping and stat badges down to scroll behavior. +The built-in files pane is itself a bundled extension (`packages/hunk/src/extensions/default/ui/sidebar/` in the Hunk repository): it registers through this exact call, its component consumes exactly the props documented above, and its windowing and selection follow run on exactly the ref contract above — so it doubles as the reference implementation for everything a third-party pane can build, from grouping and stat badges down to scroll behavior. ## Pane state from events diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 61aaab3dc..a8b0b8244 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -36,7 +36,7 @@ Writing one with a coding agent? `hunk skill path hunk-extensions` prints a bund - The two repo-local sources are one group: one trust decision, one sort order. - A directory source matches `*.ts`, `*.tsx`, `*.js`, `*.jsx`, `*.mjs` directly inside it, plus one level of folder extensions. - `--no-extensions` disables user extensions for one run; nothing on disk is read. -- `--extension` is explicit intent: it loads immediately, without a trust prompt, even from inside the reviewed repo — so never pass a path you have not read. +- `--extension` is explicit intent: it loads immediately, without a trust prompt, even from inside the reviewed repo — but it cannot bypass a persistent package denial, including through a symlink. Never pass a path you have not read. ### Folder extensions @@ -52,6 +52,7 @@ A folder is an extension if its `package.json` declares entries under the `hunk` ``` - Manifest paths resolve against the folder and may list several entries; each loads as its own extension, in manifest order. +- The package `name` is the stable activation identity shared by those entries; optional `hunk.packageId` overrides it. Package ids are bounded lowercase npm-style names, while legacy folder names are deterministically lowercased and sanitized. - The manifest is a real `package.json`, so a folder extension can depend on npm packages installed into its own `node_modules`. - Pointing `--extension` or `[extensions] paths` at a directory works either way: a folder extension loads as one extension; any other directory is scanned as a directory _of_ extensions. @@ -77,8 +78,9 @@ hunk extension install git:codeberg.org/acme/ext # any host; https:// is assu hunk extension install ~/dev/hunk-word-diff # a local checkout, for testing ``` -- `hunk extension list` shows every managed install with its version, commit, and source. -- `hunk extension update [name]` re-clones one install (or all of them) from its recorded source; an `@ref` pin stays put until you re-install with a different one. +- `hunk extension list` shows each managed install's stable package identity, ordered entry ids, activation, version, commit, and source. +- `hunk extension disable ` skips all entries in that package before imports and trust checks; `hunk extension enable ...` re-enables them. This preference is stored separately from installation metadata. +- `hunk extension update [name]` re-clones one install (or all of them) from its recorded source; an `@ref` pin stays put until you re-install with a different one. Updates preserve per-package activation and refuse new or ambiguous identities while an affected package is disabled. - `hunk extension remove ` deletes the install and its record. Hand-copied extensions in `~/.config/hunk/extensions/` are never touched. Installing is the consent step: extensions run with your full user permissions, so a fresh install asks for confirmation (or takes `--yes`) after naming the repository. Only install repositories you trust. Managed installs then load through the global group above — same precedence, no further prompts. @@ -98,7 +100,7 @@ Test the exact layout users will get with `hunk extension install /path/to/check ## Bundled extensions -Hunk's Git, Jujutsu, Sapling, and file-navigation pane use the same public extension API. Bundled extensions differ from yours in three ways: +Hunk's Git, Jujutsu, Sapling, and file-navigation pane use the same public extension API. The VCS providers are private, statically bundled workspaces rather than independently installable packages. Bundled extensions differ from yours in three ways: - statically imported, so they load before config resolution picks the session's VCS - implicitly trusted, with no `[extension.]` config table diff --git a/website/src/content/docs/docs/extend/file-previews.md b/website/src/content/docs/docs/extend/file-previews.md index 928e28914..8481b80a7 100644 --- a/website/src/content/docs/docs/extend/file-previews.md +++ b/website/src/content/docs/docs/extend/file-previews.md @@ -220,7 +220,7 @@ A `null`, invalid, oversized, cancelled, timed-out, or throwing layout produces The examples are not bundled or loaded by default. Run one directly while developing: ```bash -bun run src/main.tsx -- diff \ +bun run packages/hunk/src/main.tsx -- diff \ --extension ./examples/extensions/rendered-markdown \ ./examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/before/README.md \ ./examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/after/README.md diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index ea51eaee5..a16b57131 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -215,6 +215,30 @@ hunk extension list **Aliases:** `hunk ext list`. +## `hunk extension enable` + +enable every entry in one managed extension package + +### Usage + +```bash +hunk extension enable +``` + +**Aliases:** `hunk ext enable`. + +## `hunk extension disable` + +disable every entry in one managed extension package + +### Usage + +```bash +hunk extension disable +``` + +**Aliases:** `hunk ext disable`. + ## `hunk extension update` re-clone managed extension installs from their recorded sources diff --git a/website/src/content/docs/docs/reference/config.md b/website/src/content/docs/docs/reference/config.md index b98146a58..c7a6978d7 100644 --- a/website/src/content/docs/docs/reference/config.md +++ b/website/src/content/docs/docs/reference/config.md @@ -5,7 +5,7 @@ description: Exhaustive generated reference for Hunk TOML keys, defaults, aliase -Hunk reads TOML preferences from the user config and an optional repository config. This reference is generated from the same catalog that `src/core/run/config.ts` uses to parse preference keys. +Hunk reads TOML preferences from the user config and an optional repository config. This reference is generated from the same catalog that `packages/hunk/src/core/run/config.ts` uses to parse preference keys. ## Resolution and scope diff --git a/website/src/data/comparisons.ts b/website/src/data/comparisons.ts index bf8128524..b5d2d8a91 100644 --- a/website/src/data/comparisons.ts +++ b/website/src/data/comparisons.ts @@ -1,4 +1,4 @@ -import { BUNDLED_SHIKI_THEME_IDS } from "../../../src/core/theme/catalog"; +import { BUNDLED_SHIKI_THEME_IDS } from "../../../packages/hunk/src/core/theme/catalog"; import { SITE_ORIGIN } from "../lib/site"; /**