diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a73e77d2..5e5effda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,41 @@ jobs: - name: TTY smoke tests run: bun run test:tty-smoke + pack-npm: + name: Verify npm package + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build npm runtime bundle + run: bun run build:npm + + - name: Verify npm pack output + run: bun run check:pack + + - name: Simulate global npm install + run: | + pkg_dir="$(mktemp -d)" + install_dir="$(mktemp -d)" + npm pack --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:$PATH" hunk --help | grep 'Usage: hunk' + build-bin: name: Build compiled binary runs-on: ubuntu-latest diff --git a/package.json b/package.json index 95668d62..4131a53e 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,56 @@ { - "name": "hunk", + "name": "hunkdiff", "version": "0.1.0", - "description": "Desktop-inspired terminal diff viewer for agent-authored changesets.", - "module": "src/main.tsx", + "description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.", "type": "module", - "private": true, + "packageManager": "bun@1.3.10", "bin": { - "hunk": "./src/main.tsx" + "hunk": "dist/npm/main.js" }, + "files": [ + "dist/npm", + "README.md", + "LICENSE", + "CONTRIBUTING.md", + "SECURITY.md" + ], "scripts": { "start": "bun run src/main.tsx", "dev": "bun --watch src/main.tsx", + "build:npm": "bash ./scripts/build-npm.sh", "build:bin": "bash ./scripts/build-bin.sh", "install:bin": "bash ./scripts/install-bin.sh", "typecheck": "tsc --noEmit", "test": "bun test", "test:tty-smoke": "bun test test/tty-render-smoke.test.ts", + "check:pack": "bun run ./scripts/check-pack.ts", + "prepack": "bun run build:npm", "bench:bootstrap-load": "bun run test/bootstrap-load-benchmark.ts", "bench:highlight-prefetch": "bun run test/adjacent-highlight-prefetch-benchmark.ts", "bench:large-stream": "bun run test/large-stream-windowing-benchmark.ts" }, + "keywords": [ + "diff", + "git", + "tui", + "terminal", + "code-review", + "ai" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/modem-dev/hunk.git" + }, + "homepage": "https://github.com/modem-dev/hunk#readme", + "bugs": { + "url": "https://github.com/modem-dev/hunk/issues" + }, + "engines": { + "bun": ">=1.3.10" + }, + "publishConfig": { + "access": "public" + }, "devDependencies": { "@types/bun": "latest", "@types/react": "^19.2.14", diff --git a/scripts/build-npm.sh b/scripts/build-npm.sh new file mode 100644 index 00000000..bb962b11 --- /dev/null +++ b/scripts/build-npm.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +outdir="${repo_root}/dist/npm" + +rm -rf "${outdir}" +mkdir -p "${outdir}" + +BUN_TMPDIR="${repo_root}/.bun-tmp" \ +BUN_INSTALL="${repo_root}/.bun-install" \ + bun build "${repo_root}/src/main.tsx" \ + --target bun \ + --format esm \ + --outdir "${outdir}" \ + --entry-naming main.js + +chmod 0755 "${outdir}/main.js" + +printf 'Built %s\n' "${outdir}/main.js" diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts new file mode 100644 index 00000000..4e0ea292 --- /dev/null +++ b/scripts/check-pack.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env bun + +import { existsSync } from "node:fs"; + +interface PackedFile { + path: string; + size: number; +} + +interface PackResult { + name: string; + version: string; + filename: string; + entryCount: number; + files: PackedFile[]; +} + +const proc = Bun.spawnSync(["npm", "pack", "--dry-run", "--json"], { + cwd: process.cwd(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, +}); + +const stdout = Buffer.from(proc.stdout).toString("utf8").trim(); +const stderr = Buffer.from(proc.stderr).toString("utf8").trim(); + +if (proc.exitCode !== 0) { + throw new Error(stderr || stdout || "npm pack --dry-run failed"); +} + +const jsonMatch = stdout.match(/(\[\s*\{[\s\S]*\}\s*\])\s*$/); +const jsonText = jsonMatch?.[1]; + +if (!jsonText) { + throw new Error(`Could not find npm pack JSON output. Full stdout:\n${stdout}`); +} + +const parsed = JSON.parse(jsonText) as PackResult[]; +const pack = parsed[0]; + +if (!pack) { + throw new Error("npm pack --dry-run returned no pack result."); +} + +const publishedPaths = new Set(pack.files.map((file) => file.path)); +const requiredPaths = ["dist/npm/main.js", "README.md", "LICENSE", "package.json"]; +const optionalPaths = ["CONTRIBUTING.md", "SECURITY.md"]; + +for (const path of requiredPaths) { + if (!publishedPaths.has(path)) { + throw new Error(`Expected npm package to include ${path}.`); + } +} + +for (const path of optionalPaths) { + if (existsSync(path) && !publishedPaths.has(path)) { + throw new Error(`Expected npm package to include ${path} when it exists in the repo.`); + } +} + +const forbiddenPrefixes = [".github/", "src/", "test/", "scripts/", "tmp/"]; +const forbiddenPaths = ["AGENTS.md", "autoresearch.checks.sh", "autoresearch.sh", "bun.lock"]; + +for (const file of pack.files) { + if (forbiddenPrefixes.some((prefix) => file.path.startsWith(prefix)) || forbiddenPaths.includes(file.path)) { + throw new Error(`Unexpected file in npm package: ${file.path}`); + } +} + +if (pack.name !== "hunkdiff") { + throw new Error(`Expected npm package name to be hunkdiff, got ${pack.name}.`); +} + +console.log(`Verified npm pack output for ${pack.name}@${pack.version} (${pack.entryCount} files).`);