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

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