From fcdd862a5cf81e2807afad4d8c44eae9563b0ea2 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Fri, 12 Jun 2026 07:18:10 -0700 Subject: [PATCH 1/2] chore: build before publish, and fix the build so it's enforceable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.5.0 shipped without dist/ because nothing built before `npm publish`. Make the build clean and guard publishes on it: - prepack: "npm run build" — npm publish/pack now always build dist/ first, so a buildless publish can't happen again. - Clear the pre-existing build errors so the guard doesn't block publish: skipLibCheck (3 @types/node lib-internal errors), targz.ts Buffer→Uint8Array, and run.ts reporter compose(new spec()) (was passing the class — this also fixes the spec reporter output). - CI now gates check + build alongside the tests across the matrix. Verified: check/build exit 0, 15/15 tests pass, and `npm pack` rebuilds dist/ via prepack after `rm -rf dist`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 4 ++++ package.json | 3 ++- src/run.ts | 2 +- src/targz.ts | 4 ++-- tsconfig.json | 3 +++ 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87724c7..492ce7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,10 @@ jobs: cache: npm - name: Install dependencies run: npm ci + - name: Type-check + run: npm run check + - name: Build + run: npm run build # Tests run the TypeScript sources directly via Node's native type stripping (Node >= 22.18, # enforced by devEngines). They spawn short-lived node processes and bind loopback ports, so # no real Harper instance or loopback-pool setup is required. diff --git a/package.json b/package.json index 1c560cd..86abcc7 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "scripts": { "check": "tsc", "build": "tsc -p tsconfig.build.json", - "test": "node --test \"test/**/*.test.ts\"" + "test": "node --test \"test/**/*.test.ts\"", + "prepack": "npm run build" }, "engines": { "node": ">=20" diff --git a/src/run.ts b/src/run.ts index 4c996b2..d9d6bea 100755 --- a/src/run.ts +++ b/src/run.ts @@ -131,7 +131,7 @@ runner.on('test:fail', (data: any) => { } }); -runner.compose(spec).pipe(process.stdout); +runner.compose(new spec()).pipe(process.stdout); process.on('exit', () => { if (failedFiles.size > 0) { diff --git a/src/targz.ts b/src/targz.ts index 6cd5aef..ccb2590 100644 --- a/src/targz.ts +++ b/src/targz.ts @@ -7,11 +7,11 @@ import { createGzip } from 'node:zlib'; * @param dirPath path to directory to pack and compress */ export async function targz(dirPath: string): Promise { - const chunks: Buffer[] = []; + const chunks: Uint8Array[] = []; return new Promise((resolve, reject) => { pack(dirPath) .pipe(createGzip()) - .on('data', (chunk: Buffer) => chunks.push(chunk)) + .on('data', (chunk: Uint8Array) => chunks.push(chunk)) .on('end', () => { resolve(Buffer.concat(chunks).toString('base64')); }) diff --git a/tsconfig.json b/tsconfig.json index fd25f38..5a7c2e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,9 @@ // Allow JS files for mixed codebase "allowJs": true, + // Don't type-check dependency .d.ts files (avoids @types/node lib-internal mismatches) + "skipLibCheck": true, + // Type checking only by default; use `tsconfig.build.json` for emitting "noEmit": true, From 1c611563dd3cfb404a93ad758053f058e5d62532 Mon Sep 17 00:00:00 2001 From: Nathan Heskew Date: Fri, 12 Jun 2026 13:44:26 -0700 Subject: [PATCH 2/2] fix(targz): use stream.pipeline for error propagation + cleanup, add tests --- src/targz.ts | 14 ++++++-------- test/targz.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) create mode 100644 test/targz.test.ts diff --git a/src/targz.ts b/src/targz.ts index ccb2590..4b317eb 100644 --- a/src/targz.ts +++ b/src/targz.ts @@ -1,5 +1,6 @@ import { pack } from 'tar-fs'; import { createGzip } from 'node:zlib'; +import { pipeline } from 'node:stream/promises'; /** * Packs and compresses a directory into a base64-encoded tar.gz string. @@ -8,13 +9,10 @@ import { createGzip } from 'node:zlib'; */ export async function targz(dirPath: string): Promise { const chunks: Uint8Array[] = []; - return new Promise((resolve, reject) => { - pack(dirPath) - .pipe(createGzip()) - .on('data', (chunk: Uint8Array) => chunks.push(chunk)) - .on('end', () => { - resolve(Buffer.concat(chunks).toString('base64')); - }) - .on('error', reject); + // pipeline() propagates errors from any stage (e.g. pack() failing on a missing dir) and + // destroys the whole chain on failure, so the returned promise always settles. + await pipeline(pack(dirPath), createGzip(), async (source) => { + for await (const chunk of source) chunks.push(chunk as Uint8Array); }); + return Buffer.concat(chunks).toString('base64'); } diff --git a/test/targz.test.ts b/test/targz.test.ts new file mode 100644 index 0000000..cd4c918 --- /dev/null +++ b/test/targz.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import { ok, rejects } from 'node:assert'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { targz } from '../src/targz.ts'; + +test('targz packs a directory into a base64-encoded gzip', async () => { + const dir = mkdtempSync(join(tmpdir(), 'targz-')); + try { + writeFileSync(join(dir, 'hello.txt'), 'world'); + const result = await targz(dir); + ok(result.length > 0, 'should return a non-empty base64 string'); + const bytes = Buffer.from(result, 'base64'); + ok(bytes[0] === 0x1f && bytes[1] === 0x8b, 'should decode to a gzip stream (magic bytes)'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('targz rejects (does not hang) when the source directory is missing', async () => { + // Regression test: a source-stream error must reject the promise rather than hang. + await rejects(targz(join(tmpdir(), `targz-missing-${process.pid}-${Date.now()}`))); +});