#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const os = require('os'); const { spawnSync } = require('child_process'); const PROJECT_ROOT = path.resolve(__dirname, '..', '..'); const SUITE_DIR = path.join(PROJECT_ROOT, 'claude-code', 'claude-code-voc-intelligence'); const RELEASE_NPM_DIR = path.join(PROJECT_ROOT, 'release', 'npm', 'claude-code-voc-intelligence'); const DIST_DIR = path.join(PROJECT_ROOT, 'dist', 'npm'); const DIST_MANIFEST = path.join(DIST_DIR, 'claude-code-voc-npm-package-manifest.json'); const PACKAGE_NAME = '@gangvy/claude-code-voc-intelligence'; const BIN_NAME = 'claude-voc'; const TEST_ROOT = path.join(os.tmpdir(), 'claude-code-voc-npm-test', String(process.pid)); function parseArgs(argv) { const opts = { pack: false, test: false, publish: false, dryRun: false, help: false }; for (const token of argv) { if (token === '--pack') opts.pack = true; else if (token === '--test') opts.test = true; else if (token === '--publish') opts.publish = true; else if (token === '--dry-run') opts.dryRun = true; else if (token === '--help' || token === '-h') opts.help = true; } if (!opts.pack && !opts.test && !opts.publish && !opts.help) { opts.pack = true; opts.test = true; } if (opts.publish) { opts.pack = true; opts.test = true; } return opts; } function usage() { return [ 'Usage:', ' node scripts/deploy/claude-code-voc-npm-package.js --pack --test', ' node scripts/deploy/claude-code-voc-npm-package.js --pack --test --publish', ' node scripts/deploy/claude-code-voc-npm-package.js --pack --dry-run', '', 'Publish requires npm login or a configured npm token.' ].join('\n'); } function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); } function parseJsonText(text, source = 'JSON') { return JSON.parse(String(text || '').replace(/^\uFEFF/, '')); } function readJson(filePath) { return parseJsonText(fs.readFileSync(filePath, 'utf8'), filePath); } function readEnvFileMaybe(filePath) { try { if (!filePath || !fs.existsSync(filePath)) return {}; const env = {}; const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''); for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith('#')) continue; const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/); if (!match) continue; let value = match[2].trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } env[match[1]] = value; } return env; } catch { return {}; } } function run(command, args, cwd, options = {}) { const useCmd = process.platform === 'win32' && command === 'npm'; const executable = useCmd ? 'cmd.exe' : command; const finalArgs = useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args; const child = spawnSync(executable, finalArgs, { cwd, encoding: 'utf8', stdio: options.capture ? 'pipe' : 'inherit', env: options.env || process.env, maxBuffer: 1024 * 1024 * 100 }); if (child.status !== 0) { if (options.capture && child.stdout) process.stdout.write(child.stdout); if (options.capture && child.stderr) process.stderr.write(child.stderr); const detail = child.error ? `: ${child.error.message}` : ''; throw new Error(`${command} ${args.join(' ')} failed with exit ${child.status}${detail}`); } return child; } function validatePackage() { console.log('[validate-npm] package metadata'); const packageJson = readJson(path.join(SUITE_DIR, 'package.json')); if (packageJson.name !== PACKAGE_NAME) { throw new Error(`package.json name should be ${PACKAGE_NAME}`); } if (!packageJson.bin || packageJson.bin[BIN_NAME] !== 'bin/claude-voc.js') { throw new Error(`package.json missing bin.${BIN_NAME}`); } const requiredFiles = [ 'bin/claude-voc.js', 'install.js', '.claude-plugin/plugin.json', '.mcp.json', 'skills/xiaohongshu-trend-intelligence/SKILL.md', 'mcp/src/server.js', 'docs/customer-quickstart.md', 'docs/demo-runbook.md' ]; const missing = requiredFiles.filter(rel => !fs.existsSync(path.join(SUITE_DIR, rel))); if (missing.length) { throw new Error(`missing files:\n${missing.map(item => ` - ${item}`).join('\n')}`); } console.log(` ok: ${packageJson.name}@${packageJson.version}`); return packageJson; } function packPackage({ dryRun = false } = {}) { console.log(dryRun ? '[npm-pack] dry run' : '[npm-pack] create tgz'); ensureDir(DIST_DIR); const args = ['pack', '--json', '--pack-destination', DIST_DIR]; if (dryRun) args.push('--dry-run'); const result = run('npm', args, SUITE_DIR, { capture: true }); const packed = parseJsonText(result.stdout || '[]', 'npm pack output')[0]; if (!packed) throw new Error('npm pack did not return package metadata'); const tarball = dryRun ? '' : path.join(DIST_DIR, packed.filename); if (!dryRun && !fs.existsSync(tarball)) { throw new Error(`tarball not found: ${tarball}`); } console.log(` ok: ${packed.filename || packed.name}`); return { filename: packed.filename, tarball, packageSize: packed.size || 0, unpackedSize: packed.unpackedSize || 0, entryCount: Array.isArray(packed.files) ? packed.files.length : 0 }; } function testTarball(tarball) { console.log('[npm-test] install tarball + run claude-voc install --smoke'); if (!tarball || !fs.existsSync(tarball)) throw new Error(`missing tarball: ${tarball}`); if (fs.existsSync(TEST_ROOT)) fs.rmSync(TEST_ROOT, { recursive: true, force: true }); ensureDir(TEST_ROOT); const appRoot = path.join(TEST_ROOT, 'app'); const pluginTarget = path.join(TEST_ROOT, 'claude-plugins', 'voc-intelligence'); ensureDir(appRoot); run('npm', ['install', tarball, '--ignore-scripts'], appRoot); run('npm', [ 'exec', '--prefix', appRoot, '--', BIN_NAME, 'install', '--target', pluginTarget, '--smoke' ], PROJECT_ROOT); console.log(' ok: npm-installed CLI can install and smoke-test the plugin'); return { appRoot, pluginTarget }; } function testNpxLikeTarball(tarball) { console.log('[npx-test] npm exec --package tarball + claude-voc install --smoke'); if (!tarball || !fs.existsSync(tarball)) throw new Error(`missing tarball: ${tarball}`); const pluginTarget = path.join(TEST_ROOT, 'npx-plugins', 'voc-intelligence'); if (fs.existsSync(pluginTarget)) fs.rmSync(pluginTarget, { recursive: true, force: true }); run('npm', [ 'exec', '--yes', '--package', tarball, '--', BIN_NAME, 'install', '--target', pluginTarget, '--smoke' ], PROJECT_ROOT); console.log(' ok: npx-style CLI can install and smoke-test the plugin'); return { pluginTarget }; } function testWorkspaceTarball(tarball) { console.log('[workspace-test] npm exec --package tarball + claude-voc workspace --smoke'); if (!tarball || !fs.existsSync(tarball)) throw new Error(`missing tarball: ${tarball}`); const workspaceRoot = path.join(TEST_ROOT, 'workspace-app'); if (fs.existsSync(workspaceRoot)) fs.rmSync(workspaceRoot, { recursive: true, force: true }); ensureDir(workspaceRoot); run('npm', [ 'exec', '--yes', '--package', tarball, '--', BIN_NAME, 'workspace', '--smoke' ], workspaceRoot); const pluginTarget = path.join(workspaceRoot, '.claude', 'plugins', 'voc-intelligence'); const pluginMcpConfig = path.join(pluginTarget, '.mcp.json'); const workspaceMcpConfig = path.join(workspaceRoot, '.mcp.json'); const workspaceSkill = path.join(workspaceRoot, '.claude', 'skills', 'xiaohongshu-trend-intelligence', 'SKILL.md'); if (!fs.existsSync(pluginMcpConfig)) { throw new Error(`workspace install missing plugin .mcp.json: ${pluginMcpConfig}`); } if (!fs.existsSync(workspaceMcpConfig)) { throw new Error(`workspace install missing project .mcp.json: ${workspaceMcpConfig}`); } if (!fs.existsSync(workspaceSkill)) { throw new Error(`workspace install missing project skill entry: ${workspaceSkill}`); } console.log(' ok: workspace CLI can install and smoke-test the plugin'); return { workspaceRoot, pluginTarget }; } function publishTarball(tarball) { console.log('[npm-publish] npm publish'); if (!tarball || !fs.existsSync(tarball)) throw new Error(`missing tarball: ${tarball}`); const env = { ...process.env }; const rootLocalEnv = readEnvFileMaybe(path.join(PROJECT_ROOT, '.env.local')); const suiteLocalEnv = readEnvFileMaybe(path.join(SUITE_DIR, '.env.local')); const releaseLocalEnv = readEnvFileMaybe(path.join(RELEASE_NPM_DIR, '.env.local')); const token = process.env.NPM_TOKEN || process.env.NODE_AUTH_TOKEN || rootLocalEnv.NPM_TOKEN || rootLocalEnv.NODE_AUTH_TOKEN || suiteLocalEnv.NPM_TOKEN || suiteLocalEnv.NODE_AUTH_TOKEN || releaseLocalEnv.NPM_TOKEN || releaseLocalEnv.NODE_AUTH_TOKEN || ''; if (token) { ensureDir(TEST_ROOT); const npmrcPath = path.join(TEST_ROOT, '.npmrc'); fs.writeFileSync(npmrcPath, `//registry.npmjs.org/:_authToken=${token}\n`, 'utf8'); env.NPM_CONFIG_USERCONFIG = npmrcPath; env.NODE_AUTH_TOKEN = token; } run('npm', ['publish', tarball, '--access', 'public'], PROJECT_ROOT, { env }); ensurePublicAccess(env); verifyPublishedPublic(); console.log(' ok: published and visible on npm registry'); } function ensurePublicAccess(env = process.env) { run('npm', ['access', 'set', 'status=public', PACKAGE_NAME, '--registry=https://registry.npmjs.org/'], PROJECT_ROOT, { env }); } function verifyPublishedPublic() { const version = readJson(path.join(SUITE_DIR, 'package.json')).version; const args = ['view', `${PACKAGE_NAME}@${version}`, 'version', '--registry=https://registry.npmjs.org/']; const publicEnv = { ...process.env }; delete publicEnv.NPM_TOKEN; delete publicEnv.NODE_AUTH_TOKEN; delete publicEnv.NPM_CONFIG_USERCONFIG; let lastError = ''; for (let attempt = 1; attempt <= 6; attempt++) { const child = spawnSync(process.platform === 'win32' ? 'cmd.exe' : 'npm', process.platform === 'win32' ? ['/d', '/s', '/c', 'npm', ...args] : args, { cwd: PROJECT_ROOT, encoding: 'utf8', stdio: 'pipe', env: publicEnv, maxBuffer: 1024 * 1024 * 10 }); if (child.status === 0 && String(child.stdout || '').trim() === version) { return true; } lastError = String(child.stderr || child.stdout || '').trim(); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10000); } throw new Error(`npm publish returned success, but registry verification failed for ${PACKAGE_NAME}@${version}: ${lastError}`); } function writeManifest({ packageJson, packed, tested, npxTested, workspaceTested, published }) { ensureDir(DIST_DIR); const existing = fs.existsSync(DIST_MANIFEST) ? readJson(DIST_MANIFEST) : {}; const preservePublished = existing.name === PACKAGE_NAME && existing.version === packageJson.version && existing.published === true; const manifest = { name: PACKAGE_NAME, version: packageJson.version, generatedAt: new Date().toISOString(), tarball: packed.tarball ? path.relative(PROJECT_ROOT, packed.tarball).replace(/\\/g, '/') : '', tarballBytes: packed.packageSize, unpackedBytes: packed.unpackedSize, entryCount: packed.entryCount, installCommands: [ `npm install -g ${PACKAGE_NAME}`, `${BIN_NAME} install`, `npx ${PACKAGE_NAME} install`, `npx ${PACKAGE_NAME} workspace --smoke` ], tested: Boolean(tested), npxTested: Boolean(npxTested), workspaceTested: Boolean(workspaceTested), published: Boolean(published) || preservePublished }; fs.writeFileSync(DIST_MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); console.log(`[manifest] ${path.relative(PROJECT_ROOT, DIST_MANIFEST)}`); } async function main() { const opts = parseArgs(process.argv.slice(2)); if (opts.help) { console.log(usage()); return; } const packageJson = validatePackage(); let packed = { filename: '', tarball: '', packageSize: 0, unpackedSize: 0, entryCount: 0 }; let tested; let npxTested; let workspaceTested; if (opts.pack) packed = packPackage({ dryRun: opts.dryRun }); if (opts.test && !opts.dryRun) { tested = testTarball(packed.tarball); npxTested = testNpxLikeTarball(packed.tarball); workspaceTested = testWorkspaceTarball(packed.tarball); } if (opts.publish && !opts.dryRun) publishTarball(packed.tarball); writeManifest({ packageJson, packed, tested, npxTested, workspaceTested, published: opts.publish && !opts.dryRun }); console.log('[done] npm package flow is ready'); } main().catch(error => { console.error(`[error] ${error.message}`); process.exit(1); });