@wanghaopeng1148/deskpet 2.0.1 → 2.0.2

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.
Files changed (53) hide show
  1. package/README.md +54 -15
  2. package/bin/deskpet.mjs +70 -2
  3. package/dist/node/server/db/database.js +113 -0
  4. package/dist/node/server/db/migrate-legacy.js +88 -0
  5. package/dist/node/server/db/task-repository.js +374 -0
  6. package/dist/node/server/http/http-server.js +493 -0
  7. package/dist/node/server/http/ws-hub.js +59 -0
  8. package/dist/node/server/main.js +291 -0
  9. package/dist/node/server/plugins/actions/builtin.js +67 -0
  10. package/dist/node/server/plugins/actions/clipboard-watch.js +27 -0
  11. package/dist/node/server/plugins/actions/http-request.js +41 -0
  12. package/dist/node/server/plugins/actions/jenkins-build.js +183 -0
  13. package/dist/node/server/plugins/actions/open-app.js +41 -0
  14. package/dist/node/server/plugins/actions/python-script.js +180 -0
  15. package/dist/node/server/plugins/actions/screenshot.js +38 -0
  16. package/dist/node/server/plugins/actions/send-keystroke.js +100 -0
  17. package/dist/node/server/plugins/actions/show-reminder.js +7 -0
  18. package/dist/node/server/plugins/actions/ssh-command.js +123 -0
  19. package/dist/node/server/plugins/actions/task-chain.js +24 -0
  20. package/dist/node/server/plugins/actions/volume-control.js +31 -0
  21. package/dist/node/server/plugins/index.js +35 -0
  22. package/dist/node/server/plugins/registry.js +23 -0
  23. package/dist/node/server/services/clipboard-watcher.js +112 -0
  24. package/dist/node/server/services/config-store.js +141 -0
  25. package/dist/node/server/services/idle-monitor.js +131 -0
  26. package/dist/node/server/services/notifier.js +36 -0
  27. package/dist/node/server/services/quick-actions-store.js +52 -0
  28. package/dist/node/server/services/remote-connector.js +67 -0
  29. package/dist/node/server/services/scanner-reader.js +217 -0
  30. package/dist/node/server/services/script-runner.js +228 -0
  31. package/dist/node/server/services/snapshot-service.js +135 -0
  32. package/dist/node/server/services/task-scheduler.js +813 -0
  33. package/dist/node/server/services/wechat-bot.js +635 -0
  34. package/dist/node/server/services/wechat-command-types.js +1 -0
  35. package/dist/node/server/services/wechat-commands.js +330 -0
  36. package/dist/node/server/suppress-warnings.js +12 -0
  37. package/dist/node/server/utils/asset-url.js +26 -0
  38. package/dist/node/server/utils/auto-start.js +186 -0
  39. package/dist/node/server/utils/clipboard.js +50 -0
  40. package/dist/node/server/utils/dashboard-url.js +8 -0
  41. package/dist/node/server/utils/instance-guard.js +165 -0
  42. package/dist/node/server/utils/native-notify.js +53 -0
  43. package/dist/node/server/utils/open.js +37 -0
  44. package/dist/node/server/utils/paths.js +95 -0
  45. package/dist/node/server/utils/python-interpreter.js +129 -0
  46. package/dist/node/shared/animation-engine.js +349 -0
  47. package/dist/node/shared/chain-condition.js +39 -0
  48. package/dist/node/shared/cron-weekly.js +124 -0
  49. package/dist/node/shared/py-task-params.js +335 -0
  50. package/dist/node/shared/types.js +69 -0
  51. package/package.json +6 -2
  52. package/server/http/http-server.ts +4 -1
  53. package/server/utils/paths.ts +28 -1
