From ed87f023cb04b3a99efdcf7af661680e0d3bc125 Mon Sep 17 00:00:00 2001 From: alexion Date: Tue, 14 Jul 2026 12:00:37 -0400 Subject: [PATCH] feat: add npm publish readiness (task 0020) Complete the distribution metadata and publish flow for the unscoped `gitea-axi` package: - add `repository`, `homepage`, and `bugs` to package.json - add `publishConfig` (public access, npmjs registry) so `npm publish` needs no extra flags - replace `prepublishOnly` with `prepack: npm run build`, so both `npm pack` and `npm publish` rebuild `dist/` first - document the single-command flow in PUBLISHING.md - add a packaging smoke-test tier (`test:pack`) that packs the real tarball, installs it globally, and drives the installed binary (`--help`, dashboard header, and `setup` finding the bundled skill) --- .claude/tasks/0020-npm-distribution.md | 25 +++- PUBLISHING.md | 38 ++++++ package.json | 17 ++- test/packaging/packaging.test.ts | 172 +++++++++++++++++++++++++ vitest.config.ts | 2 +- vitest.packaging.config.ts | 19 +++ 6 files changed, 266 insertions(+), 7 deletions(-) create mode 100644 PUBLISHING.md create mode 100644 test/packaging/packaging.test.ts create mode 100644 vitest.packaging.config.ts diff --git a/.claude/tasks/0020-npm-distribution.md b/.claude/tasks/0020-npm-distribution.md index aeb126b..d538c64 100644 --- a/.claude/tasks/0020-npm-distribution.md +++ b/.claude/tasks/0020-npm-distribution.md @@ -13,7 +13,24 @@ This tarball-and-global-install check is the applicable real-integration surface ## Acceptance criteria -- [ ] The packed tarball contains the built CLI, the bin entry, and the Agent Skill markdown, and nothing declares a postinstall script -- [ ] A global install from the tarball puts a working `gitea-axi` on the PATH (dashboard header, `--help`, and `setup` all function) -- [ ] Package metadata is complete: unscoped name, description, repository URL, license, Node 20+ engines, ESM module type -- [ ] The publish flow (registry target, access, prepack build) is documented or scripted so publishing is a single command +- [x] The packed tarball contains the built CLI, the bin entry, and the Agent Skill markdown, and nothing declares a postinstall script +- [x] A global install from the tarball puts a working `gitea-axi` on the PATH (dashboard header, `--help`, and `setup` all function) +- [x] Package metadata is complete: unscoped name, description, repository URL, license, Node 20+ engines, ESM module type +- [x] The publish flow (registry target, access, prepack build) is documented or scripted so publishing is a single command + +## Implementation Notes + +Most package metadata (unscoped name, description, `type: "module"`, MIT license, `engines.node >=20`, the `gitea-axi` bin, and the `files` allowlist shipping `dist` + `skills`) already existed from earlier tasks; this slice added the missing `repository` (plus conventional `homepage`/`bugs`) and the publish wiring. + +`prepublishOnly` was replaced with `prepack: "npm run build"`. +`prepack` fires on both `npm pack` and `npm publish`, so the tarball always carries a freshly built `dist/` — which the packaging smoke test's `npm pack` relies on — whereas `prepublishOnly` only ran on publish. +`publishConfig` pins `access: "public"` (so the unscoped package publishes without `--access public`) and `registry: "https://registry.npmjs.org/"` (so a machine with a different default registry still publishes to the right place), making `npm publish` a genuine single command. +The flow is also written down in `PUBLISHING.md`. + +The packaging smoke test is its own tier: `vitest.packaging.config.ts` + the `test:pack` script, excluded from the fast `npm test` run (it packs, installs globally, and fetches runtime deps from the registry, so it is slow). +Distribution touches no Gitea API, so — exactly as the task frames it — this tarball-and-global-install check stands in for the absent live-Gitea e2e tier rather than being one. + +Deviations / follow-ups: + +- **Process deviation (TDD sequencing):** the test-writer sub-agent ran concurrently with the `package.json`/`PUBLISHING.md` edits, so by its final run it observed GREEN and did not report a clean RED for the metadata/publish facets (it mis-attributed the fields to the prior commit). The pre-edit tree was genuinely RED for those facets (no `repository`, `prepack`, `publishConfig`, or `PUBLISHING.md`); the file-presence and installed-binary facets were already GREEN because the CLI, bin, and bundled skill shipped in task 0018. +- **Test robustness fixes the sub-agent made:** the `npm pack --json` output shape differs across npm majors (array vs. object), so the test tolerates both; and the dashboard facet uses a promisified `execFile` rather than `execFileSync` so the in-process fixture Gitea server can answer the CLI's HTTP calls (a synchronous spawn deadlocks the shared event loop). diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..f315187 --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,38 @@ +# Publishing gitea-axi + +`gitea-axi` is an unscoped, public npm package. +Publishing it is a single command. + +## Release + +```sh +npm publish +``` + +That is the whole flow. +Everything the release needs is wired into `package.json`, so no extra flags are required: + +- The `prepack` script runs `npm run build`, so the tarball always carries a freshly compiled `dist/` rather than whatever happened to be on disk. +- `publishConfig.access` is `public`, so the unscoped package publishes publicly without `--access public`. +- `publishConfig.registry` targets the public npm registry (`https://registry.npmjs.org/`), so a machine whose default registry is set elsewhere still publishes to the right place. +- The `files` allowlist ships only `dist/` and `skills/`, so the built CLI and the bundled Agent Skill go out and nothing else does. + +There is deliberately no `postinstall` script. +Installing the package delivers the `gitea-axi` binary only; installing the Agent Skill and the session hooks stays an explicit user action behind `gitea-axi setup` and `gitea-axi setup hooks`. + +## Before publishing + +You need to be authenticated to the npm registry (`npm whoami` to check, `npm login` if not) with publish rights to the `gitea-axi` name. + +Bump the version first with `npm version `, which updates `package.json` and creates the release commit and tag. + +## Verifying the packed artifact + +The packaging smoke test packs the real tarball, installs it globally into a throwaway prefix, and drives the installed binary — `--help`, the dashboard header, and `setup` finding the bundled skill: + +```sh +npm run test:pack +``` + +It builds, packs, and fetches the runtime dependencies from the registry, so it is slower than the unit and integration tiers and is not part of the default `npm test` run. +Distribution touches no Gitea API, so this smoke test is the distribution analogue of the live-Gitea end-to-end tier. diff --git a/package.json b/package.json index ef7aa50..820a3ad 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,31 @@ "bin": { "gitea-axi": "dist/main.js" }, + "repository": { + "type": "git", + "url": "git+https://git.alexion.dev/alexion/gitea-axi.git" + }, + "homepage": "https://git.alexion.dev/alexion/gitea-axi", + "bugs": { + "url": "https://git.alexion.dev/alexion/gitea-axi/issues" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, "files": [ "dist", "skills" ], "scripts": { "build": "tsc -p tsconfig.build.json", - "prepublishOnly": "npm run build", + "prepack": "npm run build", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:e2e": "vitest run --config vitest.e2e.config.ts" + "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:pack": "vitest run --config vitest.packaging.config.ts" }, "dependencies": { "@toon-format/toon": "^2.3.0", diff --git a/test/packaging/packaging.test.ts b/test/packaging/packaging.test.ts new file mode 100644 index 0000000..de48ea4 --- /dev/null +++ b/test/packaging/packaging.test.ts @@ -0,0 +1,172 @@ +import { execFile, execFileSync } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { startFixtureServer } from "../fixture-server.js"; + +// The one artifact under test: the packed npm tarball, installed globally into a +// throwaway prefix. Packing plus a global install is expensive and identical for +// every facet, so it is built once here and the four facets assert against the +// shared result. + +const projectRoot = fileURLToPath(new URL("../..", import.meta.url)); + +let workDir: string; +let extractDir: string; +let binPath: string; +/** The manifest as it ships inside the packed tarball. */ +let packedManifest: Record; + +// Unlike the in-process CLI-seam harness (test/harness.ts), which keeps the +// environment fully explicit so nothing leaks in, this tier spawns a real +// globally-installed binary as a subprocess. That subprocess genuinely needs the +// parent env (PATH to resolve node/npm/tar, npm config, etc.), so both helpers +// inherit `process.env` on purpose and layer the per-call `env` on top. + +/** Run the globally installed binary, returning its stdout. */ +function run(args: string[], env: Record = {}): string { + return execFileSync(binPath, args, { encoding: "utf8", env: { ...process.env, ...env } }); +} + +const execFileAsync = promisify(execFile); + +/** + * Run the installed binary without blocking this process's event loop, so an + * in-process fixture server can answer the CLI's HTTP calls while it runs. (A + * synchronous `execFileSync` would freeze the loop the fixture server lives on, + * deadlocking the request/response.) + */ +async function runAsync(args: string[], env: Record = {}): Promise { + const { stdout } = await execFileAsync(binPath, args, { + encoding: "utf8", + env: { ...process.env, ...env }, + }); + return stdout; +} + +beforeAll(() => { + workDir = mkdtempSync(join(tmpdir(), "gitea-axi-pack-")); + + // Pack the real tarball. `npm pack --json` reports the tarball filename; the + // shape shifted across npm majors (an array of entries on npm <12, an object + // keyed by package name on npm 12+), so accept either and pull the one + // filename out. + const packOutput = execFileSync( + "npm", + ["pack", "--json", "--pack-destination", workDir], + { cwd: projectRoot, encoding: "utf8" }, + ); + const packResult = JSON.parse(packOutput) as unknown; + const packEntries = Array.isArray(packResult) + ? (packResult as Array<{ filename: string }>) + : Object.values(packResult as Record); + const tarball = join(workDir, packEntries[0]!.filename); + + // Extract to inspect the packed manifest and confirm bundled files. npm nests + // everything under `package/`. + extractDir = join(workDir, "extract"); + mkdirSync(extractDir); + execFileSync("tar", ["-xzf", tarball, "-C", extractDir]); + packedManifest = JSON.parse( + readFileSync(join(extractDir, "package", "package.json"), "utf8"), + ) as Record; + + // Install globally into a throwaway prefix so the binary lands on a PATH-like + // bin dir we control. + const prefix = join(workDir, "prefix"); + execFileSync("npm", ["install", "-g", "--prefix", prefix, tarball], { + cwd: projectRoot, + encoding: "utf8", + }); + binPath = join(prefix, "bin", "gitea-axi"); +}, 300_000); + +afterAll(() => { + if (workDir) { + rmSync(workDir, { recursive: true, force: true }); + } +}); + +describe("npm distribution artifact", () => { + it("bundles the built CLI, the bin entry, and the Agent Skill, and declares no postinstall", () => { + expect(existsSync(join(extractDir, "package", "dist", "main.js"))).toBe(true); + expect(existsSync(join(extractDir, "package", "skills", "gitea-axi", "SKILL.md"))).toBe(true); + + const bin = packedManifest.bin as Record | undefined; + expect(bin?.["gitea-axi"]).toBe("dist/main.js"); + + const scripts = packedManifest.scripts as Record | undefined; + expect(scripts?.postinstall).toBeUndefined(); + }); + + it("declares complete metadata: unscoped name, description, repo, license, engines, ESM type", () => { + const name = packedManifest.name as string; + expect(name).toBe("gitea-axi"); + expect(name).not.toContain("@"); + expect(name).not.toContain("/"); + + const description = packedManifest.description as string; + expect(typeof description).toBe("string"); + expect(description.length).toBeGreaterThan(0); + + const repository = packedManifest.repository as string | { url?: string } | undefined; + const repositoryUrl = typeof repository === "string" ? repository : repository?.url; + expect(repositoryUrl).toContain("gitea-axi"); + + expect(packedManifest.license).toBe("MIT"); + + const engines = packedManifest.engines as Record | undefined; + expect(engines?.node).toMatch(/20/); + + expect(packedManifest.type).toBe("module"); + }); + + it("scripts and documents the publish flow so publishing is a single command", () => { + const scripts = packedManifest.scripts as Record | undefined; + expect(scripts?.prepack).toBe("npm run build"); + + const publishConfig = packedManifest.publishConfig as Record | undefined; + expect(publishConfig?.access).toBe("public"); + + const publishingDoc = join(projectRoot, "PUBLISHING.md"); + expect(existsSync(publishingDoc)).toBe(true); + expect(readFileSync(publishingDoc, "utf8")).toMatch(/npm publish/); + }); + + it("puts a working gitea-axi on the PATH: dashboard header, --help, and setup all function", async () => { + expect(existsSync(binPath)).toBe(true); + + expect(run(["--help"])).toContain("usage: gitea-axi"); + + // Dashboard header renders against a stubbed Gitea. + const server = await startFixtureServer([ + { method: "GET", path: "/api/v1/repos/o/r/pulls", body: [] }, + { method: "GET", path: "/api/v1/repos/o/r/issues", body: [] }, + ]); + try { + const dashboard = await runAsync([], { + GITEA_AXI_API_URL: server.url, + GITEA_AXI_REPO: "o/r", + GITEA_AXI_TOKEN: "x", + }); + expect(dashboard).toContain("bin:"); + expect(dashboard).toContain("description: Agent-ergonomic CLI for Gitea"); + expect(dashboard).toContain("repo: o/r"); + } finally { + await server.close(); + } + + // `setup` installs the Agent Skill into HOME/.claude. + const home = mkdtempSync(join(tmpdir(), "gitea-axi-home-")); + try { + expect(run(["setup"], { HOME: home })).toContain("status: installed"); + expect(existsSync(join(home, ".claude", "skills", "gitea-axi", "SKILL.md"))).toBe(true); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7285912..30969ec 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ // Unit + integration tiers: fast, no external dependencies. The end-to-end // tier under test/e2e needs a live Gitea instance and runs via `test:e2e`. include: ["test/**/*.test.ts"], - exclude: ["test/e2e/**"], + exclude: ["test/e2e/**", "test/packaging/**"], coverage: { provider: "v8", reporter: ["text", "html", "lcov"], diff --git a/vitest.packaging.config.ts b/vitest.packaging.config.ts new file mode 100644 index 0000000..29b05f2 --- /dev/null +++ b/vitest.packaging.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // The packaging tier: pack the real tarball, install it globally into a + // throwaway prefix, then drive the installed binary. It builds, packs, and + // fetches runtime deps from the registry, so it is far slower than the fast + // tiers and runs on its own via `test:pack`. Distribution touches no Gitea + // API, so this smoke test is the distribution analogue of the live-Gitea + // e2e tier rather than a member of it. + include: ["test/packaging/**/*.test.ts"], + testTimeout: 180_000, + hookTimeout: 300_000, + // Keep the pack/install work in one process: the tarball is built once in a + // shared setup and reused across the assertions. + fileParallelism: false, + passWithNoTests: true, + }, +}); -- 2.47.3