claude-code-voc-intelligence-suite.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  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 qiniu = require('qiniu');
  7. const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
  8. const SUITE_DIR = path.join(PROJECT_ROOT, 'claude-code', 'claude-code-voc-intelligence');
  9. const MANIFEST_PATH = path.join(SUITE_DIR, 'skill-package-manifest.json');
  10. const DIST_DIR = path.join(PROJECT_ROOT, 'dist');
  11. const PACKAGE_ID = 'claude-code-voc-intelligence';
  12. const NPM_PACKAGE_NAME = '@vocmarket/voc-skill';
  13. const PACKAGE_ZIP = path.join(DIST_DIR, `${PACKAGE_ID}.zip`);
  14. const DIST_MANIFEST = path.join(DIST_DIR, `${PACKAGE_ID}-suite-manifest.json`);
  15. const TEMP_ROOT = path.join(os.tmpdir(), 'claude-code-voc-pkg', PACKAGE_ID);
  16. const TEST_ROOT = path.join(os.tmpdir(), 'claude-code-voc-pkg-test', PACKAGE_ID);
  17. const CDN_PREFIX = 'x/claude-code-skills/packages';
  18. const CDN_KEY = `${CDN_PREFIX}/${PACKAGE_ID}.zip`;
  19. const CDN_DOMAIN = 'https://repos.fmode.cn';
  20. const BUCKET = 'nova-repos';
  21. const REQUIRED_FILES = [
  22. 'SKILL.md',
  23. '.claude-plugin/plugin.json',
  24. '.mcp.json',
  25. 'bin/claude-voc.js',
  26. 'install.js',
  27. 'README.md',
  28. 'docs/customer-quickstart.md',
  29. 'docs/demo-runbook.md',
  30. 'docs/npm-packaging-notes.md',
  31. 'package.json',
  32. 'package-lock.json',
  33. 'skill-package-manifest.json',
  34. 'memory-templates/xiaohongshu-trend-profile.json',
  35. 'mcp/src/server.js',
  36. 'mcp/src/core/credentials.js',
  37. 'mcp/src/core/memory-store.js',
  38. 'mcp/src/core/result-envelope.js',
  39. 'mcp/src/features/xiaohongshu-trend/live-collector.js',
  40. 'mcp/src/features/xiaohongshu-trend/preference-memory.js',
  41. 'mcp/src/features/xiaohongshu-trend/report.js',
  42. 'mcp/src/features/xiaohongshu-trend/sample-data.js',
  43. 'mcp/src/features/douyin-trend/live-collector.js',
  44. 'mcp/src/features/douyin-trend/preference-memory.js',
  45. 'mcp/src/features/douyin-trend/report.js',
  46. 'mcp/src/features/douyin-trend/sample-data.js',
  47. 'mcp/src/features/voc-problem-deep-dive/deep-dive.js',
  48. 'mcp/src/providers/xiaohongshu-api.js',
  49. 'mcp/src/providers/douyin-api.js',
  50. 'mcp/src/tools/xiaohongshu-preference-update.js',
  51. 'mcp/src/tools/xiaohongshu-trend-run.js',
  52. 'mcp/src/tools/douyin-preference-update.js',
  53. 'mcp/src/tools/douyin-trend-run.js',
  54. 'mcp/src/tools/voc-problem-deep-dive-run.js',
  55. 'scripts/smoke-mcp.js',
  56. 'scripts/smoke-package.js',
  57. 'skills/xiaohongshu-trend-intelligence/SKILL.md',
  58. 'skills/douyin-trend-intelligence/SKILL.md',
  59. 'skills/voc-problem-deep-dive/SKILL.md',
  60. 'skills/xiaohongshu-trend-intelligence/references/user-workflow.md',
  61. 'skills/xiaohongshu-trend-intelligence/references/output-format.md',
  62. 'skills/xiaohongshu-trend-intelligence/references/live-mode.md'
  63. ];
  64. const EXCLUDED_DIRS = new Set([
  65. 'node_modules',
  66. 'memory',
  67. 'outputs',
  68. '.npm-cache-acceptance',
  69. '.tmp',
  70. '.git',
  71. '.claude'
  72. ]);
  73. const EXCLUDED_FILES = new Set([
  74. '.env',
  75. '.env.local',
  76. '.npmrc'
  77. ]);
  78. function parseArgs(argv) {
  79. const opts = {
  80. validate: false,
  81. build: false,
  82. testPackage: false,
  83. upload: false,
  84. help: false
  85. };
  86. for (const token of argv) {
  87. if (token === '--validate') opts.validate = true;
  88. else if (token === '--build') opts.build = true;
  89. else if (token === '--test-package') opts.testPackage = true;
  90. else if (token === '--upload') opts.upload = true;
  91. else if (token === '--all') {
  92. opts.validate = true;
  93. opts.build = true;
  94. opts.testPackage = true;
  95. } else if (token === '--help' || token === '-h') {
  96. opts.help = true;
  97. }
  98. }
  99. if (!opts.validate && !opts.build && !opts.testPackage && !opts.upload && !opts.help) {
  100. opts.validate = true;
  101. opts.build = true;
  102. opts.testPackage = true;
  103. }
  104. return opts;
  105. }
  106. function usage() {
  107. return [
  108. 'Usage:',
  109. ' node scripts/deploy/claude-code-voc-intelligence-suite.js',
  110. ' node scripts/deploy/claude-code-voc-intelligence-suite.js --validate',
  111. ' node scripts/deploy/claude-code-voc-intelligence-suite.js --build --test-package',
  112. ' node scripts/deploy/claude-code-voc-intelligence-suite.js --build --upload',
  113. '',
  114. 'Default mode is --validate --build --test-package without upload.',
  115. 'Upload requires QINIU_AK and QINIU_SK environment variables.'
  116. ].join('\n');
  117. }
  118. function ensureDir(dirPath) {
  119. fs.mkdirSync(dirPath, { recursive: true });
  120. }
  121. function readJson(filePath) {
  122. return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  123. }
  124. function copyDirRecursive(srcDir, destDir) {
  125. ensureDir(destDir);
  126. let count = 0;
  127. for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
  128. if (entry.isDirectory() && EXCLUDED_DIRS.has(entry.name)) continue;
  129. if (entry.isFile() && EXCLUDED_FILES.has(entry.name)) continue;
  130. const src = path.join(srcDir, entry.name);
  131. const dest = path.join(destDir, entry.name);
  132. if (entry.isDirectory()) {
  133. count += copyDirRecursive(src, dest);
  134. } else if (entry.isFile()) {
  135. ensureDir(path.dirname(dest));
  136. fs.copyFileSync(src, dest);
  137. count++;
  138. }
  139. }
  140. return count;
  141. }
  142. function run(command, args, cwd) {
  143. const useCmd = process.platform === 'win32' && command === 'npm';
  144. const executable = useCmd ? 'cmd.exe' : command;
  145. const finalArgs = useCmd ? ['/d', '/s', '/c', 'npm', ...args] : args;
  146. const child = spawnSync(executable, finalArgs, {
  147. cwd,
  148. encoding: 'utf8',
  149. maxBuffer: 1024 * 1024 * 100
  150. });
  151. if (child.stdout) process.stdout.write(child.stdout);
  152. if (child.stderr) process.stderr.write(child.stderr);
  153. if (child.status !== 0) {
  154. const detail = child.error ? `: ${child.error.message}` : '';
  155. throw new Error(`${command} ${args.join(' ')} failed with exit ${child.status}${detail}`);
  156. }
  157. return child;
  158. }
  159. function createZip(sourceDir, destinationPath) {
  160. if (process.platform === 'win32') {
  161. run('tar.exe', ['-a', '-c', '-f', destinationPath, '-C', sourceDir, '.'], PROJECT_ROOT);
  162. return;
  163. }
  164. run('zip', ['-q', '-r', destinationPath, '.'], sourceDir);
  165. }
  166. function listZipEntries(zipPath) {
  167. const result = process.platform === 'win32'
  168. ? run('tar.exe', ['-t', '-f', zipPath], PROJECT_ROOT)
  169. : run('unzip', ['-Z1', zipPath], PROJECT_ROOT);
  170. return result.stdout
  171. .split(/\r?\n/)
  172. .map(entry => entry.replace(/^\.\//, '').replace(/\\/g, '/'))
  173. .filter(Boolean);
  174. }
  175. function extractZip(zipPath, destinationDir) {
  176. if (process.platform === 'win32') {
  177. run('tar.exe', ['-x', '-f', zipPath, '-C', destinationDir], PROJECT_ROOT);
  178. return;
  179. }
  180. run('unzip', ['-q', zipPath, '-d', destinationDir], PROJECT_ROOT);
  181. }
  182. function validateSuite() {
  183. console.log('[validate] claude-code-voc-intelligence suite');
  184. const missing = [];
  185. for (const rel of REQUIRED_FILES) {
  186. if (!fs.existsSync(path.join(SUITE_DIR, rel))) missing.push(rel);
  187. }
  188. const packageJson = readJson(path.join(SUITE_DIR, 'package.json'));
  189. const pluginJson = readJson(path.join(SUITE_DIR, '.claude-plugin', 'plugin.json'));
  190. const manifest = readJson(MANIFEST_PATH);
  191. const mcpJson = readJson(path.join(SUITE_DIR, '.mcp.json'));
  192. const rootSkill = fs.readFileSync(path.join(SUITE_DIR, 'SKILL.md'), 'utf8');
  193. if (packageJson.name !== NPM_PACKAGE_NAME) missing.push('package.json:name mismatch');
  194. if (pluginJson.name !== 'voc-intelligence') missing.push('.claude-plugin/plugin.json:name mismatch');
  195. if (!/^description:\s*\S.+$/m.test(rootSkill)) missing.push('SKILL.md missing description');
  196. if (!manifest.skills.includes('xiaohongshu-trend-intelligence')) missing.push('manifest missing xiaohongshu-trend-intelligence');
  197. if (!mcpJson.mcpServers || !(mcpJson.mcpServers.voc || mcpJson.mcpServers['voc-intelligence'])) {
  198. missing.push('.mcp.json missing VOC MCP server');
  199. }
  200. if (missing.length) {
  201. throw new Error(`Suite validation failed:\n${missing.map(item => ` - ${item}`).join('\n')}`);
  202. }
  203. console.log(` ok: ${manifest.skills.length} skill, ${manifest.mcpTools.length} MCP tools`);
  204. }
  205. function buildSuite() {
  206. console.log('[build] dist/claude-code-voc-intelligence.zip');
  207. ensureDir(DIST_DIR);
  208. if (fs.existsSync(TEMP_ROOT)) fs.rmSync(TEMP_ROOT, { recursive: true, force: true });
  209. ensureDir(TEMP_ROOT);
  210. const copied = copyDirRecursive(SUITE_DIR, TEMP_ROOT);
  211. if (fs.existsSync(PACKAGE_ZIP)) fs.unlinkSync(PACKAGE_ZIP);
  212. createZip(TEMP_ROOT, PACKAGE_ZIP);
  213. const entries = new Set(listZipEntries(PACKAGE_ZIP));
  214. for (const required of ['install.js', '.claude-plugin/plugin.json']) {
  215. if (!entries.has(required)) {
  216. throw new Error(`Built zip is incomplete: missing ${required}`);
  217. }
  218. }
  219. writeDistManifest({ uploaded: false, copiedFiles: copied });
  220. const size = fs.statSync(PACKAGE_ZIP).size;
  221. console.log(` ok: ${PACKAGE_ZIP} (${(size / 1024).toFixed(0)} KB, ${copied} files)`);
  222. }
  223. function testPackage() {
  224. console.log('[test-package] unzip + install.js smoke');
  225. if (!fs.existsSync(PACKAGE_ZIP)) throw new Error(`Package zip not found: ${PACKAGE_ZIP}`);
  226. if (fs.existsSync(TEST_ROOT)) fs.rmSync(TEST_ROOT, { recursive: true, force: true });
  227. ensureDir(TEST_ROOT);
  228. extractZip(PACKAGE_ZIP, TEST_ROOT);
  229. run(process.execPath, ['install.js', '--smoke'], TEST_ROOT);
  230. console.log(' ok: extracted package can run install.js and sample/preference/MCP smoke');
  231. }
  232. function uploadFile(localFile, cdnKey) {
  233. return new Promise((resolve, reject) => {
  234. const accessKey = process.env.QINIU_AK;
  235. const secretKey = process.env.QINIU_SK;
  236. if (!accessKey || !secretKey) {
  237. return reject(new Error('Missing QINIU_AK or QINIU_SK environment variable'));
  238. }
  239. const mac = new qiniu.auth.digest.Mac(accessKey, secretKey);
  240. const putPolicy = new qiniu.rs.PutPolicy({ scope: `${BUCKET}:${cdnKey}`, expires: 3600 });
  241. const token = putPolicy.uploadToken(mac);
  242. const config = new qiniu.conf.Config({ zone: qiniu.zone.Zone_z2 });
  243. const formUploader = new qiniu.form_up.FormUploader(config);
  244. const putExtra = new qiniu.form_up.PutExtra();
  245. formUploader.putFile(token, cdnKey, localFile, putExtra, (err, body, info) => {
  246. if (err) return reject(err);
  247. if (info.statusCode === 200) return resolve({ key: body.key, url: `${CDN_DOMAIN}/${body.key}` });
  248. reject(new Error(`Upload failed: ${info.statusCode} ${JSON.stringify(body)}`));
  249. });
  250. });
  251. }
  252. async function uploadSuite() {
  253. console.log('[upload] qiniu nova-repos');
  254. if (!fs.existsSync(PACKAGE_ZIP)) throw new Error(`Package zip not found: ${PACKAGE_ZIP}`);
  255. const result = await uploadFile(PACKAGE_ZIP, CDN_KEY);
  256. writeDistManifest({ uploaded: true, downloadUrl: result.url });
  257. const urlsPath = path.join(DIST_DIR, 'download-urls.json');
  258. const urls = fs.existsSync(urlsPath) ? readJson(urlsPath) : {};
  259. urls[PACKAGE_ID] = result.url;
  260. fs.writeFileSync(urlsPath, `${JSON.stringify(urls, null, 2)}\n`, 'utf8');
  261. console.log(` ok: ${result.url}`);
  262. }
  263. function writeDistManifest(extra = {}) {
  264. const manifest = readJson(MANIFEST_PATH);
  265. const stat = fs.existsSync(PACKAGE_ZIP) ? fs.statSync(PACKAGE_ZIP) : undefined;
  266. const output = {
  267. name: PACKAGE_ID,
  268. version: manifest.version,
  269. generatedAt: new Date().toISOString(),
  270. packageZip: fs.existsSync(PACKAGE_ZIP) ? path.relative(PROJECT_ROOT, PACKAGE_ZIP).replace(/\\/g, '/') : '',
  271. packageZipBytes: stat ? stat.size : 0,
  272. plugin: manifest.plugin,
  273. skills: manifest.skills,
  274. mcpTools: manifest.mcpTools,
  275. installHint: manifest.installHint,
  276. installCommand: manifest.installCommand,
  277. npmInstallCommand: manifest.npmInstallCommand,
  278. npxInstallCommand: manifest.npxInstallCommand,
  279. workspaceInstallCommand: manifest.workspaceInstallCommand,
  280. workspaceInstallTarget: manifest.workspaceInstallTarget,
  281. buildCommand: 'node scripts/deploy/claude-code-voc-intelligence-suite.js --validate --build --test-package',
  282. uploadCommand: 'node scripts/deploy/claude-code-voc-intelligence-suite.js --validate --build --test-package --upload',
  283. ...extra
  284. };
  285. fs.writeFileSync(DIST_MANIFEST, `${JSON.stringify(output, null, 2)}\n`, 'utf8');
  286. console.log(`[manifest] ${path.relative(PROJECT_ROOT, DIST_MANIFEST)}`);
  287. }
  288. async function main() {
  289. const opts = parseArgs(process.argv.slice(2));
  290. if (opts.help) {
  291. console.log(usage());
  292. return;
  293. }
  294. if (opts.validate) validateSuite();
  295. if (opts.build) buildSuite();
  296. if (opts.testPackage) testPackage();
  297. if (opts.upload) await uploadSuite();
  298. if (!opts.build) writeDistManifest({ uploaded: false });
  299. console.log('[done] claude-code-voc-intelligence suite is ready');
  300. }
  301. main().catch(error => {
  302. console.error(`[error] ${error.message}`);
  303. process.exit(1);
  304. });