@@ -0,0 +1,291 @@
1
+ /**
2
+ * DeskPet 服务入口 — 纯 Node 常驻进程(无 Electron)
3
+ *
4
+ * 职责: 任务系统(SQLite + 动作插件 + 调度器)、微信 Bot、扫码枪、
5
+ * 配置快照、管理台 HTTP/WebSocket 服务。前端用浏览器访问即可。
6
+ *
7
+ * 启动: npm start(内部 node --experimental-strip-types server/main.ts)
8
+ */
9
+ import "./suppress-warnings.js";
10
+ import { join } from 'node:path';
11
+ import { dashboardUrl } from "./utils/dashboard-url.js";
12
+ import { openDatabase } from "./db/database.js";
13
+ import { migrateLegacyTasksFile } from "./db/migrate-legacy.js";
14
+ import { TaskRepository } from "./db/task-repository.js";
15
+ import { registerBuiltinActions } from "./plugins/index.js";
16
+ import { ActionRegistry } from "./plugins/registry.js";
17
+ import { createApp } from "./http/http-server.js";
18
+ import { WsHub } from "./http/ws-hub.js";
19
+ import { ClipboardWatchRegistry } from "./services/clipboard-watcher.js";
20
+ import { ConfigStore } from "./services/config-store.js";
21
+ import { IdleMonitor, NetworkMonitor } from "./services/idle-monitor.js";
22
+ import { Notifier } from "./services/notifier.js";
23
+ import { QuickActionsStore } from "./services/quick-actions-store.js";
24
+ import { ScannerReader } from "./services/scanner-reader.js";
25
+ import { ScriptRunner } from "./services/script-runner.js";
26
+ import { SnapshotService } from "./services/snapshot-service.js";
27
+ import { TaskScheduler } from "./services/task-scheduler.js";
28
+ import { WechatBot } from "./services/wechat-bot.js";
29
+ import { WechatCommandRouter } from "./services/wechat-commands.js";
30
+ import { setAutoStart } from "./utils/auto-start.js";
31
+ import { acquireInstanceLock, startHeartbeat } from "./utils/instance-guard.js";
32
+ import { getDataRoot, getConfigDir } from "./utils/paths.js";
33
+ async function main() {
34
+ // ── 基础服务 ──────────────────────────────────────────
35
+ const config = new ConfigStore();
36
+ // ── 任务系统(SQLite + 动作插件 + 调度器) ─────────────
37
+ const db = openDatabase();
38
+ const repo = new TaskRepository(db);
39
+ // 启动时清理历史:每个任务只保留最近 N 条执行记录(默认 10)
40
+ try {
41
+ const pruned = repo.pruneExecutionsToLimit();
42
+ if (pruned > 0)
43
+ console.log(`[deskpet] 已清理 ${pruned} 条过期执行记录`);
44
+ }
45
+ catch (err) {
46
+ console.warn(`[deskpet] 清理执行记录失败: ${String(err)}`);
47
+ }
48
+ const scriptRunner = new ScriptRunner();
49
+ const notifier = new Notifier();
50
+ const registry = new ActionRegistry();
51
+ registerBuiltinActions(registry);
52
+ // ── 微信 Bot ──────────────────────────────────────────
53
+ const wechatCredsPath = join(getConfigDir(), 'wechat.json');
54
+ const clipboardWatch = new ClipboardWatchRegistry();
55
+ let wsHubSafe = null;
56
+ const scheduler = new TaskScheduler(repo, registry, {
57
+ scriptRunner,
58
+ notifier,
59
+ config,
60
+ trackRunningScript: (taskId, handle) => scheduler.trackRunningScript(taskId, handle),
61
+ requestRun: (taskId, source) => scheduler.requestRun(taskId, (source ?? 'manual')),
62
+ armClipboardWatch: (rule) => {
63
+ clipboardWatch.arm(rule);
64
+ },
65
+ log: () => {
66
+ /* 日志经执行记录落库;此处可扩展文件日志 */
67
+ }
68
+ });
69
+ const wechatBot = new WechatBot(wechatCredsPath, () => config.get().wechat);
70
+ const commandRouter = new WechatCommandRouter({
71
+ scheduler,
72
+ reminder: (text) => notifier.showReminder({ text })
73
+ });
74
+ wechatBot.setCommandHandler((text) => commandRouter.handle(text));
75
+ notifier.setWechatSender((text) => {
76
+ void wechatBot.sendText(text);
77
+ });
78
+ wechatBot.on('message', ({ text }) => repo.insertWechatMessage('in', text));
79
+ wechatBot.on('status', (info) => wsHubSafe?.broadcast('wechat-status', info));
80
+ wechatBot.on('disconnected', (reason) => {
81
+ console.warn(`[wechat] ${reason}`);
82
+ notifier.notify('微信连接断开', reason);
83
+ });
84
+ // 若已保存过凭据,启动时自动恢复会话(无需重新扫码)
85
+ if (wechatBot.connected) {
86
+ wechatBot.resume();
87
+ }
88
+ // 剪贴板监听:命中后以匹配值触发目标任务
89
+ clipboardWatch.on('match', ({ rule, value }) => {
90
+ console.log(`[clipboard-watch] 命中 /${rule.pattern}/: ${value}`);
91
+ if (rule.passValueAsArg) {
92
+ scheduler.runTaskWithArgs(rule.targetTaskId, [value]);
93
+ }
94
+ else {
95
+ scheduler.requestRun(rule.targetTaskId, 'trigger');
96
+ }
97
+ });
98
+ // 扫码枪
99
+ const scanner = new ScannerReader(() => config.get().scanner);
100
+ scanner.on('scan', (value) => {
101
+ console.log(`[scanner] 扫码内容: ${value}`);
102
+ wsHubSafe?.broadcast('scanner-scan', { value });
103
+ const sc = config.get().scanner;
104
+ const now = new Date().toLocaleString('zh-CN', { hour12: false });
105
+ if (sc.notifyWechat && wechatBot.connected) {
106
+ void wechatBot.sendText(`📱 扫码内容\n值:${value}\n时间:${now}`);
107
+ }
108
+ if (sc.notifyDesktop) {
109
+ notifier.notify('扫码枪', value);
110
+ }
111
+ });
112
+ scanner.on('status', (level, msg) => {
113
+ console.log(`[scanner:${level}] ${msg}`);
114
+ wsHubSafe?.broadcast('scanner-status', { level, message: msg });
115
+ });
116
+ // 快捷指令存储
117
+ const quickActionsStore = new QuickActionsStore(join(getConfigDir(), 'quick-actions.json'));
118
+ // 配置快照:配置变更自动快照,保留 20 份
119
+ const snapshotSource = {
120
+ getSettings: () => config.get(),
121
+ listTasks: () => scheduler.listTasks().map((t) => ({ id: t.id, config: t.config })),
122
+ getQuickActions: () => quickActionsStore.load(),
123
+ apply: ({ settings: s, tasks, quickActions }) => {
124
+ config.replaceAll(s);
125
+ repo.replaceAll(tasks ?? []);
126
+ if (quickActions)
127
+ quickActionsStore.save(quickActions);
128
+ }
129
+ };
130
+ // 旧版 tasks.json 迁移(仅当任务表为空)
131
+ const legacyPath = join(getConfigDir(), 'tasks.json');
132
+ const migrated = migrateLegacyTasksFile(legacyPath, repo);
133
+ if (migrated.imported > 0) {
134
+ console.log(`[deskpet] 已从旧版导入 ${migrated.imported} 个任务`);
135
+ }
136
+ scheduler.load();
137
+ // 注意:scheduler.start() 刻意推迟到 listen() 成功之后,见下方调用点的注释
138
+ // 自动快照:任务/设置/皮肤变更去抖 3 秒
139
+ let snapshotTimer = null;
140
+ const snapshots = new SnapshotService(db, snapshotSource);
141
+ const scheduleAutoSnapshot = () => {
142
+ if (snapshotTimer)
143
+ clearTimeout(snapshotTimer);
144
+ snapshotTimer = setTimeout(() => {
145
+ try {
146
+ snapshots.snapshot('auto');
147
+ }
148
+ catch {
149
+ /* ignore */
150
+ }
151
+ }, 3000);
152
+ snapshotTimer.unref?.();
153
+ };
154
+ // ── 管理台 WebUI 服务(Express + WebSocket) ───────────
155
+ const wsHub = new WsHub();
156
+ wsHubSafe = wsHub;
157
+ notifier.setSink(wsHub);
158
+ const expressApp = createApp({
159
+ scheduler,
160
+ repo,
161
+ config,
162
+ notifier,
163
+ registry,
164
+ wsHub,
165
+ wechat: wechatBot,
166
+ scanner,
167
+ snapshots,
168
+ quickActions: quickActionsStore,
169
+ clipboardWatch
170
+ });
171
+ // 监听地址允许用环境变量临时覆盖。
172
+ // 为什么需要:dev-all.mjs 会读 DESKPET_PORT 去等端口,而后端原先只认 settings.json,
173
+ // 两边取值来源不一致 —— 一旦设了环境变量,就成了「后端其实起来了,启动脚本却一直等不到」。
174
+ // 顺带也让同机并行跑第二个实例(调试用)成为可能。
175
+ const envPort = Number(process.env.DESKPET_PORT);
176
+ const serverHost = process.env.DESKPET_HOST?.trim() || config.get('server.host');
177
+ const serverPort = Number.isInteger(envPort) && envPort > 0 ? envPort : config.get('server.port');
178
+ const httpServer = await listen(expressApp, serverPort, serverHost);
179
+ wsHub.attach(httpServer);
180
+ const dashUrl = dashboardUrl(serverHost, serverPort);
181
+ console.log(`[deskpet] 管理台: ${dashUrl}`);
182
+ console.log(`[deskpet] 数据目录: ${getDataRoot()}`);
183
+ // ── 事件接线 ──────────────────────────────────────────
184
+ scheduler.on('status', (evt) => wsHub.broadcast('task-status', evt));
185
+ scheduler.on('execution', (info) => wsHub.broadcast('execution', info));
186
+ scheduler.on('busy', (on) => wsHub.broadcast('busy', on));
187
+ scheduler.on('queue', (state) => wsHub.broadcast('queue-changed', state));
188
+ // 运行中脚本的实时 stdout/stderr(管理台可边跑边看)
189
+ scheduler.on('output', (ev) => wsHub.broadcast('task-output', ev));
190
+ scheduler.on('changed', () => {
191
+ wsHub.broadcast('tasks-changed', { ts: Date.now() });
192
+ scheduleAutoSnapshot();
193
+ });
194
+ config.on('changed', scheduleAutoSnapshot);
195
+ // ── 启动调度器(必须晚于 listen 成功)─────────────────────
196
+ // start() 内部会调用 recoverInterrupted():把库里残留在 running / pending 的任务
197
+ // 复位,并给未结束的执行记录收尾。这一步是「有罪推定」——它假定
198
+ // 「库里凡是 running 的任务,都是上个进程挂掉时留下的」。
199
+ //
200
+ // 但本进程完全可能只是因为 3210 端口被占用而没抢到实例(重复启动、--watch 抖动),
201
+ // 而真正在跑任务的其实是另一个进程。此时若提前执行 recoverInterrupted(),
202
+ // 就会把对方正在执行的任务误判成「服务重启,执行被中断」——
203
+ // 用户看到的现象是「刚点运行不到 1 秒就报中断」,且任务日志一个字节都没产生。
204
+ //
205
+ // 因此必须等 listen() 成功之后再启动调度器。
206
+ //
207
+ // 但光靠端口还不够:Windows 上 libuv 默认给监听套接字设 SO_REUSEADDR,
208
+ // 允许多个进程同时绑定同一端口且都 listen 成功,EADDRINUSE 基本不会触发。
209
+ // 所以这里再用心跳文件判一次「是否已有实例在跑」,有的话只跳过恢复动作、
210
+ // 不中断对方(见 utils/instance-guard.ts)。
211
+ // 单实例判定改用**原子锁**(O_EXCL 一步完成「检查 + 创建」),
212
+ // 取代原先心跳那种「读 → 判 → 写」三步:后者在两个实例几乎同时启动时,
213
+ // 双方都可能读到「没有心跳 / 心跳已过期」,于是双双认定「我是唯一实例」,
214
+ // 双双执行 recoverInterrupted(),把对方正在跑的任务掐成「服务重启,执行被中断」。
215
+ const instanceLock = acquireInstanceLock();
216
+ if (!instanceLock.acquired) {
217
+ console.warn('[deskpet] 检测到已有 DeskPet 实例在运行:本次跳过「残留任务恢复」,' +
218
+ '不会中断对方正在执行的任务');
219
+ }
220
+ startHeartbeat();
221
+ scheduler.start({ recover: instanceLock.acquired });
222
+ // 开机自启:启动时应用 + 跟随设置变化
223
+ let lastAutoStart = config.get().system.autoStart;
224
+ void setAutoStart(lastAutoStart);
225
+ config.on('changed', () => {
226
+ const next = config.get().system.autoStart;
227
+ if (next !== lastAutoStart) {
228
+ lastAutoStart = next;
229
+ void setAutoStart(next);
230
+ }
231
+ });
232
+ // 扫码枪:启用则随应用自动连接 + 跟随"自动开启"开关变化
233
+ if (config.get().scanner.enabled) {
234
+ void scanner.start();
235
+ }
236
+ let lastScannerEnabled = config.get().scanner.enabled;
237
+ config.on('changed', () => {
238
+ const next = config.get().scanner.enabled;
239
+ if (next !== lastScannerEnabled) {
240
+ lastScannerEnabled = next;
241
+ if (next)
242
+ void scanner.start();
243
+ else
244
+ scanner.stop();
245
+ }
246
+ });
247
+ // ── 场景触发:闲置 / 联网 ─────────────────────────────
248
+ const idleMonitor = new IdleMonitor((minutes) => scheduler.notifyIdle(minutes));
249
+ idleMonitor.start();
250
+ const networkMonitor = new NetworkMonitor(() => scheduler.notifyNetwork());
251
+ networkMonitor.start();
252
+ // ── 优雅退出 ─────────────────────────────────────────
253
+ const shutdown = () => {
254
+ // 用 shutdown() 而非 stop():前者会把运行中/排队中的任务收尾落库,
255
+ // 否则退出时留下的 running 状态会被下次启动误判成「服务重启中断」
256
+ scheduler.shutdown();
257
+ idleMonitor.stop();
258
+ networkMonitor.stop();
259
+ wsHub.close();
260
+ httpServer.close();
261
+ // killAll 现在会真正终止子进程树(旧实现只清状态,Windows 上会留下孤儿脚本)
262
+ scriptRunner.killAll();
263
+ wechatBot.shutdown();
264
+ scanner.shutdown();
265
+ clipboardWatch.shutdown();
266
+ instanceLock.release();
267
+ console.log('[deskpet] 已退出');
268
+ process.exit(0);
269
+ };
270
+ process.on('SIGINT', shutdown);
271
+ process.on('SIGTERM', shutdown);
272
+ console.log('[deskpet] 服务就绪');
273
+ }
274
+ /** 监听端口;端口被占用时给出提示并退出(取代单实例锁) */
275
+ function listen(app, port, host) {
276
+ return new Promise((resolve, reject) => {
277
+ const server = app.listen(port, host, () => resolve(server));
278
+ server.on('error', (err) => {
279
+ if (err.code === 'EADDRINUSE') {
280
+ reject(new Error(`端口 ${port} 已被占用,DeskPet 可能已在运行(访问 ${dashboardUrl(host, port)})`));
281
+ }
282
+ else {
283
+ reject(err);
284
+ }
285
+ });
286
+ });
287
+ }
288
+ main().catch((err) => {
289
+ console.error('[deskpet] 启动失败:', err);
290
+ process.exit(1);
291
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 系统内置动作 — 锁屏 / 打开回收站 / 刷新桌面 / 清剪贴板
3
+ * 无 Electron 依赖,全部走系统命令;未适配的平台返回失败
4
+ */
5
+ import { exec } from 'node:child_process';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { clearClipboard } from "../../utils/clipboard.js";
9
+ import { openPath } from "../../utils/open.js";
10
+ function runCmd(cmd) {
11
+ return new Promise((resolve) => {
12
+ exec(cmd, { timeout: 10000 }, (err) => {
13
+ resolve(err ? { ok: false, error: err.message } : { ok: true });
14
+ });
15
+ });
16
+ }
17
+ /** 锁屏(macOS: Ctrl+Cmd+Q;Windows: rundll32 锁定工作站) */
18
+ export const lockScreenAction = async (_task, ctx) => {
19
+ const cmd = process.platform === 'darwin'
20
+ ? 'osascript -e \'tell application "System Events" to keystroke "q" using {control down, command down}\''
21
+ : process.platform === 'win32'
22
+ ? 'rundll32.exe user32.dll,LockWorkStation'
23
+ : '';
24
+ if (!cmd)
25
+ return { success: false, error: `暂不支持的平台: ${process.platform}` };
26
+ const r = await runCmd(cmd);
27
+ if (r.ok) {
28
+ ctx.log('已锁定屏幕');
29
+ return { success: true };
30
+ }
31
+ return { success: false, error: r.error };
32
+ };
33
+ /** 打开回收站 */
34
+ export const openTrashAction = async (_task, ctx) => {
35
+ const target = process.platform === 'darwin'
36
+ ? join(homedir(), '.Trash')
37
+ : process.platform === 'win32'
38
+ ? 'shell:RecycleBinFolder'
39
+ : 'trash:///';
40
+ const err = await openPath(target);
41
+ if (err)
42
+ return { success: false, error: err };
43
+ ctx.log('已打开回收站');
44
+ return { success: true };
45
+ };
46
+ /** 刷新桌面(macOS: 重启 Finder;Windows: 重启资源管理器) */
47
+ export const refreshDesktopAction = async (_task, ctx) => {
48
+ const cmd = process.platform === 'darwin'
49
+ ? 'killall Finder'
50
+ : process.platform === 'win32'
51
+ ? 'taskkill /F /IM explorer.exe & start explorer.exe'
52
+ : '';
53
+ if (!cmd)
54
+ return { success: false, error: `暂不支持的平台: ${process.platform}` };
55
+ const r = await runCmd(cmd);
56
+ if (r.ok) {
57
+ ctx.log('已刷新桌面');
58
+ return { success: true };
59
+ }
60
+ return { success: false, error: r.error };
61
+ };
62
+ /** 清空剪贴板 */
63
+ export const clearClipboardAction = async (_task, ctx) => {
64
+ await clearClipboard();
65
+ ctx.log('已清空剪贴板');
66
+ return { success: true };
67
+ };
@@ -0,0 +1,27 @@
1
+ /** 注册表由 main 注入到 ActionContext.requestClipboardWatch */
2
+ export const clipboardWatchAction = async (task, ctx) => {
3
+ const p = task.config.actionParams ?? {};
4
+ const pattern = String(p['pattern'] ?? '').trim();
5
+ const targetTaskId = String(p['targetTaskId'] ?? '').trim();
6
+ if (!pattern || !targetTaskId) {
7
+ return {
8
+ success: false,
9
+ error: '需要参数 pattern(正则)与 targetTaskId(命中后执行的任务)'
10
+ };
11
+ }
12
+ const arm = ctx.armClipboardWatch;
13
+ if (!arm) {
14
+ return { success: false, error: '剪贴板监视器未接入' };
15
+ }
16
+ try {
17
+ arm({ pattern, targetTaskId, passValueAsArg: p['passValueAsArg'] !== false });
18
+ }
19
+ catch (err) {
20
+ return { success: false, error: String(err) };
21
+ }
22
+ ctx.log(`已武装剪贴板监听: /${pattern}/ → ${targetTaskId}`);
23
+ return {
24
+ success: true,
25
+ stdout: `正在监听剪贴板 /${pattern}/,命中后执行任务 ${targetTaskId}`
26
+ };
27
+ };
@@ -0,0 +1,41 @@
1
+ export const httpRequestAction = async (task, ctx) => {
2
+ const p = task.config.actionParams;
3
+ const url = p.url;
4
+ if (!url)
5
+ return { success: false, error: '未指定 url' };
6
+ const method = String(p.method ?? 'GET').toUpperCase();
7
+ const headers = p.headers ?? {};
8
+ const body = p.body;
9
+ const timeoutMs = typeof p.timeoutMs === 'number' ? p.timeoutMs : 15000;
10
+ const successCodes = p.successCodes ?? [];
11
+ const controller = new AbortController();
12
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
13
+ try {
14
+ const resp = await fetch(url, {
15
+ method,
16
+ headers: { 'User-Agent': 'DeskPet/2.0', ...headers },
17
+ body: body === undefined || body === null
18
+ ? undefined
19
+ : typeof body === 'string'
20
+ ? body
21
+ : JSON.stringify(body),
22
+ signal: controller.signal
23
+ });
24
+ const ok = successCodes.length > 0
25
+ ? successCodes.includes(resp.status)
26
+ : resp.status >= 200 && resp.status < 300;
27
+ const text = (await resp.text()).slice(0, 2000);
28
+ ctx.log(`HTTP ${method} ${url} → ${resp.status}`);
29
+ return {
30
+ success: ok,
31
+ stdout: text,
32
+ error: ok ? undefined : `HTTP ${resp.status}`
33
+ };
34
+ }
35
+ catch (err) {
36
+ return { success: false, error: `请求失败: ${String(err)}` };
37
+ }
38
+ finally {
39
+ clearTimeout(timer);
40
+ }
41
+ };
@@ -0,0 +1,183 @@
1
+ const DEFAULT_TIMEOUT_MS = 600_000;
2
+ const POLL_INTERVAL_MS = 3_000;
3
+ function normalizeBase(raw) {
4
+ return String(raw ?? '').trim().replace(/\/+$/, '');
5
+ }
6
+ function basicAuth(user, pass) {
7
+ return 'Basic ' + Buffer.from(`${user}:${pass}`, 'utf-8').toString('base64');
8
+ }
9
+ /** "folder/my job" → "job/folder/job/my%20job" */
10
+ function jobPath(job) {
11
+ const segs = String(job ?? '')
12
+ .split('/')
13
+ .map((s) => encodeURIComponent(s.trim()))
14
+ .filter(Boolean);
15
+ return segs.map((s) => `job/${s}`).join('/');
16
+ }
17
+ /** 构建参数:JSON 对象 / 每行 key=value / 空 */
18
+ function parseParams(raw) {
19
+ if (!raw)
20
+ return {};
21
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
22
+ const out = {};
23
+ for (const [k, v] of Object.entries(raw)) {
24
+ out[k] = typeof v === 'object' ? JSON.stringify(v) : String(v);
25
+ }
26
+ return out;
27
+ }
28
+ const text = String(raw).trim();
29
+ if (!text)
30
+ return {};
31
+ if (text.startsWith('{')) {
32
+ const obj = JSON.parse(text);
33
+ const out = {};
34
+ for (const [k, v] of Object.entries(obj)) {
35
+ out[k] = typeof v === 'object' ? JSON.stringify(v) : String(v);
36
+ }
37
+ return out;
38
+ }
39
+ const out = {};
40
+ for (const line of text.split('\n')) {
41
+ const t = line.trim();
42
+ if (!t)
43
+ continue;
44
+ const i = t.indexOf('=');
45
+ if (i <= 0)
46
+ throw new Error(`构建参数每行应为 key=value,收到:${t}`);
47
+ out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
48
+ }
49
+ return out;
50
+ }
51
+ /** 获取 CSRF crumb;未启用 CSRF 时返回空 */
52
+ async function fetchCrumb(base, headers) {
53
+ try {
54
+ const r = await fetch(`${base}/crumbIssuer/api/json`, { headers });
55
+ if (!r.ok)
56
+ return {};
57
+ const j = (await r.json());
58
+ if (j.crumbRequestField && j.crumb)
59
+ return { [j.crumbRequestField]: j.crumb };
60
+ return {};
61
+ }
62
+ catch {
63
+ return {};
64
+ }
65
+ }
66
+ function sleep(ms) {
67
+ return new Promise((r) => setTimeout(r, ms));
68
+ }
69
+ /** 轮询直到返回非 null 或超时(返回 null 表示超时) */
70
+ async function pollUntil(fn, timeoutMs) {
71
+ const start = Date.now();
72
+ for (;;) {
73
+ const v = await fn();
74
+ if (v !== null && v !== undefined)
75
+ return v;
76
+ if (Date.now() - start >= timeoutMs)
77
+ return null;
78
+ await sleep(POLL_INTERVAL_MS);
79
+ }
80
+ }
81
+ export const jenkinsBuildAction = async (task, ctx) => {
82
+ const p = task.config.actionParams ?? {};
83
+ const job = String(p['jobName'] ?? '').trim();
84
+ if (!job)
85
+ return { success: false, error: '未填写 Jenkins Job 名称' };
86
+ const j = (ctx.config.get().jenkins ?? {});
87
+ const base = normalizeBase(j.url ?? '');
88
+ if (!base) {
89
+ return { success: false, error: '未配置 Jenkins 地址,请先到「系统设置 → Jenkins」填写 URL 与账号' };
90
+ }
91
+ let params;
92
+ try {
93
+ params = parseParams(p['params']);
94
+ }
95
+ catch (err) {
96
+ return { success: false, error: `构建参数解析失败: ${err.message}` };
97
+ }
98
+ const parsedTimeout = Number(p['timeoutMs']);
99
+ const timeoutMs = Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : DEFAULT_TIMEOUT_MS;
100
+ const headers = { Authorization: basicAuth(j.username ?? '', j.password ?? '') };
101
+ Object.assign(headers, await fetchCrumb(base, headers));
102
+ const hasParams = Object.keys(params).length > 0;
103
+ const endpoint = hasParams ? 'buildWithParameters' : 'build';
104
+ const qs = hasParams ? `?${new URLSearchParams(params).toString()}` : '';
105
+ const triggerUrl = `${base}/${jobPath(job)}/${endpoint}${qs}`;
106
+ ctx.log(`触发 Jenkins 构建:${job}`);
107
+ let res;
108
+ try {
109
+ res = await fetch(triggerUrl, { method: 'POST', headers });
110
+ }
111
+ catch (err) {
112
+ return { success: false, error: `请求 Jenkins 失败: ${err.message}(请检查地址与网络)` };
113
+ }
114
+ if (res.status === 401 || res.status === 403) {
115
+ return {
116
+ success: false,
117
+ error: `Jenkins 认证失败(HTTP ${res.status}),请检查用户名与密码/API Token`
118
+ };
119
+ }
120
+ if (!res.ok && res.status !== 201) {
121
+ const detail = (await res.text().catch(() => '')).slice(0, 200);
122
+ return { success: false, error: `触发构建失败:HTTP ${res.status} ${detail}` };
123
+ }
124
+ const location = res.headers.get('location') ?? '';
125
+ if (!location) {
126
+ return { success: false, error: 'Jenkins 未返回队列地址(响应缺少 Location 头)' };
127
+ }
128
+ // 1) 等排队任务落到具体构建号
129
+ const queueUrl = location.endsWith('/') ? `${location}api/json` : `${location}/api/json`;
130
+ const buildNumber = await pollUntil(async () => {
131
+ try {
132
+ const r = await fetch(queueUrl, { headers });
133
+ if (!r.ok)
134
+ return null;
135
+ const q = (await r.json());
136
+ return q.executable?.number ?? null;
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }, Math.min(timeoutMs, 120_000));
142
+ if (buildNumber == null) {
143
+ return { success: false, error: '等待 Jenkins 开始构建超时(任务可能一直在排队)' };
144
+ }
145
+ // 2) 等构建结束拿到结果
146
+ const buildApi = `${base}/${jobPath(job)}/${buildNumber}/api/json`;
147
+ const consoleUrl = `${base}/${jobPath(job)}/${buildNumber}/console`;
148
+ const result = await pollUntil(async () => {
149
+ try {
150
+ const r = await fetch(buildApi, { headers });
151
+ if (!r.ok)
152
+ return null;
153
+ const b = (await r.json());
154
+ if (b.building)
155
+ return null;
156
+ return b.result ?? null;
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }, timeoutMs);
162
+ if (result == null) {
163
+ return {
164
+ success: false,
165
+ error: `等待构建结果超时(>${Math.round(timeoutMs / 1000)}s)`,
166
+ stdout: `Job: ${job}\n构建号: #${buildNumber}\n控制台: ${consoleUrl}`
167
+ };
168
+ }
169
+ const ok = result === 'SUCCESS';
170
+ const summary = [
171
+ `Job: ${job}`,
172
+ `构建号: #${buildNumber}`,
173
+ `结果: ${result}`,
174
+ `控制台: ${consoleUrl}`
175
+ ].join('\n');
176
+ ctx.log(`构建 #${buildNumber} 结果 ${result}`);
177
+ return {
178
+ success: ok,
179
+ exitCode: ok ? 0 : 1,
180
+ stdout: summary,
181
+ error: ok ? undefined : `Jenkins 构建结果:${result}(#${buildNumber})`
182
+ };
183
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * open_app 动作 — 打开本地程序 / 文件 / 文件夹 / URL
3
+ * action_params:
4
+ * - url: 打开 URL(系统默认浏览器)
5
+ * - target: 文件/文件夹路径(系统默认程序)
6
+ * - app: 应用名或可执行文件路径
7
+ */
8
+ import { openApp, openPath, openUrl } from "../../utils/open.js";
9
+ export const openAppAction = async (task, ctx) => {
10
+ const p = task.config.actionParams;
11
+ const url = p.url;
12
+ const target = p.target;
13
+ const app = p.app;
14
+ try {
15
+ if (url) {
16
+ const err = await openUrl(String(url));
17
+ if (err)
18
+ return { success: false, error: `打开失败: ${err}` };
19
+ ctx.log(`已打开 URL: ${url}`);
20
+ return { success: true };
21
+ }
22
+ if (target) {
23
+ const err = await openPath(String(target));
24
+ if (err)
25
+ return { success: false, error: `打开失败: ${err}` };
26
+ ctx.log(`已打开: ${target}`);
27
+ return { success: true };
28
+ }
29
+ if (app) {
30
+ const err = await openApp(String(app));
31
+ if (err)
32
+ return { success: false, error: `打开失败: ${err}` };
33
+ ctx.log(`已打开应用: ${app}`);
34
+ return { success: true };
35
+ }
36
+ return { success: false, error: '未指定 url/target/app 任一参数' };
37
+ }
38
+ catch (err) {
39
+ return { success: false, error: `打开失败: ${String(err)}` };
40
+ }
41
+ };