@lyra-ai/toolkit 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hook.mjs CHANGED
@@ -1,31 +1,45 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * hook.mjs — Claude Code SessionEnd hook 插件入口。
4
- * 职责:detached spawn flush.mjsexit(0)。
5
- * 不读 stdin、不开网、不写文件。<10ms,远在 1.5s 超时内。
6
- *
7
- * Claude Code 通过 hooks.json (exec form) 调用:
8
- * command: "node"
9
- * args: ["${CLAUDE_PLUGIN_ROOT}/hook.mjs"]
10
- *
11
- * ${CLAUDE_PLUGIN_ROOT} 被 Claude Code 替换为插件目录路径,
12
- * 同时 export 为环境变量 process.env.CLAUDE_PLUGIN_ROOT。
3
+ * hook.mjs — Claude Code SessionEnd hook 入口。
4
+ * 职责:读 stdin JSON取 session_id + transcript_path
5
+ * detached spawn `flush.mjs current <sid> <path>` → exit 0。
6
+ * 无字段或坏 JSON → 不 spawn,exit 0(漏报可接受,下次 SessionEnd 补)。
7
+ * TOOLKIT_HOOK_DRY=1 跳过 spawn(用于测试 exit 行为)
8
+ * 1s 安全超时(stdin 不结束时 hook 仍 <1.5s SessionEnd 预算返回)。
13
9
  */
14
10
  import { spawn } from 'node:child_process';
15
11
  import { fileURLToPath } from 'node:url';
16
12
  import path from 'node:path';
17
13
 
18
14
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
- const flushScript = path.join(__dirname, 'flush.mjs');
15
+ const flushScript = process.env.TOOLKIT_FLUSH_BIN ?? path.join(__dirname, 'flush.mjs');
20
16
 
21
- try {
22
- spawn(process.execPath, [flushScript], {
23
- detached: true,
24
- stdio: 'ignore',
25
- env: { ...process.env, TOOLKIT_AUTO: '1' },
26
- }).unref();
27
- } catch {
28
- // 永不抛错:flusher 没起来,下次 SessionEnd 或手动 toolkit flush 补传
29
- }
17
+ let payload = '';
18
+ process.stdin.setEncoding('utf8');
19
+ process.stdin.on('data', (c) => { payload += c; });
20
+ process.stdin.on('end', () => {
21
+ let sid = null;
22
+ let tpath = null;
23
+ try {
24
+ const j = JSON.parse(payload);
25
+ sid = j.session_id;
26
+ tpath = j.transcript_path;
27
+ } catch {
28
+ // 坏 JSON / 空 stdin:不 spawn,exit 0
29
+ }
30
+ if (sid && tpath && !process.env.TOOLKIT_HOOK_DRY) {
31
+ try {
32
+ spawn(process.execPath, [flushScript, 'current', sid, tpath], {
33
+ detached: true,
34
+ stdio: 'ignore',
35
+ env: { ...process.env, TOOLKIT_AUTO: '1' },
36
+ }).unref();
37
+ } catch {
38
+ // 永不抛:漏报可接受,下次 SessionEnd 或手动 toolkit flush 补传
39
+ }
40
+ }
41
+ process.exit(0);
42
+ });
30
43
 
