ai-worklens-agent 0.1.0
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/README.md +166 -0
- package/package.json +22 -0
- package/src/cli.mjs +159 -0
- package/src/config.mjs +83 -0
- package/src/event-builder.mjs +77 -0
- package/src/hook-adapter.mjs +351 -0
- package/src/hook-smoke.mjs +105 -0
- package/src/install.mjs +602 -0
- package/src/mcp-server.mjs +364 -0
- package/src/publish-npm.mjs +149 -0
- package/src/queue.mjs +128 -0
- package/src/team-rollout.mjs +197 -0
- package/src/uploader.mjs +428 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { loadClientConfig } from "./config.mjs";
|
|
6
|
+
import { buildEvent } from "./event-builder.mjs";
|
|
7
|
+
import { ClientAgent } from "./uploader.mjs";
|
|
8
|
+
|
|
9
|
+
const tools = [
|
|
10
|
+
{
|
|
11
|
+
name: "record_ai_event",
|
|
12
|
+
description: "静默记录一条通用 AI 使用事件,上传到中心端;上传失败会进入本地队列。",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
type: "object",
|
|
15
|
+
required: ["summary"],
|
|
16
|
+
properties: {
|
|
17
|
+
eventType: { type: "string" },
|
|
18
|
+
summary: { type: "string" },
|
|
19
|
+
title: { type: "string" },
|
|
20
|
+
durationSeconds: { type: "number" },
|
|
21
|
+
files: { type: "string" },
|
|
22
|
+
commands: { type: "string" },
|
|
23
|
+
sessionId: { type: "string" }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "record_skill_use",
|
|
29
|
+
description: "记录 Codex Skill 使用事件。",
|
|
30
|
+
inputSchema: {
|
|
31
|
+
type: "object",
|
|
32
|
+
required: ["skillName"],
|
|
33
|
+
properties: {
|
|
34
|
+
skillName: { type: "string" },
|
|
35
|
+
summary: { type: "string" },
|
|
36
|
+
durationSeconds: { type: "number" },
|
|
37
|
+
sessionId: { type: "string" }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "record_assistant_turn",
|
|
43
|
+
description: "记录 AI 助手回复摘要,用于更准确计算对话轮次和会话完整度。",
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: "object",
|
|
46
|
+
required: ["summary"],
|
|
47
|
+
properties: {
|
|
48
|
+
summary: { type: "string" },
|
|
49
|
+
title: { type: "string" },
|
|
50
|
+
durationSeconds: { type: "number" },
|
|
51
|
+
sessionId: { type: "string" },
|
|
52
|
+
turnIndex: { type: "number" }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "record_mode_change",
|
|
58
|
+
description: "记录 Plan/Edit/Agent 等模式切换,避免通过文本猜测规划能力。",
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: "object",
|
|
61
|
+
required: ["mode"],
|
|
62
|
+
properties: {
|
|
63
|
+
mode: { type: "string" },
|
|
64
|
+
previousMode: { type: "string" },
|
|
65
|
+
summary: { type: "string" },
|
|
66
|
+
sessionId: { type: "string" }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "record_tool_result",
|
|
72
|
+
description: "记录工具调用结果、退出码、成功失败和命令,补齐工具调用闭环。",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: "object",
|
|
75
|
+
required: ["toolName"],
|
|
76
|
+
properties: {
|
|
77
|
+
toolName: { type: "string" },
|
|
78
|
+
status: { type: "string" },
|
|
79
|
+
exitCode: { type: "number" },
|
|
80
|
+
summary: { type: "string" },
|
|
81
|
+
command: { type: "string" },
|
|
82
|
+
durationSeconds: { type: "number" },
|
|
83
|
+
sessionId: { type: "string" }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "record_retry",
|
|
89
|
+
description: "记录失败后的重试、返修或再次执行,辅助分析返工风险。",
|
|
90
|
+
inputSchema: {
|
|
91
|
+
type: "object",
|
|
92
|
+
required: ["summary"],
|
|
93
|
+
properties: {
|
|
94
|
+
summary: { type: "string" },
|
|
95
|
+
failureKind: { type: "string" },
|
|
96
|
+
retryAttempt: { type: "number" },
|
|
97
|
+
durationSeconds: { type: "number" },
|
|
98
|
+
sessionId: { type: "string" }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "record_plugin_use",
|
|
104
|
+
description: "记录插件使用事件,例如 Spreadsheets、Figma、Playwright 等。",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
required: ["pluginName"],
|
|
108
|
+
properties: {
|
|
109
|
+
pluginName: { type: "string" },
|
|
110
|
+
summary: { type: "string" },
|
|
111
|
+
durationSeconds: { type: "number" },
|
|
112
|
+
sessionId: { type: "string" }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "record_mcp_tool_call",
|
|
118
|
+
description: "记录 MCP server 或 MCP tool 调用事件。",
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: "object",
|
|
121
|
+
required: ["mcpServer"],
|
|
122
|
+
properties: {
|
|
123
|
+
mcpServer: { type: "string" },
|
|
124
|
+
toolName: { type: "string" },
|
|
125
|
+
summary: { type: "string" },
|
|
126
|
+
durationSeconds: { type: "number" },
|
|
127
|
+
sessionId: { type: "string" }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "record_verification",
|
|
133
|
+
description: "记录测试、构建、浏览器验证或人工检查结果。",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: "object",
|
|
136
|
+
required: ["summary"],
|
|
137
|
+
properties: {
|
|
138
|
+
summary: { type: "string" },
|
|
139
|
+
command: { type: "string" },
|
|
140
|
+
durationSeconds: { type: "number" },
|
|
141
|
+
sessionId: { type: "string" }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: "client_checkin",
|
|
147
|
+
description: "上报员工端 MCP、Hook、队列和版本健康状态。",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
mcpReady: { type: "boolean" },
|
|
152
|
+
hookReady: { type: "boolean" },
|
|
153
|
+
issues: { type: "array", items: { type: "string" } }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "flush_queue",
|
|
159
|
+
description: "立即上传本地离线队列中已到期的事件。",
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: "object",
|
|
162
|
+
properties: {
|
|
163
|
+
force: { type: "boolean" }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "recover_queue",
|
|
169
|
+
description: "中心端恢复后同步规则、按批补传本地离线队列并上报健康状态。",
|
|
170
|
+
inputSchema: {
|
|
171
|
+
type: "object",
|
|
172
|
+
properties: {
|
|
173
|
+
force: { type: "boolean" },
|
|
174
|
+
maxBatches: { type: "number" },
|
|
175
|
+
sync: { type: "boolean" },
|
|
176
|
+
checkin: { type: "boolean" }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: "sync_remote_config",
|
|
182
|
+
description: "从中心端拉取最新采集规则,并写入本地 client.json。",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
type: "object",
|
|
185
|
+
properties: {
|
|
186
|
+
write: { type: "boolean" }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
function textResult(value) {
|
|
193
|
+
return {
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: "text",
|
|
197
|
+
text: typeof value === "string" ? value : JSON.stringify(value, null, 2)
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function eventForTool(name, args, config) {
|
|
204
|
+
if (name === "record_assistant_turn") {
|
|
205
|
+
return buildEvent({
|
|
206
|
+
eventType: "assistant_response",
|
|
207
|
+
title: args.title || "AI 回复",
|
|
208
|
+
content: args.summary,
|
|
209
|
+
durationSeconds: args.durationSeconds,
|
|
210
|
+
localSessionId: args.sessionId,
|
|
211
|
+
turnIndex: args.turnIndex,
|
|
212
|
+
interactionType: "assistant_response"
|
|
213
|
+
}, config);
|
|
214
|
+
}
|
|
215
|
+
if (name === "record_mode_change") {
|
|
216
|
+
return buildEvent({
|
|
217
|
+
eventType: "mode_change",
|
|
218
|
+
title: `模式切换:${args.mode}`,
|
|
219
|
+
content: args.summary || `${args.previousMode || "-"} -> ${args.mode}`,
|
|
220
|
+
previousMode: args.previousMode,
|
|
221
|
+
nextMode: args.mode,
|
|
222
|
+
codexMode: args.mode,
|
|
223
|
+
localSessionId: args.sessionId,
|
|
224
|
+
interactionType: "mode_change"
|
|
225
|
+
}, config);
|
|
226
|
+
}
|
|
227
|
+
if (name === "record_tool_result") {
|
|
228
|
+
return buildEvent({
|
|
229
|
+
eventType: "tool_result",
|
|
230
|
+
title: args.toolName,
|
|
231
|
+
content: args.summary || `工具结果:${args.toolName}`,
|
|
232
|
+
commands: args.command,
|
|
233
|
+
durationSeconds: args.durationSeconds,
|
|
234
|
+
localSessionId: args.sessionId,
|
|
235
|
+
toolName: args.toolName,
|
|
236
|
+
toolStatus: args.status,
|
|
237
|
+
exitCode: args.exitCode,
|
|
238
|
+
interactionType: "tool_result"
|
|
239
|
+
}, config);
|
|
240
|
+
}
|
|
241
|
+
if (name === "record_retry") {
|
|
242
|
+
return buildEvent({
|
|
243
|
+
eventType: "retry",
|
|
244
|
+
title: "失败重试",
|
|
245
|
+
content: args.summary,
|
|
246
|
+
durationSeconds: args.durationSeconds,
|
|
247
|
+
localSessionId: args.sessionId,
|
|
248
|
+
failureKind: args.failureKind,
|
|
249
|
+
retryAttempt: args.retryAttempt,
|
|
250
|
+
interactionType: "retry"
|
|
251
|
+
}, config);
|
|
252
|
+
}
|
|
253
|
+
if (name === "record_skill_use") {
|
|
254
|
+
return buildEvent({
|
|
255
|
+
eventType: "skill_use",
|
|
256
|
+
title: args.skillName,
|
|
257
|
+
content: args.summary || `使用 Skill:${args.skillName}`,
|
|
258
|
+
skillName: args.skillName,
|
|
259
|
+
durationSeconds: args.durationSeconds,
|
|
260
|
+
localSessionId: args.sessionId
|
|
261
|
+
}, config);
|
|
262
|
+
}
|
|
263
|
+
if (name === "record_plugin_use") {
|
|
264
|
+
return buildEvent({
|
|
265
|
+
eventType: "plugin_use",
|
|
266
|
+
title: args.pluginName,
|
|
267
|
+
content: args.summary || `使用插件:${args.pluginName}`,
|
|
268
|
+
pluginName: args.pluginName,
|
|
269
|
+
durationSeconds: args.durationSeconds,
|
|
270
|
+
localSessionId: args.sessionId
|
|
271
|
+
}, config);
|
|
272
|
+
}
|
|
273
|
+
if (name === "record_mcp_tool_call") {
|
|
274
|
+
return buildEvent({
|
|
275
|
+
eventType: "mcp_tool_call",
|
|
276
|
+
title: args.toolName || args.mcpServer,
|
|
277
|
+
content: args.summary || `调用 MCP:${args.mcpServer}`,
|
|
278
|
+
mcpServer: args.mcpServer,
|
|
279
|
+
durationSeconds: args.durationSeconds,
|
|
280
|
+
localSessionId: args.sessionId,
|
|
281
|
+
metadata: { toolName: args.toolName }
|
|
282
|
+
}, config);
|
|
283
|
+
}
|
|
284
|
+
if (name === "record_verification") {
|
|
285
|
+
return buildEvent({
|
|
286
|
+
eventType: "verification",
|
|
287
|
+
title: "验证",
|
|
288
|
+
content: args.summary,
|
|
289
|
+
commands: args.command,
|
|
290
|
+
durationSeconds: args.durationSeconds,
|
|
291
|
+
localSessionId: args.sessionId
|
|
292
|
+
}, config);
|
|
293
|
+
}
|
|
294
|
+
return buildEvent({
|
|
295
|
+
eventType: args.eventType || "tool_call",
|
|
296
|
+
title: args.title || args.eventType || "AI 使用事件",
|
|
297
|
+
content: args.summary || args.content,
|
|
298
|
+
files: args.files,
|
|
299
|
+
commands: args.commands,
|
|
300
|
+
durationSeconds: args.durationSeconds,
|
|
301
|
+
localSessionId: args.sessionId
|
|
302
|
+
}, config);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function handleMcpRequest(request, config = loadClientConfig()) {
|
|
306
|
+
if (request.method === "initialize") {
|
|
307
|
+
return {
|
|
308
|
+
protocolVersion: "2024-11-05",
|
|
309
|
+
capabilities: { tools: {} },
|
|
310
|
+
serverInfo: { name: "ai-worklens", version: "0.1.0" }
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
if (request.method === "tools/list") {
|
|
314
|
+
return { tools };
|
|
315
|
+
}
|
|
316
|
+
if (request.method === "tools/call") {
|
|
317
|
+
const { name, arguments: args = {} } = request.params || {};
|
|
318
|
+
const agent = new ClientAgent(config);
|
|
319
|
+
if (name === "client_checkin") return textResult(await agent.checkin(args));
|
|
320
|
+
if (name === "flush_queue") return textResult(await agent.flush({ force: args.force === true }));
|
|
321
|
+
if (name === "recover_queue") {
|
|
322
|
+
return textResult(await agent.recover({
|
|
323
|
+
force: args.force === true,
|
|
324
|
+
maxBatches: args.maxBatches || 20,
|
|
325
|
+
sync: args.sync !== false,
|
|
326
|
+
checkin: args.checkin !== false
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
if (name === "sync_remote_config") return textResult(await agent.syncConfig({ write: args.write !== false }));
|
|
330
|
+
if (!tools.some((tool) => tool.name === name)) {
|
|
331
|
+
throw new Error(`unknown tool: ${name}`);
|
|
332
|
+
}
|
|
333
|
+
const event = eventForTool(name, args, config);
|
|
334
|
+
return textResult({ eventId: event.eventId, eventType: event.eventType, result: await agent.record(event) });
|
|
335
|
+
}
|
|
336
|
+
return {};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function writeResponse(message) {
|
|
340
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function main() {
|
|
344
|
+
const config = loadClientConfig();
|
|
345
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
346
|
+
for await (const line of rl) {
|
|
347
|
+
if (!line.trim()) continue;
|
|
348
|
+
const request = JSON.parse(line);
|
|
349
|
+
if (!request.id) continue;
|
|
350
|
+
try {
|
|
351
|
+
const result = await handleMcpRequest(request, config);
|
|
352
|
+
writeResponse({ jsonrpc: "2.0", id: request.id, result });
|
|
353
|
+
} catch (error) {
|
|
354
|
+
writeResponse({ jsonrpc: "2.0", id: request.id, error: { code: -32000, message: error.message } });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) {
|
|
360
|
+
main().catch((error) => {
|
|
361
|
+
process.stderr.write(`${error.stack || error.message}\n`);
|
|
362
|
+
process.exitCode = 1;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
9
|
+
const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
10
|
+
|
|
11
|
+
export function parseArgs(argv = []) {
|
|
12
|
+
const options = {
|
|
13
|
+
access: "public",
|
|
14
|
+
registry: PUBLIC_NPM_REGISTRY,
|
|
15
|
+
tag: "latest",
|
|
16
|
+
dryRun: false
|
|
17
|
+
};
|
|
18
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
19
|
+
const arg = argv[index];
|
|
20
|
+
const next = () => argv[++index];
|
|
21
|
+
if (arg === "--package-name") options.packageName = next();
|
|
22
|
+
else if (arg === "--version") options.version = next();
|
|
23
|
+
else if (arg === "--tag") options.tag = next();
|
|
24
|
+
else if (arg === "--registry") options.registry = next();
|
|
25
|
+
else if (arg === "--access") options.access = next();
|
|
26
|
+
else if (arg === "--token") options.token = next();
|
|
27
|
+
else if (arg === "--otp") options.otp = next();
|
|
28
|
+
else if (arg === "--dry-run") options.dryRun = true;
|
|
29
|
+
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
30
|
+
else throw new Error(`unknown argument: ${arg}`);
|
|
31
|
+
}
|
|
32
|
+
options.packageName = options.packageName || process.env.WORKLENS_NPM_PACKAGE_NAME || "";
|
|
33
|
+
options.version = options.version || process.env.WORKLENS_NPM_VERSION || "";
|
|
34
|
+
options.token = options.token || process.env.NPM_TOKEN || "";
|
|
35
|
+
return options;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function usage() {
|
|
39
|
+
return [
|
|
40
|
+
"Usage:",
|
|
41
|
+
" npm run client:npm:publish -- --package-name <npm-package> --version <version> --tag latest --dry-run",
|
|
42
|
+
" NPM_TOKEN=<token> npm run client:npm:publish -- --package-name <npm-package> --version <version> --tag latest",
|
|
43
|
+
"",
|
|
44
|
+
"Notes:",
|
|
45
|
+
" - Public npm scoped packages require --access public.",
|
|
46
|
+
" - For @scope/name, the npm account token must have permission for that scope.",
|
|
47
|
+
" - Token can be passed through NPM_TOKEN or --token; it is written only to a temporary .npmrc."
|
|
48
|
+
].join("\n");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readPackageJson() {
|
|
52
|
+
return JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safePackageName(name) {
|
|
56
|
+
const value = String(name || "").trim();
|
|
57
|
+
if (!value) throw new Error("--package-name is required for public npm publish");
|
|
58
|
+
if (!/^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(value)) {
|
|
59
|
+
throw new Error(`invalid npm package name: ${value}`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function safeVersion(version, fallback) {
|
|
65
|
+
const value = String(version || fallback || "").trim();
|
|
66
|
+
if (!value) throw new Error("--version is required for public npm publish");
|
|
67
|
+
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test(value)) {
|
|
68
|
+
throw new Error(`invalid semver version: ${value}`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function registryAuthHost(registry) {
|
|
74
|
+
const url = new URL(registry || PUBLIC_NPM_REGISTRY);
|
|
75
|
+
return `${url.host}${url.pathname.replace(/\/?$/, "/")}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function preparePublishDirectory(options = {}) {
|
|
79
|
+
const pkg = readPackageJson();
|
|
80
|
+
const packageName = safePackageName(options.packageName || pkg.name);
|
|
81
|
+
const version = safeVersion(options.version, pkg.version);
|
|
82
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "worklens-npm-publish-"));
|
|
83
|
+
const stagedPackage = {
|
|
84
|
+
...pkg,
|
|
85
|
+
name: packageName,
|
|
86
|
+
version,
|
|
87
|
+
private: false,
|
|
88
|
+
publishConfig: {
|
|
89
|
+
...(pkg.publishConfig || {}),
|
|
90
|
+
access: options.access || "public",
|
|
91
|
+
registry: options.registry || PUBLIC_NPM_REGISTRY
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
fs.cpSync(path.join(PACKAGE_ROOT, "src"), path.join(tempDir, "src"), { recursive: true });
|
|
95
|
+
fs.copyFileSync(path.join(PACKAGE_ROOT, "README.md"), path.join(tempDir, "README.md"));
|
|
96
|
+
fs.writeFileSync(path.join(tempDir, "package.json"), `${JSON.stringify(stagedPackage, null, 2)}\n`);
|
|
97
|
+
if (options.token) {
|
|
98
|
+
fs.writeFileSync(path.join(tempDir, ".npmrc"), `//${registryAuthHost(options.registry)}:_authToken=${options.token}\n`);
|
|
99
|
+
}
|
|
100
|
+
return { tempDir, packageName, version };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function buildNpmPublishArgs(stagedDir, options = {}) {
|
|
104
|
+
return [
|
|
105
|
+
"publish",
|
|
106
|
+
stagedDir,
|
|
107
|
+
"--registry",
|
|
108
|
+
options.registry || PUBLIC_NPM_REGISTRY,
|
|
109
|
+
"--access",
|
|
110
|
+
options.access || "public",
|
|
111
|
+
"--tag",
|
|
112
|
+
options.tag || "latest",
|
|
113
|
+
"--cache",
|
|
114
|
+
path.join(stagedDir, ".npm-cache"),
|
|
115
|
+
...(options.dryRun ? ["--dry-run"] : []),
|
|
116
|
+
...(options.token ? ["--userconfig", path.join(stagedDir, ".npmrc")] : []),
|
|
117
|
+
...(options.otp ? ["--otp", options.otp] : [])
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function publishToNpm(options = {}) {
|
|
122
|
+
if (!options.dryRun && !options.token) {
|
|
123
|
+
throw new Error("NPM_TOKEN is required for real public npm publish; use --dry-run to preview without token");
|
|
124
|
+
}
|
|
125
|
+
const prepared = preparePublishDirectory(options);
|
|
126
|
+
const args = buildNpmPublishArgs(prepared.tempDir, options);
|
|
127
|
+
const result = spawnSync("npm", args, { stdio: "inherit" });
|
|
128
|
+
if (result.status !== 0) {
|
|
129
|
+
throw new Error(`npm publish failed with exit code ${result.status}`);
|
|
130
|
+
}
|
|
131
|
+
return prepared;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) {
|
|
135
|
+
try {
|
|
136
|
+
const options = parseArgs(process.argv.slice(2));
|
|
137
|
+
if (options.help) {
|
|
138
|
+
console.log(usage());
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
const result = publishToNpm(options);
|
|
142
|
+
console.log(`Published package payload: ${result.packageName}@${result.version}`);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
console.error(error.message);
|
|
145
|
+
console.error("");
|
|
146
|
+
console.error(usage());
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/queue.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_RETRY = {
|
|
6
|
+
maxDelayMs: 30 * 60 * 1000,
|
|
7
|
+
baseDelayMs: 30 * 1000
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
async function readJsonArray(filePath) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
13
|
+
const parsed = JSON.parse(raw);
|
|
14
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error.code === "ENOENT") return [];
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeItem(item) {
|
|
22
|
+
const event = item?.event || item;
|
|
23
|
+
const queuedAt = item?.queuedAt || new Date().toISOString();
|
|
24
|
+
return {
|
|
25
|
+
id: item?.id || event?.eventId || crypto.randomUUID(),
|
|
26
|
+
queuedAt,
|
|
27
|
+
attempts: Number(item?.attempts || 0),
|
|
28
|
+
lastAttemptAt: item?.lastAttemptAt || "",
|
|
29
|
+
nextAttemptAt: item?.nextAttemptAt || queuedAt,
|
|
30
|
+
lastError: item?.lastError || "",
|
|
31
|
+
event
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function dueAt(item) {
|
|
36
|
+
const value = Date.parse(item.nextAttemptAt || item.queuedAt || 0);
|
|
37
|
+
return Number.isFinite(value) ? value : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function retryDelay(attempts, options = {}) {
|
|
41
|
+
const baseDelayMs = Number(options.baseDelayMs || DEFAULT_RETRY.baseDelayMs);
|
|
42
|
+
const maxDelayMs = Number(options.maxDelayMs || DEFAULT_RETRY.maxDelayMs);
|
|
43
|
+
const exponent = Math.min(10, Math.max(0, Number(attempts || 1) - 1));
|
|
44
|
+
return Math.min(maxDelayMs, baseDelayMs * (2 ** exponent));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class EventQueue {
|
|
48
|
+
constructor(filePath) {
|
|
49
|
+
this.filePath = filePath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async list() {
|
|
53
|
+
const items = await readJsonArray(this.filePath);
|
|
54
|
+
return items.map(normalizeItem);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async save(items) {
|
|
58
|
+
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
|
59
|
+
await fs.writeFile(this.filePath, `${JSON.stringify(items.map(normalizeItem), null, 2)}\n`, { mode: 0o600 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async enqueue(event) {
|
|
63
|
+
const items = await this.list();
|
|
64
|
+
items.push(normalizeItem({ queuedAt: new Date().toISOString(), event }));
|
|
65
|
+
await this.save(items);
|
|
66
|
+
return items.length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async replace(items) {
|
|
70
|
+
await this.save(items);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async due({ now = new Date(), limit = Infinity, force = false } = {}) {
|
|
74
|
+
const timestamp = now instanceof Date ? now.getTime() : Date.parse(now);
|
|
75
|
+
const items = await this.list();
|
|
76
|
+
return items
|
|
77
|
+
.filter((item) => force || dueAt(item) <= timestamp)
|
|
78
|
+
.slice(0, limit);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async markFailed(failedIds, error, { now = new Date(), retry = DEFAULT_RETRY } = {}) {
|
|
82
|
+
const idSet = new Set(failedIds);
|
|
83
|
+
const nowIso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
|
|
84
|
+
const items = await this.list();
|
|
85
|
+
const marked = items.map((item) => {
|
|
86
|
+
if (!idSet.has(item.id)) return item;
|
|
87
|
+
const attempts = Number(item.attempts || 0) + 1;
|
|
88
|
+
return {
|
|
89
|
+
...item,
|
|
90
|
+
attempts,
|
|
91
|
+
lastAttemptAt: nowIso,
|
|
92
|
+
lastError: String(error?.message || error || "upload_failed"),
|
|
93
|
+
nextAttemptAt: new Date(Date.parse(nowIso) + retryDelay(attempts, retry)).toISOString()
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
await this.save(marked);
|
|
97
|
+
return marked;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async remove(removeIds) {
|
|
101
|
+
const idSet = new Set(removeIds);
|
|
102
|
+
const items = await this.list();
|
|
103
|
+
const remaining = items.filter((item) => !idSet.has(item.id));
|
|
104
|
+
await this.save(remaining);
|
|
105
|
+
return remaining;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async stats({ now = new Date() } = {}) {
|
|
109
|
+
const timestamp = now instanceof Date ? now.getTime() : Date.parse(now);
|
|
110
|
+
const items = await this.list();
|
|
111
|
+
const dueItems = items.filter((item) => dueAt(item) <= timestamp);
|
|
112
|
+
const nextAttemptAt = items
|
|
113
|
+
.filter((item) => dueAt(item) > timestamp)
|
|
114
|
+
.map((item) => item.nextAttemptAt)
|
|
115
|
+
.sort()[0] || "";
|
|
116
|
+
return {
|
|
117
|
+
total: items.length,
|
|
118
|
+
due: dueItems.length,
|
|
119
|
+
waiting: items.length - dueItems.length,
|
|
120
|
+
nextAttemptAt,
|
|
121
|
+
maxAttempts: items.reduce((max, item) => Math.max(max, Number(item.attempts || 0)), 0)
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async clear() {
|
|
126
|
+
await this.save([]);
|
|
127
|
+
}
|
|
128
|
+
}
|