diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f12052d3..e6c579710 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,75 +145,7 @@ jobs: - name: Prepare publish directories run: | - node - <<'NODE' - const fs = require('fs') - const path = require('path') - - const root = process.cwd() - const outRoot = path.join(root, '.publish') - const coreVersion = require('./packages/core/package.json').version - - fs.rmSync(outRoot, { recursive: true, force: true }) - fs.mkdirSync(outRoot, { recursive: true }) - - const copyRecursive = (from, to) => { - const stat = fs.statSync(from) - if (stat.isDirectory()) { - fs.mkdirSync(to, { recursive: true }) - for (const entry of fs.readdirSync(from)) { - copyRecursive(path.join(from, entry), path.join(to, entry)) - } - return - } - fs.mkdirSync(path.dirname(to), { recursive: true }) - fs.copyFileSync(from, to) - } - - const packages = [ - { dir: 'packages/core', include: ['dist', 'src', 'assets'], extraFiles: [] }, - { dir: 'packages/cli', include: ['bin', 'dist'], extraFiles: [] }, - { dir: 'packages/mcp', include: ['dist'], extraFiles: [] }, - { dir: 'packages/vue', include: ['dist'], extraFiles: ['README.md'] } - ] - - for (const pkg of packages) { - const srcDir = path.join(root, pkg.dir) - const destDir = path.join(outRoot, path.basename(pkg.dir)) - fs.mkdirSync(destDir, { recursive: true }) - - for (const rel of pkg.include) { - const from = path.join(srcDir, rel) - if (fs.existsSync(from)) copyRecursive(from, path.join(destDir, rel)) - } - - for (const rel of pkg.extraFiles) { - const from = path.join(srcDir, rel) - if (fs.existsSync(from)) copyRecursive(from, path.join(destDir, rel)) - } - - const pkgJsonPath = path.join(srcDir, 'package.json') - const json = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')) - for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { - if (!json[field]) continue - for (const [name, version] of Object.entries(json[field])) { - if (typeof version === 'string' && version.startsWith('workspace:')) { - json[field][name] = '^' + coreVersion - } - } - } - delete json.scripts - delete json.devDependencies - if (json.publishConfig) { - for (const [key, value] of Object.entries(json.publishConfig)) { - if (key === 'access' || key === 'provenance' || key === 'registry') continue - json[key] = value - } - delete json.publishConfig - } - fs.writeFileSync(path.join(destDir, 'package.json'), JSON.stringify(json, null, 2) + '\n') - console.log(`Prepared ${destDir}`) - } - NODE + bun tools/release-packages/src/prepare-publish-dirs.ts find .publish -maxdepth 3 -type f | sort - name: Pack packages for publish @@ -239,20 +171,7 @@ jobs: tar -tf .npm-packages/open-pencil-cli-*.tgz | sed -n '1,40p' || true tar -tf .npm-packages/open-pencil-mcp-*.tgz | sed -n '1,40p' || true tar -tf .npm-packages/open-pencil-vue-*.tgz | sed -n '1,60p' || true - node --input-type=module <<'NODE' - import fs from 'node:fs' - import { execFileSync } from 'node:child_process' - for (const tarball of fs.readdirSync('.npm-packages').filter((name) => name.endsWith('.tgz'))) { - const entries = new Set(execFileSync('tar', ['-tf', `.npm-packages/${tarball}`], { encoding: 'utf8' }).trim().split('\n')) - const packageJson = JSON.parse(execFileSync('tar', ['-xOf', `.npm-packages/${tarball}`, 'package/package.json'], { encoding: 'utf8' })) - const bin = packageJson.bin - const targets = typeof bin === 'string' ? { [packageJson.name]: bin } : bin || {} - for (const [name, target] of Object.entries(targets)) { - const entry = `package/${String(target).replace(/^\.\//, '')}` - if (!entries.has(entry)) throw new Error(`${tarball}: bin ${name} target missing from tarball: ${entry}`) - } - } - NODE + bun tools/release-packages/src/validate-tarballs.ts - name: Publish packages to npm run: | diff --git a/package.json b/package.json index cff13a9f2..dcd0f4ab1 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "test:unit": "bun test ./tests/engine", "test:coverage": "bun test --coverage ./tests/engine", "test:type-shapes": "bun scripts/type-shapes.ts", - "test:tools": "bun --cwd tools/pr-review-guidance test", + "test:tools": "bun tools/test.ts", "test:dupes": "jscpd packages/core/src packages/cli/src src --min-lines 5 --min-tokens 50 --format typescript --threshold 0", "test:packages": "bun scripts/check-package-metadata.ts && bun scripts/smoke-packages.ts", "build:packages": "bun --filter @open-pencil/core build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build", diff --git a/tools/pr-review-guidance/src/index.ts b/tools/pr-review-guidance/src/index.ts index 334ad7abe..283b86bdd 100644 --- a/tools/pr-review-guidance/src/index.ts +++ b/tools/pr-review-guidance/src/index.ts @@ -59,14 +59,19 @@ export function markdownTableCells(line: string): string[] { } export function normalizedCheckName(value: string): string { - return value.replace(/[^a-z0-9]+/gi, ' ').trim().toLowerCase() + return value + .replace(/[^a-z0-9]+/gi, ' ') + .trim() + .toLowerCase() } export function reviewGuidanceCheckName(line: string): string | null { const [rawCheckName = '', status = ''] = markdownTableCells(line) const checkName = rawCheckName.replace(/^\[ignored\]\s*/i, '') const normalizedName = normalizedCheckName(checkName) - const isRelevantCheck = REVIEW_GUIDANCE_PREFIXES.some((prefix) => normalizedName.startsWith(prefix)) + const isRelevantCheck = REVIEW_GUIDANCE_PREFIXES.some((prefix) => + normalizedName.startsWith(prefix) + ) const needsMaintainerAttention = /❌/u.test(status) || /\berror\b/i.test(status) if (!isRelevantCheck || !needsMaintainerAttention) return null @@ -167,7 +172,9 @@ export async function monitorPRReviewGuidance(options: MonitorOptions = {}): Pro const checks = reviewGuidanceChecks(context.text) if (checks.length === 0) { - log('CodeRabbit did not report a PR description/readability check that needs maintainer attention.') + log( + 'CodeRabbit did not report a PR description/readability check that needs maintainer attention.' + ) return } @@ -189,7 +196,9 @@ export async function monitorPRReviewGuidance(options: MonitorOptions = {}): Pro ) } -const isDirectRun = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false +const isDirectRun = process.argv[1] + ? import.meta.url === pathToFileURL(process.argv[1]).href + : false if (isDirectRun) { await monitorPRReviewGuidance() diff --git a/tools/pr-review-guidance/tests/index.test.ts b/tools/pr-review-guidance/tests/index.test.ts index de7792ced..56b434b6c 100644 --- a/tools/pr-review-guidance/tests/index.test.ts +++ b/tools/pr-review-guidance/tests/index.test.ts @@ -1,9 +1,8 @@ +import { describe, expect, test } from 'bun:test' import { mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, test } from 'bun:test' - import { eventContext, monitorPRReviewGuidance, diff --git a/tools/release-packages/package.json b/tools/release-packages/package.json new file mode 100644 index 000000000..8a1adf62b --- /dev/null +++ b/tools/release-packages/package.json @@ -0,0 +1,10 @@ +{ + "name": "@open-pencil/release-packages", + "private": true, + "type": "module", + "scripts": { + "prepare-publish-dirs": "bun src/prepare-publish-dirs.ts", + "validate-tarballs": "bun src/validate-tarballs.ts", + "test": "bun test tests" + } +} diff --git a/tools/release-packages/src/prepare-publish-dirs.ts b/tools/release-packages/src/prepare-publish-dirs.ts new file mode 100644 index 000000000..f6f388074 --- /dev/null +++ b/tools/release-packages/src/prepare-publish-dirs.ts @@ -0,0 +1,18 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import { DEFAULT_PACKAGES, preparePublishDirectories } from './publish-dirs' + +const root = process.cwd() +const corePackageJSON = JSON.parse( + await readFile(join(root, 'packages/core/package.json'), 'utf8') +) as { + version: string +} + +await preparePublishDirectories({ + coreVersion: corePackageJSON.version, + packages: DEFAULT_PACKAGES, + root, + log: (message) => process.stdout.write(`${message}\n`) +}) diff --git a/tools/release-packages/src/publish-dirs.ts b/tools/release-packages/src/publish-dirs.ts new file mode 100644 index 000000000..4bc4885b1 --- /dev/null +++ b/tools/release-packages/src/publish-dirs.ts @@ -0,0 +1,117 @@ +import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' + +interface PackagePublishConfig { + dir: string + extraFiles: string[] + include: string[] +} + +interface PreparePublishDirectoriesOptions { + coreVersion: string + packages: PackagePublishConfig[] + root: string + outRoot?: string + log?: (message: string) => void +} + +type PackageJSON = Record & { + dependencies?: Record + devDependencies?: Record + peerDependencies?: Record + publishConfig?: Record + scripts?: unknown +} + +const PACKAGE_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies'] as const +const PUBLISH_CONFIG_FIELDS = new Set(['access', 'provenance', 'registry']) + +export const DEFAULT_PACKAGES: PackagePublishConfig[] = [ + { dir: 'packages/core', include: ['dist', 'src', 'assets'], extraFiles: [] }, + { dir: 'packages/cli', include: ['bin', 'dist'], extraFiles: [] }, + { dir: 'packages/mcp', include: ['dist'], extraFiles: [] }, + { dir: 'packages/vue', include: ['dist'], extraFiles: ['README.md'] } +] + +async function exists(path: string) { + try { + await stat(path) + return true + } catch { + return false + } +} + +async function copyRecursive(from: string, to: string): Promise { + const sourceStat = await stat(from) + if (sourceStat.isDirectory()) { + await mkdir(to, { recursive: true }) + for (const entry of await readdir(from)) { + await copyRecursive(join(from, entry), join(to, entry)) + } + return + } + + await mkdir(dirname(to), { recursive: true }) + await copyFile(from, to) +} + +export function publishPackageJSON(source: PackageJSON, coreVersion: string): PackageJSON { + const json = structuredClone(source) + + for (const field of PACKAGE_FIELDS) { + const dependencies = json[field] + if (!dependencies) continue + for (const [name, version] of Object.entries(dependencies)) { + if (version.startsWith('workspace:')) dependencies[name] = `^${coreVersion}` + } + } + + delete json.scripts + delete json.devDependencies + + if (json.publishConfig) { + for (const [key, value] of Object.entries(json.publishConfig)) { + if (!PUBLISH_CONFIG_FIELDS.has(key)) json[key] = value + } + delete json.publishConfig + } + + return json +} + +export async function preparePublishDirectories( + options: PreparePublishDirectoriesOptions +): Promise { + const outRoot = options.outRoot ?? join(options.root, '.publish') + const log = options.log + + await rm(outRoot, { recursive: true, force: true }) + await mkdir(outRoot, { recursive: true }) + + for (const pkg of options.packages) { + const sourceDir = join(options.root, pkg.dir) + const destinationDir = join(outRoot, basename(pkg.dir)) + await mkdir(destinationDir, { recursive: true }) + + for (const relativePath of pkg.include) { + const from = join(sourceDir, relativePath) + if (await exists(from)) await copyRecursive(from, join(destinationDir, relativePath)) + } + + for (const relativePath of pkg.extraFiles) { + const from = join(sourceDir, relativePath) + if (await exists(from)) await copyRecursive(from, join(destinationDir, relativePath)) + } + + const packageJSON = JSON.parse( + await readFile(join(sourceDir, 'package.json'), 'utf8') + ) as PackageJSON + const publishJSON = publishPackageJSON(packageJSON, options.coreVersion) + await writeFile( + join(destinationDir, 'package.json'), + `${JSON.stringify(publishJSON, null, 2)}\n` + ) + log?.(`Prepared ${destinationDir}`) + } +} diff --git a/tools/release-packages/src/tarballs.ts b/tools/release-packages/src/tarballs.ts new file mode 100644 index 000000000..e97670693 --- /dev/null +++ b/tools/release-packages/src/tarballs.ts @@ -0,0 +1,47 @@ +import { execFile } from 'node:child_process' +import { readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +type PackageJSON = { + bin?: Record | string + name: string +} + +export function packageBinTargets(packageJSON: PackageJSON): Record { + if (typeof packageJSON.bin === 'string') return { [packageJSON.name]: packageJSON.bin } + return packageJSON.bin ?? {} +} + +export async function tarballEntries(tarballPath: string): Promise> { + const { stdout } = await execFileAsync('tar', ['-tf', tarballPath], { encoding: 'utf8' }) + return new Set(stdout.trim().split('\n').filter(Boolean)) +} + +export async function tarballPackageJSON(tarballPath: string): Promise { + const { stdout } = await execFileAsync('tar', ['-xOf', tarballPath, 'package/package.json'], { + encoding: 'utf8' + }) + return JSON.parse(stdout) as PackageJSON +} + +export async function validateTarballBinTargets(tarballPath: string): Promise { + const entries = await tarballEntries(tarballPath) + const packageJSON = await tarballPackageJSON(tarballPath) + + for (const [name, target] of Object.entries(packageBinTargets(packageJSON))) { + const entry = `package/${target.replace(/^\.\//, '')}` + if (!entries.has(entry)) { + throw new Error(`${tarballPath}: bin ${name} target missing from tarball: ${entry}`) + } + } +} + +export async function validatePackedTarballs(directory: string): Promise { + const tarballs = (await readdir(directory)).filter((name) => name.endsWith('.tgz')) + for (const tarball of tarballs) { + await validateTarballBinTargets(join(directory, tarball)) + } +} diff --git a/tools/release-packages/src/validate-tarballs.ts b/tools/release-packages/src/validate-tarballs.ts new file mode 100644 index 000000000..158499b2f --- /dev/null +++ b/tools/release-packages/src/validate-tarballs.ts @@ -0,0 +1,3 @@ +import { validatePackedTarballs } from './tarballs' + +await validatePackedTarballs('.npm-packages') diff --git a/tools/release-packages/tests/publish-dirs.test.ts b/tools/release-packages/tests/publish-dirs.test.ts new file mode 100644 index 000000000..6be9c8585 --- /dev/null +++ b/tools/release-packages/tests/publish-dirs.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { preparePublishDirectories, publishPackageJSON } from '../src/publish-dirs' + +async function fixtureRoot() { + const root = join(tmpdir(), `open-pencil-release-packages-${crypto.randomUUID()}`) + await mkdir(join(root, 'packages/example/dist'), { recursive: true }) + await writeFile(join(root, 'packages/example/dist/index.js'), 'export {}\n') + await writeFile( + join(root, 'packages/example/package.json'), + JSON.stringify( + { + name: '@open-pencil/example', + version: '1.0.0', + scripts: { build: 'tsdown' }, + dependencies: { '@open-pencil/core': 'workspace:*', zod: '^4.0.0' }, + devDependencies: { typescript: '^5.0.0' }, + publishConfig: { access: 'public', main: './dist/index.js', types: './dist/index.d.ts' } + }, + null, + 2 + ) + ) + return root +} + +describe('publishPackageJSON', () => { + test('rewrites workspace dependencies and strips private build fields', () => { + const json = publishPackageJSON( + { + name: '@open-pencil/example', + scripts: { build: 'tsdown' }, + dependencies: { '@open-pencil/core': 'workspace:*', zod: '^4.0.0' }, + devDependencies: { typescript: '^5.0.0' }, + publishConfig: { access: 'public', main: './dist/index.js' } + }, + '0.13.2' + ) + + expect(json).toEqual({ + name: '@open-pencil/example', + dependencies: { '@open-pencil/core': '^0.13.2', zod: '^4.0.0' }, + main: './dist/index.js' + }) + }) +}) + +describe('preparePublishDirectories', () => { + test('copies requested files and writes publish package metadata', async () => { + const root = await fixtureRoot() + const outRoot = join(root, '.publish') + + await preparePublishDirectories({ + coreVersion: '0.13.2', + outRoot, + packages: [{ dir: 'packages/example', include: ['dist'], extraFiles: [] }], + root + }) + + expect(await readFile(join(outRoot, 'example/dist/index.js'), 'utf8')).toBe('export {}\n') + expect(JSON.parse(await readFile(join(outRoot, 'example/package.json'), 'utf8'))).toEqual({ + name: '@open-pencil/example', + version: '1.0.0', + dependencies: { '@open-pencil/core': '^0.13.2', zod: '^4.0.0' }, + main: './dist/index.js', + types: './dist/index.d.ts' + }) + }) +}) diff --git a/tools/release-packages/tests/tarballs.test.ts b/tools/release-packages/tests/tarballs.test.ts new file mode 100644 index 000000000..e0b1bb246 --- /dev/null +++ b/tools/release-packages/tests/tarballs.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'bun:test' + +import { packageBinTargets } from '../src/tarballs' + +describe('packageBinTargets', () => { + test('normalizes string bin fields', () => { + expect(packageBinTargets({ name: '@open-pencil/cli', bin: './bin/openpencil.js' })).toEqual({ + '@open-pencil/cli': './bin/openpencil.js' + }) + }) + + test('keeps named bin fields', () => { + expect( + packageBinTargets({ name: '@open-pencil/cli', bin: { openpencil: './bin/openpencil.js' } }) + ).toEqual({ + openpencil: './bin/openpencil.js' + }) + }) +}) diff --git a/tools/test.ts b/tools/test.ts new file mode 100644 index 000000000..71d562df3 --- /dev/null +++ b/tools/test.ts @@ -0,0 +1,26 @@ +import { spawn } from 'node:child_process' +import { readdir, readFile } from 'node:fs/promises' +import { join } from 'node:path' + +interface PackageJSON { + scripts?: Record +} + +async function run(command: string, args: string[], cwd: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, stdio: 'inherit' }) + child.on('error', reject) + child.on('exit', (code) => { + if (code === 0) resolve() + else reject(new Error(`${command} ${args.join(' ')} failed in ${cwd}`)) + }) + }) +} + +for (const entry of await readdir('tools', { withFileTypes: true })) { + if (!entry.isDirectory()) continue + + const cwd = join('tools', entry.name) + const packageJSON = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8')) as PackageJSON + if (packageJSON.scripts?.test) await run('bun', ['test'], cwd) +}