31
- process.exit(0);
44
+ // stdin 不结束的安全网(hook 必 <1.5s 返回)
45
+ setTimeout(() => process.exit(0), 1000).unref();
package/src/install.mjs CHANGED
@@ -1,20 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * install.mjs — `toolkit install claudecode --registry <url>`
4
- * 创建 Claude Code 插件(~/.claude/plugins/toolkit/)+ 配置(~/.toolkit/)+ 启用。
3
+ * install.mjs — `toolkit install --registry <url>`
4
+ *
5
+ * 自动检测本机 AI Coding Agent:目前识别 Claude Code(~/.claude),
6
+ * 命中即用其官方「Skills 目录 plugin」机制(@skills-dir)注册:
7
+ * ~/.claude/skills/toolkit/ ← 含 .claude-plugin/plugin.json + hooks/hooks.json
8
+ * + hook.mjs + flush.mjs + shared/
9
+ *
10
+ * 个人范围的 @skills-dir plugin 在每个项目自动加载、无需市场、无需安装步骤、
11
+ * 无需 trust 弹窗,且 defaultEnabled 默认 true —— 因此**不写 settings.json 的
12
+ * enabledPlugins**(那对 @skills-dir 既不需要、又会被用户切供应商时冲掉)。
13
+ *
14
+ * 迁移:若检测到旧版残留(裸目录 ~/.claude/plugins/toolkit 或 settings.json 里
15
+ * 失效的 toolkit@local 条目),一并清理。
16
+ *
17
+ * 配置走 ~/.toolkit/(config.json + backlog.json + logs/)。
5
18
  */
6
19
  import fs from 'node:fs';
7
20
  import path from 'node:path';
8
21
  import os from 'node:os';
9
22
  import { randomUUID } from 'node:crypto';
10
23
  import { fileURLToPath } from 'node:url';
11
- import { load as loadConfig, save as saveConfig, TOOLKIT_DIR, UPLOADED_PATH, LOG_DIR } from './shared/config.mjs';
24
+ import { load as loadConfig, save as saveConfig, LOG_DIR } from './shared/config.mjs';
12
25
 
13
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
27
  const HOME = os.homedir();
15
- const PLUGIN_DIR = path.join(HOME, '.claude', 'plugins', 'toolkit');
28
+ const PLUGIN_DIR = path.join(HOME, '.claude', 'skills', 'toolkit');
29
+ const OLD_PLUGIN_DIR = path.join(HOME, '.claude', 'plugins', 'toolkit');
16
30
  const SETTINGS_PATH = path.join(HOME, '.claude', 'settings.json');
17
- const PLUGIN_KEY = 'toolkit@local';
31
+ const OLD_PLUGIN_KEY = 'toolkit@local';
18
32
 
19
33
  const PLUGIN_JSON = {
20
34
  name: 'toolkit',
@@ -35,21 +49,51 @@ const HOOKS_JSON = {
35
49
  },
36
50
  };
37
51
 
