claude-code-voc-npm-package.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. #!/usr/bin/env node
  2. const fs = require('fs');
  3. const path = require('path');
  4. const os = require('os');
  5. const { spawnSync } = require('child_process');
  6. const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
  7. const SUITE_DIR = path.join(PROJECT_ROOT, 'claude-code-voc-intelligence');
  8. const DIST_DIR = path.join(PROJECT_ROOT, 'dist', 'npm');
  9. const DIST_MANIFEST = path.join(DIST_DIR, 'claude-code-voc-npm-package-manifest.json');
  10. const PACKAGE_NAME = '@gangvy/claude-code-voc-intelligence';
  11. const BIN_NAME = 'claude-voc';
  12. const TEST_ROOT = path.join(os.tmpdir(), 'claude-code-voc-npm-test', String(process.pid));
  13. function parseArgs(argv) {
  14. const opts = {
  15. pack: false,
  16. test: false,
  17. publish: false,
  18. dryRun: false,
  19. help: false
  20. };
  21. for (const token of argv) {
  22. if (token === '--pack') opts.pack = true;
  23. else if (token === '--test') opts.test = true;
  24. else if (token === '--publish') opts.publish = true;
  25. else if (token === '--dry-run') opts.dryRun = true;
  26. else if (token === '--help' || token === '-h') opts.help = true;
  27. }
  28. if (!opts.pack && !opts.test && !opts.publish && !opts.help) {
  29. opts.pack = true;
  30. opts.test = true;
  31. }
  32. if (opts.publish) {
  33. opts.pack = true;
  34. opts.test = true;
  35. }
  36. return opts;
  37. }
  38. function usage() {
  39. return [
  40. 'Usage:',
  41. ' node scripts/deploy/claude-code-voc-npm-package.js --pack --test',
  42. ' node scripts/deploy/claude-code-voc-npm-package.js --pack --test --publish',
  43. ' node scripts/deploy/claude-code-voc-npm-package.js --pack --dry-run',
  44. '',
  45. 'Publish requires npm login or a configured npm token.'
  46. ].join('\n');
  47. }
  48. function ensureDir(dirPath) {
  49. fs.mkdirSync(dirPath, { recursive: true });
  50. }
  51. function parseJsonText(text, source = 'JSON') {
  52. return JSON.parse(String(text || '').replace(/^\uFEFF/, ''));
  53. }
  54. function readJson(filePath) {
  55. return parseJsonText(fs.readFileSync(filePath, 'utf8'), filePath);
  56. }
  57. function run(command, args, cwd, options = {}) {
  58. const useCmd = process.platform === 'win32' && command === 'npm';
  59. const executable = useCmd ? 'cmd.exe' : command;
  60. const finalArgs = useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args;
  61. const child = spawnSync(executable, finalArgs, {
  62. cwd,
  63. encoding: 'utf8',
  64. stdio: options.capture ? 'pipe' : 'inherit',
  65. env: options.env || process.env,
  66. maxBuffer: 1024 * 1024 * 100
  67. });
  68. if (child.status !== 0) {
  69. if (options.capture && child.stdout) process.stdout.write(child.stdout);
  70. if (options.capture && child.stderr) process.stderr.write(child.stderr);
  71. const detail = child.error ? `: ${child.error.message}` : '';
  72. throw new Error(`${command} ${args.join(' ')} failed with exit ${child.status}${detail}`);
  73. }
  74. return child;
  75. }
  76. function validatePackage() {
  77. console.log('[validate-npm] package metadata');
  78. const packageJson = readJson(path.join(SUITE_DIR, 'package.json'));
  79. if (packageJson.name !== PACKAGE_NAME) {
  80. throw new Error(`package.json name should be ${PACKAGE_NAME}`);
  81. }
  82. if (!packageJson.bin || packageJson.bin[BIN_NAME] !== 'bin/claude-voc.js') {
  83. throw new Error(`package.json missing bin.${BIN_NAME}`);
  84. }
  85. const requiredFiles = [
  86. 'bin/claude-voc.js',
  87. 'install.js',
  88. '.claude-plugin/plugin.json',
  89. '.mcp.json',
  90. 'skills/xiaohongshu-trend-intelligence/SKILL.md',
  91. 'mcp/src/server.js',
  92. 'docs/customer-quickstart.md',
  93. 'docs/demo-runbook.md'
  94. ];
  95. const missing = requiredFiles.filter(rel => !fs.existsSync(path.join(SUITE_DIR, rel)));
  96. if (missing.length) {
  97. throw new Error(`missing files:\n${missing.map(item => ` - ${item}`).join('\n')}`);
  98. }
  99. console.log(` ok: ${packageJson.name}@${packageJson.version}`);
  100. return packageJson;
  101. }
  102. function packPackage({ dryRun = false } = {}) {
  103. console.log(dryRun ? '[npm-pack] dry run' : '[npm-pack] create tgz');
  104. ensureDir(DIST_DIR);
  105. const args = ['pack', '--json', '--pack-destination', DIST_DIR];
  106. if (dryRun) args.push('--dry-run');
  107. const result = run('npm', args, SUITE_DIR, { capture: true });
  108. const packed = parseJsonText(result.stdout || '[]', 'npm pack output')[0];
  109. if (!packed) throw new Error('npm pack did not return package metadata');
  110. const tarball = dryRun ? '' : path.join(DIST_DIR, packed.filename);
  111. if (!dryRun && !fs.existsSync(tarball)) {
  112. throw new Error(`tarball not found: ${tarball}`);
  113. }
  114. console.log(` ok: ${packed.filename || packed.name}`);
  115. return {
  116. filename: packed.filename,
  117. tarball,
  118. packageSize: packed.size || 0,
  119. unpackedSize: packed.unpackedSize || 0,
  120. entryCount: Array.isArray(packed.files) ? packed.files.length : 0
  121. };
  122. }
  123. function testTarball(tarball) {
  124. console.log('[npm-test] install tarball + run claude-voc install --smoke');
  125. if (!tarball || !fs.existsSync(tarball)) throw new Error(`missing tarball: ${tarball}`);
  126. if (fs.existsSync(TEST_ROOT)) fs.rmSync(TEST_ROOT, { recursive: true, force: true });
  127. ensureDir(TEST_ROOT);
  128. const appRoot = path.join(TEST_ROOT, 'app');
  129. const pluginTarget = path.join(TEST_ROOT, 'claude-plugins', 'voc-intelligence');
  130. ensureDir(appRoot);
  131. run('npm', ['install', tarball, '--ignore-scripts'], appRoot);
  132. run('npm', [
  133. 'exec',
  134. '--prefix',
  135. appRoot,
  136. '--',
  137. BIN_NAME,
  138. 'install',
  139. '--target',
  140. pluginTarget,
  141. '--smoke'
  142. ], PROJECT_ROOT);
  143. console.log(' ok: npm-installed CLI can install and smoke-test the plugin');
  144. return { appRoot, pluginTarget };
  145. }
  146. function testNpxLikeTarball(tarball) {
  147. console.log('[npx-test] npm exec --package tarball + claude-voc install --smoke');
  148. if (!tarball || !fs.existsSync(tarball)) throw new Error(`missing tarball: ${tarball}`);
  149. const pluginTarget = path.join(TEST_ROOT, 'npx-plugins', 'voc-intelligence');
  150. if (fs.existsSync(pluginTarget)) fs.rmSync(pluginTarget, { recursive: true, force: true });
  151. run('npm', [
  152. 'exec',
  153. '--yes',
  154. '--package',
  155. tarball,
  156. '--',
  157. BIN_NAME,
  158. 'install',
  159. '--target',
  160. pluginTarget,
  161. '--smoke'
  162. ], PROJECT_ROOT);
  163. console.log(' ok: npx-style CLI can install and smoke-test the plugin');
  164. return { pluginTarget };
  165. }
  166. function publishTarball(tarball) {
  167. console.log('[npm-publish] npm publish');
  168. if (!tarball || !fs.existsSync(tarball)) throw new Error(`missing tarball: ${tarball}`);
  169. const env = { ...process.env };
  170. const token = process.env.NPM_TOKEN || process.env.NODE_AUTH_TOKEN || '';
  171. if (token) {
  172. ensureDir(TEST_ROOT);
  173. const npmrcPath = path.join(TEST_ROOT, '.npmrc');
  174. fs.writeFileSync(npmrcPath, `//registry.npmjs.org/:_authToken=${token}\n`, 'utf8');
  175. env.NPM_CONFIG_USERCONFIG = npmrcPath;
  176. env.NODE_AUTH_TOKEN = token;
  177. }
  178. run('npm', ['publish', tarball, '--access', 'public'], PROJECT_ROOT, { env });
  179. ensurePublicAccess(env);
  180. verifyPublishedPublic();
  181. console.log(' ok: published and visible on npm registry');
  182. }
  183. function ensurePublicAccess(env = process.env) {
  184. run('npm', ['access', 'set', 'status=public', PACKAGE_NAME, '--registry=https://registry.npmjs.org/'], PROJECT_ROOT, { env });
  185. }
  186. function verifyPublishedPublic() {
  187. const version = readJson(path.join(SUITE_DIR, 'package.json')).version;
  188. const args = ['view', `${PACKAGE_NAME}@${version}`, 'version', '--registry=https://registry.npmjs.org/'];
  189. const publicEnv = { ...process.env };
  190. delete publicEnv.NPM_TOKEN;
  191. delete publicEnv.NODE_AUTH_TOKEN;
  192. delete publicEnv.NPM_CONFIG_USERCONFIG;
  193. let lastError = '';
  194. for (let attempt = 1; attempt <= 6; attempt++) {
  195. const child = spawnSync(process.platform === 'win32' ? 'cmd.exe' : 'npm', process.platform === 'win32'
  196. ? ['/d', '/s', '/c', 'npm', ...args]
  197. : args, {
  198. cwd: PROJECT_ROOT,
  199. encoding: 'utf8',
  200. stdio: 'pipe',
  201. env: publicEnv,
  202. maxBuffer: 1024 * 1024 * 10
  203. });
  204. if (child.status === 0 && String(child.stdout || '').trim() === version) {
  205. return true;
  206. }
  207. lastError = String(child.stderr || child.stdout || '').trim();
  208. Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10000);
  209. }
  210. throw new Error(`npm publish returned success, but registry verification failed for ${PACKAGE_NAME}@${version}: ${lastError}`);
  211. }
  212. function writeManifest({ packageJson, packed, tested, npxTested, published }) {
  213. ensureDir(DIST_DIR);
  214. const existing = fs.existsSync(DIST_MANIFEST) ? readJson(DIST_MANIFEST) : {};
  215. const preservePublished = existing.name === PACKAGE_NAME &&
  216. existing.version === packageJson.version &&
  217. existing.published === true;
  218. const manifest = {
  219. name: PACKAGE_NAME,
  220. version: packageJson.version,
  221. generatedAt: new Date().toISOString(),
  222. tarball: packed.tarball ? path.relative(PROJECT_ROOT, packed.tarball).replace(/\\/g, '/') : '',
  223. tarballBytes: packed.packageSize,
  224. unpackedBytes: packed.unpackedSize,
  225. entryCount: packed.entryCount,
  226. installCommands: [
  227. `npm install -g ${PACKAGE_NAME}`,
  228. `${BIN_NAME} install`,
  229. `npx ${PACKAGE_NAME} install`
  230. ],
  231. tested: Boolean(tested),
  232. npxTested: Boolean(npxTested),
  233. published: Boolean(published) || preservePublished
  234. };
  235. fs.writeFileSync(DIST_MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
  236. console.log(`[manifest] ${path.relative(PROJECT_ROOT, DIST_MANIFEST)}`);
  237. }
  238. async function main() {
  239. const opts = parseArgs(process.argv.slice(2));
  240. if (opts.help) {
  241. console.log(usage());
  242. return;
  243. }
  244. const packageJson = validatePackage();
  245. let packed = { filename: '', tarball: '', packageSize: 0, unpackedSize: 0, entryCount: 0 };
  246. let tested;
  247. let npxTested;
  248. if (opts.pack) packed = packPackage({ dryRun: opts.dryRun });
  249. if (opts.test && !opts.dryRun) {
  250. tested = testTarball(packed.tarball);
  251. npxTested = testNpxLikeTarball(packed.tarball);
  252. }
  253. if (opts.publish && !opts.dryRun) publishTarball(packed.tarball);
  254. writeManifest({ packageJson, packed, tested, npxTested, published: opts.publish && !opts.dryRun });
  255. console.log('[done] npm package flow is ready');
  256. }
  257. main().catch(error => {
  258. console.error(`[error] ${error.message}`);
  259. process.exit(1);
  260. });