@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/QUICKSTART.md +1 -4
- package/README.md +1 -2
- package/bin/toolkit.mjs +10 -21
- package/package.json +1 -1
- package/src/config-cmd.mjs +1 -1
- package/src/flush.mjs +243 -221
- package/src/hook.mjs +35 -21
- package/src/install.mjs +74 -34
- package/src/shared/backlog.mjs +96 -0
- package/src/shared/config.mjs +17 -0
- package/src/shared/http.mjs +29 -0
- package/src/shared/lock.mjs +79 -0
- package/src/shared/settle.mjs +20 -0
- package/src/shared/zip.mjs +89 -0
- package/src/skillhub.mjs +2 -319
- package/src/status.mjs +103 -36
- package/src/uninstall.mjs +26 -13
- package/src/zentao.mjs +57 -2
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
*
|
|
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,
|
|
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', '
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
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('✅
|
|
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
|
+
}
|
package/src/shared/config.mjs
CHANGED
|
@@ -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
|
+
}
|