38
- export function install(registry, opts = {}) {
39
- const agent = opts.agent || 'claudecode';
40
- // 1. 检测 Claude Code
41
- // (后续 --agent qoder 时,检测 ~/.qoder)
52
+ function rmrf(dir) {
53
+ if (!fs.existsSync(dir)) return;
54
+ for (const entry of fs.readdirSync(dir)) {
55
+ const p = path.join(dir, entry);
56
+ try {
57
+ if (fs.statSync(p).isDirectory()) rmrf(p);
58
+ else fs.unlinkSync(p);
59
+ } catch {}
60
+ }
61
+ try { fs.rmdirSync(dir); } catch {}
62
+ }
63
+
64
+ /** 清理旧版残留:裸目录 plugins/toolkit + settings.json 里失效的 toolkit@local。 */
65
+ function cleanupLegacy() {
66
+ let cleaned = [];
67
+ if (fs.existsSync(OLD_PLUGIN_DIR)) { rmrf(OLD_PLUGIN_DIR); cleaned.push(OLD_PLUGIN_DIR); }
68
+ try {
69
+ const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
70
+ if (settings.enabledPlugins && settings.enabledPlugins[OLD_PLUGIN_KEY] !== undefined) {
71
+ delete settings.enabledPlugins[OLD_PLUGIN_KEY];
72
+ fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
73
+ cleaned.push(`${OLD_PLUGIN_KEY} (settings.json)`);
74
+ }
75
+ } catch {}
76
+ return cleaned;
77
+ }
78
+
79
+ export async function install(registry) {
80
+ // 1. 自动检测本机 AI Coding Agent:目前仅识别 Claude Code(~/.claude)。
81
+ // 后续可扩展其它 agent(如 ~/.qoder)。
42
82
  const claudeDir = path.join(HOME, '.claude');
43
83
  if (!fs.existsSync(claudeDir)) {
44
- console.error('ERROR: ~/.claude not found. Is Claude Code installed?');
84
+ console.error('ERROR: 未检测到任何已支持的 AI Coding Agent。');
85
+ console.error(' 当前支持: Claude Code (需存在 ~/.claude 目录)');
45
86
  process.exit(1);
46
87
  }
47
88
 
48
- // 2. 创建插件目录
89
+ // 2. 清理旧版残留(若有,静默)
90
+ cleanupLegacy();
91
+
92
+ // 3. 创建 @skills-dir 插件目录结构
49
93
  fs.mkdirSync(path.join(PLUGIN_DIR, '.claude-plugin'), { recursive: true });
50
94
  fs.mkdirSync(path.join(PLUGIN_DIR, 'hooks'), { recursive: true });
51
95
 
52
- // 3. 写插件文件
96
+ // 4. 写插件清单 + hooks
53
97
  fs.writeFileSync(
54
98
  path.join(PLUGIN_DIR, '.claude-plugin', 'plugin.json'),
55
99
  JSON.stringify(PLUGIN_JSON, null, 2)
@@ -59,39 +103,35 @@ export function install(registry, opts = {}) {
59
103
  JSON.stringify(HOOKS_JSON, null, 2)
60
104
  );
61
105
 
62
- // 4. 复制 hook.mjs + flush.mjs 到插件目录(自包含)
106
+ // 5. 复制 hook.mjs + flush.mjs + shared/ 到插件目录(自包含)
63
107
  const srcDir = __dirname;
64
108
  fs.copyFileSync(path.join(srcDir, 'hook.mjs'), path.join(PLUGIN_DIR, 'hook.mjs'));
65
109
  fs.copyFileSync(path.join(srcDir, 'flush.mjs'), path.join(PLUGIN_DIR, 'flush.mjs'));
110
+ const sharedSrc = path.join(srcDir, 'shared');
111
+ const sharedDst = path.join(PLUGIN_DIR, 'shared');
112
+ fs.mkdirSync(sharedDst, { recursive: true });
113
+ for (const f of fs.readdirSync(sharedSrc)) {
114
+ if (f.endsWith('.mjs')) fs.copyFileSync(path.join(sharedSrc, f), path.join(sharedDst, f));
115
+ }
66
116
 
67
- // 5. 创建 ~/.toolkit/ 配置(走 shared/config.mjs)
117
+ // 6. 创建 ~/.toolkit/ 配置(走 shared/config.mjs)
68
118
  fs.mkdirSync(LOG_DIR, { recursive: true });
69
119
  const config = loadConfig();
70
120
  if (!config.uid) config.uid = randomUUID();
71
121
  config.registry = registry.replace(/\/$/, '');
72
122
  config.contexts = { metrics: '/ai-metrics', skills: '/skillhub' };
73
- config.installed_at = new Date().toISOString();
74
123
  saveConfig(config);
75
124
 
76
- // 6. uploaded.json 初始化(如果不存在)
77
- if (!fs.existsSync(UPLOADED_PATH)) {
78
- fs.writeFileSync(UPLOADED_PATH, JSON.stringify({ uploaded_sids: [], last_flush: null }, null, 2));
79
- }
125
+ // installed_at 的唯一来源是 backlog.json(下一步 ensureBacklog 写入),
126
+ // config.json 不再承载该字段(单一真相源)
127
+
128
+ // 7. backlog.json 初始化(installed_at = now;若已存在则保留)
129
+ const { ensureBacklog } = await import('./shared/backlog.mjs');
130
+ ensureBacklog();
80
131
 
81
- // 7. 启用插件(settings.json)
82
- let settings = {};
83
- try { settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
84
- if (!settings.enabledPlugins) settings.enabledPlugins = {};
85
- settings.enabledPlugins[PLUGIN_KEY] = true;
86
- fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
132
+ // 注:不写 settings.json 的 enabledPlugins。@skills-dir plugin 默认启用,
133
+ // enabledPlugins 反而会被用户切供应商时连累冲掉。
87
134
 
88
- // 8. 输出
89
- console.log('✅ 安装完成。\n');
90
- console.log(` 插件目录: ${PLUGIN_DIR}`);
91
- console.log(` 配置文件: ${path.join(TOOLKIT_DIR, 'config.json')}`);
92
- console.log(` 身份 UID: ${config.uid.slice(0, 12)}...`);
93
- console.log(` 平台地址: ${config.registry}${config.contexts.metrics}/`);
94
- console.log(`\n Claude Code 会话结束后,数据自动上报到上述地址。`);
95
- console.log(` 手动触发: toolkit flush`);
96
- console.log(` 查看状态: toolkit status`);
135
+ // 8. 输出(极简:只报成败)
136
+ console.log('✅ 安装完成');
97
137
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * shared/backlog.mjs — 上传 backlog 状态模块
3
+ *
4
+ * 管理 toolkit 客户端唯一的上传状态文件:~/.toolkit/backlog.json
5
+ *
6
+ * 结构:
7
+ * {
8
+ * installed_at: string|null, // ISO,首次创建时间(安装边界 / drain 起点)
9
+ * last_sweep: string|null, // ISO,上次 sweep 时间(可选)
10
+ * cursors: { // 每个 project-dir 的 drain 游标
11
+ * [projectDir]: { mtime:number, name:string }
12
+ * }
13
+ * }
14
+ *
15
+ * 游标语义(逆时间戳 drain):
16
+ * - 每个项目维护一个「已处理到哪里」的边界 {mtime, name}
17
+ * - drain 按时间戳降序遍历,处理完一个文件就把游标推进到该文件的 {mtime, name}
18
+ * - compareKey 为升序(older = 更小);待处理 = 比游标更小
19
+ * - 初始游标 = topCursor(installedMs) = {mtime:installedMs, name:''}
20
+ * 令 backlog 内所有文件(mtime ≤ installed)都更小 = 都待处理
21
+ */
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { TOOLKIT_DIR } from './config.mjs';
25
+
26
+ export const BACKLOG_PATH = path.join(TOOLKIT_DIR, 'backlog.json');
27
+
28
+ const DEFAULT_STATE = () => ({ installed_at: null, last_sweep: null, cursors: {} });
29
+
30
+ /**
31
+ * 读取整个 backlog 状态。文件缺失或坏 JSON 返回默认结构。
32
+ */
33
+ export function loadBacklog() {
34
+ try {
35
+ const raw = JSON.parse(fs.readFileSync(BACKLOG_PATH, 'utf-8'));
36
+ return {
37
+ installed_at: raw.installed_at ?? null,
38
+ last_sweep: raw.last_sweep ?? null,
39
+ cursors: raw.cursors && typeof raw.cursors === 'object' ? raw.cursors : {},
40
+ };
41
+ } catch {
42
+ return DEFAULT_STATE();
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 原子写入整个 backlog 状态(同盘 tmp + rename)。
48
+ * 失败静默:调用方内存游标仍推进,下次幂等重算。
49
+ *
50
+ * @param {object} state 待写入的 backlog 状态
51
+ * @param {(msg:string)=>void} [logFn] 写盘失败时用于告警的日志函数,
52
+ * 默认 console.warn。仍不抛出。
53
+ */
54
+ export function saveBacklog(state, logFn = (m) => console.warn(m)) {
55
+ try {
56
+ fs.mkdirSync(TOOLKIT_DIR, { recursive: true });
57
+ const tmp = BACKLOG_PATH + '.tmp';
58
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
59
+ fs.renameSync(tmp, BACKLOG_PATH);
60
+ } catch (e) {
61
+ // 调用方决定;内存游标仍推进,下次幂等重算。仅告警,不抛出。
62
+ logFn(`WARN: saveBacklog failed: ${e && e.message ? e.message : e}`);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 首次运行迁移:若 backlog.json 不存在或无 installed_at,
68
+ * 创建并落盘 installed_at = now。返回当前状态(可能已被前次调用初始化)。
69
+ */
70
+ export function ensureBacklog() {
71
+ const st = loadBacklog();
72
+ if (!st.installed_at) {
73
+ st.installed_at = new Date().toISOString();
74
+ saveBacklog(st);
75
+ }
76
+ return st;
77
+ }
78
+
79
+ /**
80
+ * 升序比较两个 {mtime, name} 键:older 更小。
81
+ * mtime 数值升序优先;mtime 相同时按 name 字典序。
82
+ */
83
+ export function compareKey(a, b) {
84
+ if (a.mtime !== b.mtime) return a.mtime - b.mtime;
85
+ if (a.name < b.name) return -1;
86
+ if (a.name > b.name) return 1;
87
+ return 0;
88
+ }
89
+
90
+ /**
91
+ * 初始游标 = 安装边界(ms)。所有 backlog 文件(mtime ≤ installed)
92
+ * 都更小 = 都待处理。name:'' 保证同 mtime 的文件名都比游标大。
93
+ */
94
+ export function topCursor(installedMs) {
95
+ return { mtime: installedMs, name: '' };
96
+ }
@@ -123,3 +123,20 @@ export function unsetZentao(key) {
123
123
  /** 导出键名映射(给 zentao 迁移用)。 */
124
124
  export { ZENTAO_KEY_MAP };
125
125
 
126
+ // ── upload 段(给 flush 用)──────────────────────────
127
+ export const UPLOAD_DEFAULTS = Object.freeze({
128
+ sweep_interval_sec: 600,
129
+ chunk_max_files: 20,
130
+ chunk_max_bytes: 30 * 1024 * 1024,
131
+ pacing_ms: 200,
132
+ settle_ms: 500,
133
+ settle_max_attempts: 3,
134
+ http_timeout_sec: 30,
135
+ });
136
+
137
+ /** 读 upload 段,缺省走 UPLOAD_DEFAULTS。 */
138
+ export function getUploadConfig() {
139
+ const u = load().upload || {};
140
+ return { ...UPLOAD_DEFAULTS, ...u };
141
+ }
142
+
@@ -0,0 +1,29 @@
1
+ /** POST zip 到平台,带指数退避重试。返回结果对象,不抛。 */
2
+ export async function postZip(url, zipBuf, uid, opts = {}) {
3
+ const timeoutSec = opts.timeoutSec ?? 30;
4
+ const delays = opts.delays ?? [2000, 4000, 8000];
5
+ for (let attempt = 0; attempt <= delays.length; attempt++) {
6
+ try {
7
+ const controller = new AbortController();
8
+ const t = setTimeout(() => controller.abort(), timeoutSec * 1000);
9
+ const res = await fetch(url, {
10
+ method: 'POST',
11
+ headers: { 'Content-Type': 'application/zip', 'X-Uid': uid },
12
+ body: zipBuf,
13
+ signal: controller.signal,
14
+ });
15
+ clearTimeout(t);
16
+ if (res.ok) return { ok: true, status: res.status, body: await res.json().catch(() => ({})) };
17
+ if (res.status >= 400 && res.status < 500 && res.status !== 429)
18
+ return { ok: false, status: res.status, error: `HTTP ${res.status}` };
19
+ throw new Error(`HTTP ${res.status}`);
20
+ } catch (e) {
21
+ if (attempt < delays.length) {
22
+ const jitter = (Math.random() - 0.5) * delays[attempt] * 0.5;
23
+ await new Promise(r => setTimeout(r, Math.max(500, delays[attempt] + jitter)));
24
+ } else {
25
+ return { ok: false, status: 0, error: e.message };
26
+ }
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * 探测 pid 是否存活。`process.kill(pid, 0)` 在 Windows/macOS/Linux 均适用。
5
+ * @param {number} pid
6
+ * @returns {boolean}
7
+ */
8
+ function pidAlive(pid) {
9
+ try {
10
+ process.kill(pid, 0);
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ /**
18
+ * 判断锁文件是否为僵尸:
19
+ * - 读取/解析失败 → 视为僵尸(可安全接管)
20
+ * - pid 已死 → 僵尸
21
+ * - 锁龄超过 staleMs → 僵尸
22
+ * - 否则 → 存活,不可接管
23
+ * @param {string} lockPath
24
+ * @param {number} staleMs
25
+ * @returns {boolean}
26
+ */
27
+ function isStale(lockPath, staleMs) {
28
+ let content;
29
+ try {
30
+ content = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
31
+ } catch {
32
+ return true;
33
+ }
34
+ if (content && content.pid && pidAlive(content.pid) && (Date.now() - (content.ts || 0)) < staleMs) {
35
+ return false;
36
+ }
37
+ return true;
38
+ }
39
+
40
+ /**
41
+ * 抢 O_EXCL 锁。原子创建锁文件,内容为 `JSON({pid, ts})`。
42
+ * 冲突时若现存锁为僵尸(pid 已死 / 锁龄超时 / 损坏)则接管,否则放弃。
43
+ *
44
+ * @param {string} lockPath 锁文件路径
45
+ * @param {{ staleMs?: number }} [opts] 选项,`staleMs` 默认 30 分钟
46
+ * @returns {(() => void)|null} 成功返回 release()(unlink,幂等);失败返回 null
47
+ */
48
+ export function acquireLock(lockPath, opts = {}) {
49
+ const staleMs = opts.staleMs ?? 30 * 60 * 1000;
50
+
51
+ const take = () => {
52
+ try {
53
+ const fd = fs.openSync(lockPath, 'wx');
54
+ try {
55
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
56
+ } finally {
57
+ fs.closeSync(fd);
58
+ }
59
+ let released = false;
60
+ return () => {
61
+ if (released) return;
62
+ released = true;
63
+ try { fs.unlinkSync(lockPath); } catch {}
64
+ };
65
+ } catch {
66
+ return null;
67
+ }
68
+ };
69
+
70
+ const rel = take();
71
+ if (rel) return rel;
72
+
73
+ if (isStale(lockPath, staleMs)) {
74
+ try { fs.unlinkSync(lockPath); } catch {}
75
+ return take();
76
+ }
77
+
78
+ return null;
79
+ }
@@ -0,0 +1,20 @@
1
+ import fs from 'node:fs';
2
+
3
+ /** 两次 stat 比较 size;稳定才认为文件写完。避免 async flush 滞后的残缺快照。 */
4
+ export async function isSettled(filePath, opts = {}) {
5
+ const settleMs = opts.settleMs ?? 500;
6
+ const maxAttempts = opts.maxAttempts ?? 3;
7
+ // maxAttempts <= 1:单次 stat,无法比较;存在即视为稳定。
8
+ if (maxAttempts <= 1) {
9
+ try { fs.statSync(filePath); return true; } catch { return false; }
10
+ }
11
+ let prev = -1;
12
+ for (let i = 0; i < maxAttempts; i++) {
13
+ let size;
14
+ try { size = fs.statSync(filePath).size; } catch { return false; }
15
+ if (i > 0 && size === prev) return true;
16
+ prev = size;
17
+ if (i < maxAttempts - 1) await new Promise(r => setTimeout(r, settleMs));
18
+ }
19
+ return false;
20
+ }
@@ -0,0 +1,89 @@
1
+ /** 零依赖 zip(单文件,DEFLATE,无数据描述符)。 */
2
+ import { deflateRawSync } from 'node:zlib';
3
+
4
+ // CRC32 查表法
5
+ const CRC_TABLE = (() => {
6
+ const t = new Uint32Array(256);
7
+ for (let i = 0; i < 256; i++) {
8
+ let c = i;
9
+ for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
10
+ t[i] = c;
11
+ }
12
+ return t;
13
+ })();
14
+
15
+ export function crc32(buf) {
16
+ let crc = 0xFFFFFFFF;
17
+ for (let i = 0; i < buf.length; i++) {
18
+ crc = CRC_TABLE[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
19
+ }
20
+ return (crc ^ 0xFFFFFFFF) >>> 0;
21
+ }
22
+
23
+ export function createZip(files) {
24
+ // files: [{ name: string, data: Buffer | string }]
25
+ const localParts = [];
26
+ const centralParts = [];
27
+ let offset = 0;
28
+
29
+ for (const file of files) {
30
+ const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data);
31
+ const compressed = deflateRawSync(data);
32
+ const crc = crc32(data);
33
+ const nameBuf = Buffer.from(file.name);
34
+
35
+ // Local file header (30 bytes)
36
+ const lh = Buffer.alloc(30);
37
+ lh.writeUInt32LE(0x04034b50, 0);
38
+ lh.writeUInt16LE(20, 4); // version needed
39
+ lh.writeUInt16LE(0, 6); // flags
40
+ lh.writeUInt16LE(8, 8); // compression: deflate
41
+ lh.writeUInt16LE(0, 10); // mod time
42
+ lh.writeUInt16LE(0x0021, 12); // mod date (1980-01-01 safe)
43
+ lh.writeUInt32LE(crc, 14);
44
+ lh.writeUInt32LE(compressed.length, 18);
45
+ lh.writeUInt32LE(data.length, 22);
46
+ lh.writeUInt16LE(nameBuf.length, 26);
47
+ lh.writeUInt16LE(0, 28); // extra length
48
+
49
+ const localPart = Buffer.concat([lh, nameBuf, compressed]);
50
+ localParts.push(localPart);
51
+
52
+ // Central directory header (46 bytes)
53
+ const ch = Buffer.alloc(46);
54
+ ch.writeUInt32LE(0x02014b50, 0);
55
+ ch.writeUInt16LE(20, 4);
56
+ ch.writeUInt16LE(20, 6);
57
+ ch.writeUInt16LE(0, 8);
58
+ ch.writeUInt16LE(8, 10);
59
+ ch.writeUInt16LE(0, 12);
60
+ ch.writeUInt16LE(0x0021, 14);
61
+ ch.writeUInt32LE(crc, 16);
62
+ ch.writeUInt32LE(compressed.length, 20);
63
+ ch.writeUInt32LE(data.length, 24);
64
+ ch.writeUInt16LE(nameBuf.length, 28);
65
+ ch.writeUInt16LE(0, 30); // extra
66
+ ch.writeUInt16LE(0, 32); // comment
67
+ ch.writeUInt16LE(0, 34); // disk
68
+ ch.writeUInt16LE(0, 36); // internal attrs
69
+ ch.writeUInt32LE(0, 38); // external attrs
70
+ ch.writeUInt32LE(offset, 42); // local header offset
71
+ centralParts.push(Buffer.concat([ch, nameBuf]));
72
+
73
+ offset += localPart.length;
74
+ }
75
+
76
+ // End of central directory (22 bytes)
77
+ const centralBuf = Buffer.concat(centralParts);
78
+ const eocd = Buffer.alloc(22);
79
+ eocd.writeUInt32LE(0x06054b50, 0);
80
+ eocd.writeUInt16LE(0, 4);
81
+ eocd.writeUInt16LE(0, 6);
82
+ eocd.writeUInt16LE(files.length, 8);
83
+ eocd.writeUInt16LE(files.length, 10);
84
+ eocd.writeUInt32LE(centralBuf.length, 12);
85
+ eocd.writeUInt32LE(offset, 16);
86
+ eocd.writeUInt16LE(0, 20);
87
+
88
+ return Buffer.concat([...localParts, centralBuf, eocd]);
89
+ }