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