From 7689a4378236090c78020cc67b9851debf1a7e12 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 10 Oct 2026 05:47:15 +0200 Subject: [PATCH] fix(release): npmjs.com shows the English README, and the package metadata names what Codeman is npm takes the package's readme from an unsorted {README,README.*} glob at publish time, and with README.zh-CN.md beside README.md it kept picking the Chinese one, so npmjs.com rendered it. `npm run release` now runs scripts/npm-release.mjs, which moves README.zh-CN.md to a dot-name for the length of `changeset publish` and always puts it back (also on a failed or thrown publish, and after an interrupted earlier run). It lives in the publish command, not in release.yml, because changesets/action commits the working tree into its version PR. Verified with npm's own normalize: README.zh-CN.md before, README.md with the file aside, and the aside copy is never packed. package.json: the description names the CLIs and says self-hosted (the old "run 20 autonomous agents" was an internal performance target), and homepage points at getcodeman.com instead of repeating the repository link. Co-Authored-By: Claude Opus 5.5 (1M context) --- package.json | 6 +-- scripts/npm-release.mjs | 71 +++++++++++++++++++++++++++ test/npm-release.test.ts | 103 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 scripts/npm-release.mjs create mode 100644 test/npm-release.test.ts diff --git a/package.json b/package.json index 0a0094c8..1c0acd17 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "aicodeman", "version": "1.40.0", - "description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence", + "description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -42,7 +42,7 @@ "check:lockfile": "node scripts/check-lockfile-sync.mjs", "check:plugin": "node scripts/sync-plugin.mjs --check && claude plugin validate --strict plugins/codeman && claude plugin validate --strict .claude-plugin/marketplace.json", "knip": "npx --yes knip@latest --config config/knip.json", - "release": "changeset publish" + "release": "node scripts/npm-release.mjs" }, "prettier": { "singleQuote": true, @@ -171,7 +171,7 @@ "bugs": { "url": "https://github.com/Ark0N/Codeman/issues" }, - "homepage": "https://github.com/Ark0N/Codeman#readme", + "homepage": "https://getcodeman.com", "files": [ "dist", "scripts/postinstall.js", diff --git a/scripts/npm-release.mjs b/scripts/npm-release.mjs new file mode 100644 index 00000000..a27282c0 --- /dev/null +++ b/scripts/npm-release.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * @fileoverview `npm run release`: `changeset publish` with `README.zh-CN.md` moved aside. + * + * npmjs.com renders the package's top-level `readme`, and npm picks it at publish time: + * @npmcli/package-json's normalize globs `{README,README.*}` in the package root UNSORTED and + * keeps the first `.md` it sees. With `README.zh-CN.md` next to `README.md` that was the + * Chinese one on this machine and on CI, so npmjs.com showed the Chinese README for months. + * `files` cannot help: npm-packlist always includes every root `README.*`. + * + * Renaming the file would break every link to it, so for the length of the publish only it + * moves to a name npm does not treat as a readme (a leading dot), and is put back afterwards, + * whatever the publish did. The move happens HERE, inside the publish command, never as a + * step before `changesets/action` in release.yml: that action also runs the version path and + * commits the working tree into its version PR, which would commit the deletion. + * + * A previous run killed between the move and the restore leaves the aside copy behind; the + * next run puts it back first. `xterm-zerolag-input` (packages/) has only a README.md. + * + * node scripts/npm-release.mjs what the Release workflow runs (via `npm run release`) + */ +import { existsSync, renameSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const HIDDEN_README = 'README.zh-CN.md'; +export const ASIDE_NAME = '.README.zh-CN.md.release-aside'; + +/** + * Runs `publish` with `HIDDEN_README` moved aside in `root`, restoring it afterwards, also + * when `publish` fails or throws. Returns the exit code `publish` returned. + * + * @param {{ root: string, publish: () => number, log?: (msg: string) => void }} opts + * @returns {number} + */ +export function publishWithReadmeAside({ root, publish, log = (msg) => console.log(msg) }) { + const original = join(root, HIDDEN_README); + const aside = join(root, ASIDE_NAME); + if (existsSync(aside) && !existsSync(original)) { + renameSync(aside, original); + log(`npm-release: restored ${HIDDEN_README} left aside by an earlier run`); + } + const moved = existsSync(original); + if (moved) { + renameSync(original, aside); + log(`npm-release: ${HIDDEN_README} moved aside so npm picks README.md as the readme`); + } + try { + return publish(); + } finally { + if (moved) { + renameSync(aside, original); + log(`npm-release: ${HIDDEN_README} restored`); + } + } +} + +function runChangesetPublish() { + const result = spawnSync('changeset', ['publish'], { stdio: 'inherit', shell: process.platform === 'win32' }); + if (result.error) { + console.error(`npm-release: could not run changeset publish: ${result.error.message}`); + return 1; + } + return result.status ?? 1; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const root = join(fileURLToPath(new URL('.', import.meta.url)), '..'); + process.exitCode = publishWithReadmeAside({ root, publish: runChangesetPublish }); +} diff --git a/test/npm-release.test.ts b/test/npm-release.test.ts new file mode 100644 index 00000000..4b389368 --- /dev/null +++ b/test/npm-release.test.ts @@ -0,0 +1,103 @@ +/** + * @fileoverview `npm run release` moves README.zh-CN.md aside for the publish, so npmjs.com + * renders README.md, and always puts it back (scripts/npm-release.mjs). + * + * Port: N/A (no server). + */ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ASIDE_NAME, HIDDEN_README, publishWithReadmeAside } from '../scripts/npm-release.mjs'; + +const roots: string[] = []; +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'npm-release-')); + roots.push(root); + writeFileSync(join(root, 'README.md'), 'english\n'); + writeFileSync(join(root, HIDDEN_README), 'chinese\n'); + return root; +} +const quiet = () => {}; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('publishWithReadmeAside', () => { + it('hides the Chinese README during the publish and restores it after a success', () => { + const root = fixture(); + let seen: { zh: boolean; en: boolean } | null = null; + const code = publishWithReadmeAside({ + root, + log: quiet, + publish: () => { + seen = { zh: existsSync(join(root, HIDDEN_README)), en: existsSync(join(root, 'README.md')) }; + return 0; + }, + }); + expect(code).toBe(0); + expect(seen).toEqual({ zh: false, en: true }); + expect(readFileSync(join(root, HIDDEN_README), 'utf8')).toBe('chinese\n'); + expect(existsSync(join(root, ASIDE_NAME))).toBe(false); + }); + + it('the aside name is not one npm treats as a readme', () => { + // npm-packlist keeps every root /readme{,.*}/i and normalize globs {README,README.*}. + expect(/^readme/i.test(ASIDE_NAME)).toBe(false); + }); + + it('restores it and passes the exit code through when the publish fails', () => { + const root = fixture(); + const code = publishWithReadmeAside({ root, log: quiet, publish: () => 1 }); + expect(code).toBe(1); + expect(readFileSync(join(root, HIDDEN_README), 'utf8')).toBe('chinese\n'); + expect(existsSync(join(root, ASIDE_NAME))).toBe(false); + }); + + it('restores it when the publish throws', () => { + const root = fixture(); + expect(() => + publishWithReadmeAside({ + root, + log: quiet, + publish: () => { + throw new Error('boom'); + }, + }) + ).toThrow('boom'); + expect(readFileSync(join(root, HIDDEN_README), 'utf8')).toBe('chinese\n'); + expect(existsSync(join(root, ASIDE_NAME))).toBe(false); + }); + + it('first puts back a copy an interrupted earlier run left aside', () => { + const root = fixture(); + rmSync(join(root, HIDDEN_README)); + writeFileSync(join(root, ASIDE_NAME), 'chinese\n'); + let zhDuring = true; + publishWithReadmeAside({ + root, + log: quiet, + publish: () => { + zhDuring = existsSync(join(root, HIDDEN_README)); + return 0; + }, + }); + expect(zhDuring).toBe(false); + expect(readFileSync(join(root, HIDDEN_README), 'utf8')).toBe('chinese\n'); + expect(existsSync(join(root, ASIDE_NAME))).toBe(false); + }); + + it('publishes normally when there is no Chinese README', () => { + const root = fixture(); + rmSync(join(root, HIDDEN_README)); + const code = publishWithReadmeAside({ root, log: quiet, publish: () => 0 }); + expect(code).toBe(0); + expect(existsSync(join(root, HIDDEN_README))).toBe(false); + }); + + it('package.json routes npm run release through the wrapper', () => { + const pkg = JSON.parse(readFileSync(join(import.meta.dirname, '..', 'package.json'), 'utf8')); + expect(pkg.scripts.release).toBe('node scripts/npm-release.mjs'); + }); +});