@quecue/dev-notify 0.0.0-stage → 0.1.0-alpha.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dev-notify contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,151 @@
1
- # Temporary Holding Version
1
+ # dev-notify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 跨平台开发任务通知工具,使用 **TypeScript / Node.js**,通过 npm 分发,无需 Python 或独立可执行文件。通用通知不依赖 Claude Code、Unity 或项目目录。
4
+
5
+ **当前为未发布的 `0.1.0-alpha.1`,`private: true` 防止误发布。npm 包名尚未核定,请勿安装未经确认的同名公开包。**
6
+
7
+ ## 安装与使用
8
+
9
+ 要求 Node.js 22 或以上,首发目标 Windows、macOS。运行时依赖锁定版本的 `@napi-rs/keyring`,由 npm 安装对应系统的预编译原生组件,不需要 Python、Rust 或 C++ 编译器。不要省略 npm optional 依赖。
10
+
11
+ 在工程目录执行:
12
+
13
+ ```sh
14
+ npm ci --ignore-scripts
15
+ npm run build
16
+ node dist/cli.js setup
17
+ node dist/cli.js doctor
18
+ ```
19
+
20
+ 配置完成后发送:
21
+
22
+ ```sh
23
+ node dist/cli.js send --title "构建完成" --content "安装包已生成"
24
+ node dist/cli.js send --title "测试结果" --content-file result.txt
25
+ node dist/cli.js send --title "任务输出" --stdin
26
+ node dist/cli.js send --title "分段预览" --content-file result.txt --dry-run
27
+ ```
28
+
29
+ 若需要本地安装为 `dev-notify` 命令,在构建后运行 `npm install --global .`,之后可执行 `dev-notify setup`、`dev-notify send ...`。这是本地源码安装,不是从公开 registry 安装;本工程不会自动进行全局安装或发布。
30
+
31
+ 正式包的安装/升级/卸载由 npm 负责。**卸载程序不会自动清除用户凭据**;如需清理,请先交互执行 `dev-notify credentials clear`,再卸载。该命令只删除当前配置引用的 Node 凭据,不删除其他账号、其他配置或 Python 原型凭据。
32
+
33
+ ## 已提供的功能
34
+
35
+ - Telegram、飞书双渠道通知,按渲染后的长度分段,不静默截断。
36
+ - 文本、严格 UTF-8 文件或标准输入;支持渠道选择、通知级别、辅助信息。
37
+ - 交互配置、隐藏输入、系统凭据存储、环境变量覆盖。
38
+ - 配置更新冲突检测、失败恢复、凭据清理。
39
+ - 可选测试通知:只有额外确认后才发送,默认不发送。
40
+ - `paths`、`doctor`、`status`、`send --dry-run` 离线诊断。
41
+
42
+ **交付范围是“安装 → 配置 → 存储凭据 → 发送 → 清理凭据”的通用通知链路。**当前不安装应用 hooks、不启动后台服务、不做远程控制。`integration list` 仅报告集成未启用;Claude Code 回复、自动配对、任务队列不属于本版已实现能力。
43
+
44
+ ## 配置向导
45
+
46
+ ```sh
47
+ node dist/cli.js setup
48
+ ```
49
+
50
+ 1. 选择 Telegram、飞书或两者。
51
+ 2. 隐藏输入 Token、webhook 和可选签名密钥。无法安全隐藏输入时停止,不回退到明文回显。
52
+ 3. Telegram 手动填写数字 chat_id,多个用逗号分隔;用户需要先启动 Bot。不会自动启动 getUpdates 配对。
53
+ 4. 展示渠道和接收人数,最终确认后保存。输入错误可以重试,Ctrl+C 取消。
54
+ 5. 保存后单独询问是否发送固定内容的测试通知。测试失败不会撤销已成功保存的配置。
55
+
56
+ 编辑已有配置需要先确认;密钥留空保留已存值,可选签名输入 `-` 清除。未选渠道会禁用,新条目只保留所选渠道的凭据。环境变量仍然优先;向导会提示存在覆盖,测试使用最终生效值,而不是忽略环境的刚输入值。
57
+
58
+ 自动化使用无密钥模板和 CI secrets:
59
+
60
+ ```sh
61
+ node dist/cli.js setup --template --yes
62
+ ```
63
+
64
+ 模板永不覆盖已有配置。`--yes` 只能配合 `--template`,不能跳过交互密钥保存确认。
65
+
66
+ ## 系统凭据与兼容性
67
+
68
+ Node 版使用 **Windows Credential Manager / macOS Keychain**,通过原生 Node API 传递秘密,不调用 Python,不将秘密放进命令行参数,不写入明文 JSON。
69
+
70
+ - 底层库:`@napi-rs/keyring`,固定版本记录于 lockfile。
71
+ - service:`dev-notify.node.credentials.v1`;account:随机 32 位十六进制 ID。
72
+ - 配置引用:`"credentials": {"backend":"keyring","id":"..."}`。
73
+ - 为兼容 Windows 通用凭据 blob 大小,凭据包限制 2400 UTF-8 字节,超限报错,不截断。
74
+ - 原生组件加载失败、存储锁定或拒绝访问时明确报错,不降级为明文文件。macOS 可能要求系统授权;离线不代表一定无系统提示。
75
+ - 默认不枚举系统条目,只访问当前引用或本次新建的随机条目。
76
+
77
+ 保留的 Python 原型使用 `backend: "system"`,Windows 是 DPAPI 文件,与 Node Credential Manager 不是同一存储。Node 不会读取或删除它。遇到旧配置可运行 `setup` 重新输入生成 Node 引用;原 Python 凭据保持不变。不要用 Python 原型编辑 Node 的 `keyring` 配置。
78
+
79
+ Python 源码及测试仍在 `src/dev_notify/`、`tests/`、`tools/`,本轮没有删除。历史文档位于 `docs/PYTHON_PROTOTYPE.md`、`docs/PYTHON_SECURITY.md`,不进入 npm 包。
80
+
81
+ ## 路径、模板和环境变量
82
+
83
+ | 平台 | 配置 | 数据预留目录 |
84
+ | --- | --- | --- |
85
+ | Windows | `%APPDATA%\DevNotify\config.json` | `%LOCALAPPDATA%\DevNotify\data` |
86
+ | macOS | `~/Library/Application Support/DevNotify/config/config.json` | `~/Library/Application Support/DevNotify/data` |
87
+
88
+ `DEV_NOTIFY_HOME` 必须是绝对路径,配置位于其 `config/config.json` 下。也可用 `--config <路径>` 显式指定文件。显式文件缺失时报错;默认文件缺失时允许仅使用环境变量。
89
+
90
+ ```json
91
+ {
92
+ "version": 1,
93
+ "telegram": {"enabled": true, "chat_ids": [], "token_env": "TELEGRAM_BOT_TOKEN"},
94
+ "feishu": {"enabled": true, "webhook_env": "FEISHU_WEBHOOK", "secret_env": "FEISHU_SECRET"}
95
+ }
96
+ ```
97
+
98
+ - `TELEGRAM_BOT_TOKEN`:Token。
99
+ - `TELEGRAM_CHAT_ID`、`TELEGRAM_CHAT_IDS`:主接收人和逗号分隔接收人,任一显式存在即完全替换文件名单;显式空名单撤销接收人。大整数 chat_id 必须用字符串。
100
+ - `FEISHU_WEBHOOK`:飞书中国站 HTTPS 自定义机器人地址。
101
+ - `FEISHU_SECRET`:可选签名密钥。
102
+
103
+ 凭据环境变量显式存在时优先,包括空值;不存在才延迟读取系统引用,同次配置加载只读取一次。禁用渠道不会仅为读取系统秘密而打开后端。Token/webhook/secret 不能直接填写进 JSON。
104
+
105
+ ## 更新与清理安全
106
+
107
+ 保存流程:写入新随机系统条目 → 校验可读 → 原子替换 JSON → 删除旧 Node 条目。配置切换前失败保留旧配置,并尝试清理本次新凭据;清理失败只报告非敏感引用 ID。旧 Python 条目从不删除。
108
+
109
+ 配置写入使用排他 `.setup.lock`,写前及切换前核对原文件。不要在向导保存时用编辑器并发改配置;编辑器不遵守此锁。断电/强杀可能留下锁或孤立条目,存储和 JSON 不是跨系统原子事务。确认没有向导运行后才能处理残留锁;程序不会擅自扫描并删除不明条目。
110
+
111
+ ```sh
112
+ node dist/cli.js credentials clear
113
+ ```
114
+
115
+ 清理必须交互确认,先去除当前 JSON 引用,再尝试删除其 Node 条目;环境变量保持不变。共享同一旧引用的配置副本也会受影响,操作前会告知。删除失败明确警告,不伪装成已清理。
116
+
117
+ ## 格式、发送与退出码
118
+
119
+ 支持标题、粗体、斜体、删除线、行内代码、代码块、引用、列表、任务列表、等宽表格。Telegram 按 UTF-16 长度,飞书按 UTF-8 请求预算分段,emoji 不从代理对中切开。跨段标签补齐,标题 `(1/N)`,每段保留辅助信息。链接显示为“文字 (URL)”,纯文本回退保留地址。不是完整 CommonMark 引擎,复杂转义或嵌套不保证完全原样排版。
120
+
121
+ 单接收人中途失败停止其后续分段,Telegram 继续其他接收人。HTML/卡片格式错误回退;Telegram 明确 `429` 按服务端要求有限重试。超时不盲目重发,不承诺恰好一次投递。
122
+
123
+ - `send` 会把指定内容上传到第三方,请检查接收人与敏感信息。
124
+ - `--dry-run` 不输出正文/凭据,不联网。
125
+ - `doctor` / `status` 可能读取本地凭据,不验证网络和实际授权。
126
+ - 文件缺失、损坏配置、UTF-8 错误立即报错,不退回发送标题。
127
+
128
+ | 代码 | 含义 |
129
+ | --- | --- |
130
+ | 0 | 所选已配置渠道完整发送成功,或配置/只读操作成功 |
131
+ | 1 | 部分/全部投递失败;或 doctor/status 没有可用渠道 |
132
+ | 2 | 参数、配置、文件或凭据操作错误 |
133
+ | 130 | 用户取消 |
134
+
135
+ 向导保存成功后测试通知失败,命令仍以保存成功结束,并明确显示测试失败,两者不混淆。
136
+
137
+ ## 开发与验证
138
+
139
+ ```sh
140
+ npm ci --ignore-scripts
141
+ npm test
142
+ npm pack --dry-run
143
+ ```
144
+
145
+ `node-src/` 是 TypeScript 源码,`dist/` 是编译输出,`test-js/` 是测试。默认测试只用假凭据和模拟后端/网络;本机原生假条目往返测试需显式设置 `DEV_NOTIFY_NATIVE_TEST=1`,且只在 Windows 启用,使用随机 ID,结束后清理。
146
+
147
+ npm 发布白名单只有 `dist/*.js`、README、SECURITY、LICENSE 和必需的 package.json;不包含 Python、测试、配置或密文。无 postinstall、自动启动和自写下载脚本;预编译原生依赖由 npm 的平台 optional 包解析,**不应使用 `--omit=optional`**。macOS x64/arm64 和 Windows x64/arm64 预编译包由依赖提供,提供包不等于本项目已实机验收。
148
+
149
+ 本机验证环境为 Windows x64、Node 24。macOS 实机、Node 最低版本、版本管理器切换及公开发布审计仍未验证;不把路径模拟或依赖支持宣称为实机完成。
150
+
151
+ 采用 MIT,见 [LICENSE](LICENSE)。安全边界见 [SECURITY.md](SECURITY.md)。未创建远程仓库、未发布 npm 包。
package/SECURITY.md ADDED
@@ -0,0 +1,45 @@
1
+ # 安全边界(Node/npm)
2
+
3
+ 当前为未发布 alpha,`private: true` 防止误发布;未宣称完成安全审计或双平台实机验收。Python 原型说明另见 `docs/PYTHON_SECURITY.md`。
4
+
5
+ ## 凭据与权限
6
+
7
+ - Node 系统凭据使用固定版本 `@napi-rs/keyring`:Windows Credential Manager / macOS Keychain。秘密通过进程内原生接口传递,不使用 shell、Python、子进程参数或明文落盘。
8
+ - Node service 为 `dev-notify.node.credentials.v1`,每份凭据使用随机 ID。JSON 只保存 `keyring` 引用,不枚举其他系统条目。
9
+ - 环境变量优先且显式空值也覆盖。环境变量和系统凭据都不能防御已被控制的同用户进程;秘密会进入 Node 内存,不能保证所有副本被安全清零。
10
+ - Keychain 可能请求系统授权;锁定、权限不足、原生库加载失败明确报错,不自动降级明文。提供预编译包不代表 macOS 权限流程已验收。
11
+ - Python 的 `system` 引用与 Node 存储隔离,不读取、不删除旧 Python 凭据;重新输入后的 Node 引用不能交给旧 Python 原型编辑。
12
+ - 交互秘密使用 raw mode 隐藏输入,异常/Ctrl+C/EOF 恢复终端状态;无法隐藏输入即拒绝,不回退回显。原生读取不保证无系统弹窗。
13
+
14
+ ## 配置事务与清理
15
+
16
+ - 最终确认前不写配置和凭据。确认后先创建新随机条目并校验,再切换 JSON,成功后删除旧 Node 条目。
17
+ - 配置切换失败保留原配置;只尝试清理本次创建的条目,清理失败只输出非敏感 ID。
18
+ - 使用排他 `.setup.lock` 和内容快照防止多个向导互相覆盖;外部编辑器不遵守锁,保存期间不要手工编辑。
19
+ - 存储和 JSON 不是跨系统原子事务。强制结束/断电可能留下锁、临时文件或孤立凭据,程序不扫描并删除未知数据。
20
+ - `credentials clear` 需要交互确认,移除当前引用并删除其 Node 条目;共享引用的配置副本会受影响,操作前告知。环境变量不会清除,删除失败明确警告。
21
+ - POSIX mode 不能代替 Windows ACL,非敏感配置仍可能暴露接收人和本机路径。
22
+
23
+ ## 网络与应用边界
24
+
25
+ - 仅非 dry-run 的 `send`,以及向导保存后**另行同意**的固定测试消息会发送通知。
26
+ - 向导测试使用环境覆盖后的最终配置;保存成功与测试失败分别报告,不伪装为全部成功。
27
+ - HTTP 启用系统 TLS 验证、禁止重定向,超时 10 秒,响应大小最多 1 MiB;底层 URL、秘密、服务端原始正文不打印。
28
+ - 网络超时可能发生服务端已接受但客户端未知,不承诺恰好一次,不能为避免失败而无限重发。
29
+ - 接收人环境变量完整替换旧名单,不隐式残留旧授权。
30
+ - 不自动读取应用会话、项目或版本控制信息,不安装 hooks、不开放监听、不注入终端、不执行远程命令。
31
+ - `doctor`、`status`、`paths` 和 dry-run 不联网;本地凭据读取可能触发系统授权。
32
+
33
+ ## npm 与测试
34
+
35
+ - 运行时有原生凭据依赖,版本和平台 optional 包锁定于 `package-lock.json`。不要省略 optional 依赖。项目无 postinstall、自写二进制下载、后台启动脚本。
36
+ - npm 白名单仅包含编译 JS、README、SECURITY、LICENSE 和 package.json。测试、Python、配置、日志、密文不进入本包。
37
+ - 第三方原生包带有其许可证与 Rust 依赖声明,公开前仍需审查依赖供应链和许可;不能把固定版本等同于完成审计。
38
+ - 默认测试用假凭据/临时目录和模拟系统后端、网络。`DEV_NOTIFY_NATIVE_TEST=1` 显式开启 Windows 原生测试,仅创建本次随机 ID 的假条目,结束清理,不读取用户现有条目。
39
+ - npm 卸载程序不自动删除用户配置/凭据。需要清理时先运行交互清理命令,不在卸载生命周期脚本偷偷操作系统存储。
40
+
41
+ ## 公开发布
42
+
43
+ 核对包名、源码权属、依赖许可;扫描完整目录、实际 npm 包和 Git 历史。密钥泄漏必须撤销轮换,删除文件不能撤销泄漏。正式公开前应设置私密漏洞报告渠道;当前没有公开安全联络地址。
44
+
45
+ 本机已验证 Windows x64;macOS 原生权限流程、其他架构及 Node 最低版本仍未实机验证,不宣称全面兼容。
@@ -0,0 +1,152 @@
1
+ import { createHmac } from 'node:crypto';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { escapeHtml, feishuParts, parseMarkdown, partTitle, telegramParts } from './formatting.js';
4
+ export class TransportError extends Error {
5
+ }
6
+ const icons = { info: 'ℹ️', success: '✅', warn: '⚠️', error: '❌' };
7
+ const colors = { info: 'blue', success: 'green', warn: 'yellow', error: 'red' };
8
+ const bytes = (data) => Buffer.byteLength(JSON.stringify(data), 'utf8');
9
+ export async function requestJson(url, payload, fetcher = fetch) {
10
+ try {
11
+ const response = await fetcher(url, { method: 'POST', body: JSON.stringify(payload),
12
+ headers: { 'Content-Type': 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(10000) });
13
+ if (!response.body)
14
+ throw new TransportError('服务端响应为空');
15
+ const reader = response.body.getReader();
16
+ const chunks = [];
17
+ let size = 0;
18
+ try {
19
+ while (true) {
20
+ const { done, value } = await reader.read();
21
+ if (done)
22
+ break;
23
+ size += value.byteLength;
24
+ if (size > 1024 * 1024) {
25
+ await reader.cancel();
26
+ throw new TransportError('服务端响应超过大小限制');
27
+ }
28
+ chunks.push(value);
29
+ }
30
+ }
31
+ finally {
32
+ reader.releaseLock();
33
+ }
34
+ const result = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)));
35
+ if (!result || typeof result !== 'object' || Array.isArray(result))
36
+ throw new TransportError('服务端未返回 JSON 对象');
37
+ return result;
38
+ }
39
+ catch (error) {
40
+ if (error instanceof TransportError)
41
+ throw error;
42
+ throw new TransportError('网络请求失败或响应无效;未自动重发,投递状态可能未知');
43
+ }
44
+ }
45
+ function text(title, content, footer, icon, html) {
46
+ const sections = [`${icon} ${html ? `<b>${escapeHtml(title)}</b>` : title}`];
47
+ if (content)
48
+ sections.push(content);
49
+ if (footer)
50
+ sections.push('────────\n' + (html ? `<i>${escapeHtml(footer)}</i>` : footer));
51
+ return sections.join('\n\n');
52
+ }
53
+ function card(message, title, content) {
54
+ const elements = [];
55
+ if (content)
56
+ elements.push({ tag: 'markdown', content });
57
+ if (message.footer)
58
+ elements.push({ tag: 'hr' }, { tag: 'note', elements: [{ tag: 'plain_text', content: message.footer }] });
59
+ return { msg_type: 'interactive', card: { config: { wide_screen_mode: true },
60
+ header: { template: colors[message.level], title: { tag: 'plain_text', content: title } }, elements } };
61
+ }
62
+ export function makePlan(channel, message, config) {
63
+ if (!message.title.trim())
64
+ throw new Error('通知标题不能为空');
65
+ const blocks = parseMarkdown(message.content);
66
+ if (channel === 'telegram') {
67
+ const icon = icons[message.level];
68
+ const reserve = String(Math.max(1, message.content.length)).length * 2 + 16;
69
+ const budget = 3900 - text(message.title, '', message.footer, icon, false).length - reserve;
70
+ const chunks = telegramParts(blocks, budget);
71
+ const items = [];
72
+ for (const chatId of config.telegram.chatIds)
73
+ chunks.forEach((chunk, index) => {
74
+ const title = partTitle(message.title, index, chunks.length);
75
+ const plain = text(title, chunk.plain, message.footer, icon, false);
76
+ if (plain.length > 4096)
77
+ throw new Error('Telegram 分段超过消息限制');
78
+ items.push({ recipient: chatId, plain, payload: { chat_id: chatId,
79
+ text: text(title, chunk.html, message.footer, icon, true), parse_mode: 'HTML', disable_web_page_preview: true } });
80
+ });
81
+ return { channel, items, recipients: config.telegram.chatIds.length };
82
+ }
83
+ const budget = Math.min(10000, Math.floor((28000 - bytes(card(message, message.title, '')) - 512) / 6));
84
+ const chunks = feishuParts(blocks, budget);
85
+ const items = chunks.map((chunk, index) => {
86
+ const payload = card(message, partTitle(message.title, index, chunks.length), chunk);
87
+ if (bytes(payload) + 128 > 28000)
88
+ throw new Error('飞书分段超过请求大小限制');
89
+ return { recipient: 'feishu', payload };
90
+ });
91
+ return { channel, items, recipients: 1 };
92
+ }
93
+ export async function deliver(plan, config, request = requestJson, sleep = delay) {
94
+ let sent = 0, error;
95
+ const failed = new Set();
96
+ const telegram = plan.channel === 'telegram';
97
+ const url = telegram ? `https://api.telegram.org/bot${config.telegram.token}/sendMessage` : config.feishu.webhook;
98
+ const post = async (payload) => {
99
+ if (!telegram && config.feishu.secret) {
100
+ const timestamp = String(Math.floor(Date.now() / 1000));
101
+ payload.timestamp = timestamp;
102
+ payload.sign = createHmac('sha256', `${timestamp}\n${config.feishu.secret}`).update('').digest('base64');
103
+ }
104
+ let response = await request(url, payload);
105
+ if (telegram && response.error_code === 429) {
106
+ const wait = response.parameters?.retry_after;
107
+ if (typeof wait === 'number' && Number.isFinite(wait) && wait > 0 && wait <= 60) {
108
+ await sleep(wait * 1000);
109
+ response = await request(url, payload);
110
+ }
111
+ }
112
+ return response;
113
+ };
114
+ for (let index = 0; index < plan.items.length; index++) {
115
+ const item = plan.items[index];
116
+ if (failed.has(item.recipient))
117
+ continue;
118
+ const payload = structuredClone(item.payload);
119
+ try {
120
+ let response = await post(payload);
121
+ if (telegram && response.ok !== true && /parse|entit/i.test(String(response.description ?? ''))) {
122
+ delete payload.parse_mode;
123
+ payload.text = item.plain;
124
+ response = await post(payload);
125
+ }
126
+ else if (!telegram && response.code !== 0) {
127
+ let changed = false;
128
+ payload.card.elements = payload.card.elements.map((element) => {
129
+ if (element.tag !== 'markdown')
130
+ return element;
131
+ changed = true;
132
+ return { tag: 'div', text: { tag: 'lark_md', content: element.content } };
133
+ });
134
+ if (changed)
135
+ response = await post(payload);
136
+ }
137
+ if (telegram ? response.ok === true : response.code === 0)
138
+ sent++;
139
+ else {
140
+ failed.add(item.recipient);
141
+ error = '渠道拒绝发送,请检查凭据、接收人授权、签名或频率限制';
142
+ }
143
+ }
144
+ catch (cause) {
145
+ failed.add(item.recipient);
146
+ error = cause instanceof TransportError ? cause.message : '发送失败;投递状态可能未知';
147
+ }
148
+ if (!telegram && !failed.has(item.recipient) && index + 1 < plan.items.length)
149
+ await sleep(250);
150
+ }
151
+ return { channel: plan.channel, sent, planned: plan.items.length, ok: sent > 0 && sent === plan.items.length && !error, error };
152
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { realpathSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+ import { appPaths, ConfigError, createConfig, decodeUtf8, loadConfig, ready } from './config.js';
8
+ import { deliver, makePlan } from './channels.js';
9
+ import { wizard, clearCredentials } from './setup.js';
10
+ import { terminalPrompts, readText, Cancelled } from './prompts.js';
11
+ import { CredentialError } from './credentials.js';
12
+ const HELP = `dev-notify 0.1.0-alpha.1 — 跨平台开发任务通知
13
+
14
+ 命令:
15
+ send --title 标题 [--content 正文 | --content-file 文件 | --stdin]
16
+ [--channel all|telegram|feishu] [--level info|success|warn|error]
17
+ [--footer 辅助信息] [--dry-run]
18
+ setup 交互配置渠道并存入系统凭据,保存后可选测试
19
+ setup --template [--yes] 仅创建无密钥模板,不覆盖已有配置
20
+ credentials clear 确认后移除当前 Node 凭据引用和条目
21
+ paths 显示配置路径,不写文件
22
+ doctor / status 离线检查,不发消息
23
+ integration list 显示集成迁移状态
24
+
25
+ 全局选项:--config 路径、--help、--version
26
+ 系统凭据使用 Windows Credential Manager / macOS Keychain;不调用 Python,不写入明文密钥。`;
27
+ const defaults = {
28
+ env: process.env,
29
+ out: text => console.log(text),
30
+ err: text => console.error(text),
31
+ stdin: async () => { const chunks = []; for await (const chunk of process.stdin)
32
+ chunks.push(Buffer.from(chunk)); return Buffer.concat(chunks); },
33
+ confirm: async () => {
34
+ if (!process.stdin.isTTY)
35
+ throw new ConfigError('非交互环境请使用 setup --template --yes');
36
+ return /^(y|yes)$/i.test(await readText('确认创建模板?[y/N] '));
37
+ },
38
+ send: deliver,
39
+ };
40
+ export async function main(argv = process.argv.slice(2), overrides = {}) {
41
+ const context = { ...defaults, ...overrides };
42
+ try {
43
+ let parsed;
44
+ try {
45
+ parsed = parseArgs({ args: argv, allowPositionals: true, strict: true, options: {
46
+ help: { type: 'boolean', short: 'h' }, version: { type: 'boolean' }, config: { type: 'string' },
47
+ title: { type: 'string' }, content: { type: 'string' }, 'content-file': { type: 'string' }, stdin: { type: 'boolean' },
48
+ channel: { type: 'string' }, level: { type: 'string' }, footer: { type: 'string' }, 'dry-run': { type: 'boolean' },
49
+ template: { type: 'boolean' }, yes: { type: 'boolean' },
50
+ } });
51
+ }
52
+ catch {
53
+ throw new ConfigError('参数无效,请使用 --help;不回显可能含秘密的参数');
54
+ }
55
+ const { values, positionals } = parsed;
56
+ if (values.help) {
57
+ context.out(HELP);
58
+ return 0;
59
+ }
60
+ if (values.version) {
61
+ context.out('dev-notify 0.1.0-alpha.1');
62
+ return 0;
63
+ }
64
+ const [command, action] = positionals;
65
+ const allowed = {
66
+ send: ['title', 'content', 'content-file', 'stdin', 'channel', 'level', 'footer', 'dry-run'],
67
+ setup: ['template', 'yes'], paths: [], doctor: [], status: [], integration: [], credentials: [],
68
+ };
69
+ if (!command || !Object.hasOwn(allowed, command) || positionals.length > (['integration', 'credentials'].includes(command) ? 2 : 1)
70
+ || Object.keys(values).some(key => key !== 'config' && !allowed[command].includes(key)))
71
+ throw new ConfigError('命令或选项组合无效,请使用 --help');
72
+ const paths = appPaths(process.platform, context.env);
73
+ const file = values.config ? path.resolve(values.config) : paths.config;
74
+ if (command === 'paths') {
75
+ context.out(JSON.stringify({ config: file, data: paths.data }, null, 2));
76
+ return 0;
77
+ }
78
+ if (command === 'integration') {
79
+ if (action !== 'list')
80
+ throw new ConfigError('当前只支持 integration list');
81
+ context.out('claude-code: 尚未迁入 Node 版,未安装 hooks 或启用远程回复');
82
+ return 0;
83
+ }
84
+ if (command === 'credentials') {
85
+ if (action !== 'clear')
86
+ throw new ConfigError('当前只支持 credentials clear,且必须交互确认');
87
+ return await clearCredentials(file, context.prompts ?? terminalPrompts()) ? 0 : 130;
88
+ }
89
+ if (command === 'setup') {
90
+ if (!values.template) {
91
+ if (values.yes)
92
+ throw new ConfigError('--yes 只允许与 --template 同时使用,不能跳过密钥保存确认');
93
+ return await wizard(file, {
94
+ prompts: context.prompts, env: context.env,
95
+ testSend: async (config) => {
96
+ let count = 0, ok = true;
97
+ for (const channel of ['telegram', 'feishu']) {
98
+ if (!ready(config, channel))
99
+ continue;
100
+ count++;
101
+ const plan = makePlan(channel, { title: 'dev-notify 配置测试', content: '这是一条由你确认发送的测试通知。', footer: '', level: 'info' }, config);
102
+ const result = await context.send(plan, config);
103
+ context.out(`${channel}: 测试已发送 ${result.sent}/${result.planned}`);
104
+ ok = ok && result.ok;
105
+ }
106
+ return count > 0 && ok;
107
+ },
108
+ }) ? 0 : 130;
109
+ }
110
+ context.out(`将创建无密钥模板: ${file};不会联网或修改 hooks`);
111
+ if (!values.yes && !await context.confirm()) {
112
+ context.out('已取消,未写入');
113
+ return 130;
114
+ }
115
+ await createConfig(file);
116
+ context.out('已创建模板;请配置环境凭据和接收人');
117
+ return 0;
118
+ }
119
+ const config = await loadConfig(file, values.config !== undefined, context.env);
120
+ if (command === 'doctor' || command === 'status') {
121
+ context.out(`配置路径: ${file}`);
122
+ for (const channel of ['telegram', 'feishu'])
123
+ context.out(`${channel}: ${ready(config, channel) ? '配置齐全,网络未验证' : '未启用或配置不完整'}`);
124
+ context.out('凭据: 环境变量优先,其次 Windows Credential Manager / macOS Keychain;检查不联网,系统可能要求授权');
125
+ return ready(config, 'telegram') || ready(config, 'feishu') ? 0 : 1;
126
+ }
127
+ if (!values.title?.trim())
128
+ throw new ConfigError('send 必须指定非空 --title');
129
+ if ([values.content !== undefined, values['content-file'] !== undefined, !!values.stdin].filter(Boolean).length > 1)
130
+ throw new ConfigError('正文来源只能指定一个');
131
+ const level = values.level ?? 'info';
132
+ const channel = values.channel ?? 'all';
133
+ if (!['info', 'success', 'warn', 'error'].includes(level) || !['all', 'telegram', 'feishu'].includes(channel))
134
+ throw new ConfigError('渠道或通知级别无效');
135
+ let content = values.content ?? values.title;
136
+ if (values['content-file'] !== undefined)
137
+ content = decodeUtf8(await readFile(values['content-file']));
138
+ if (values.stdin)
139
+ content = decodeUtf8(await context.stdin());
140
+ const message = { title: values.title, content, footer: values.footer ?? '', level: level };
141
+ const selected = channel === 'all' ? ['telegram', 'feishu'] : [channel];
142
+ let failed = false, planned = 0;
143
+ for (const name of selected) {
144
+ if (!ready(config, name)) {
145
+ if (channel !== 'all')
146
+ throw new ConfigError('指定渠道未启用或配置不完整');
147
+ continue;
148
+ }
149
+ planned++;
150
+ let plan;
151
+ try {
152
+ plan = makePlan(name, message, config);
153
+ }
154
+ catch {
155
+ context.err(`${name}: 无法生成安全分段,请缩短标题/辅助信息或检查正文格式`);
156
+ failed = true;
157
+ continue;
158
+ }
159
+ if (values['dry-run']) {
160
+ context.out(`${name}: 接收目标 ${plan.recipients},预计请求 ${plan.items.length};未发送`);
161
+ continue;
162
+ }
163
+ const result = await context.send(plan, config);
164
+ context.out(`${name}: 已发送 ${result.sent}/${result.planned}`);
165
+ if (!result.ok) {
166
+ failed = true;
167
+ context.err(result.error ?? '投递失败');
168
+ }
169
+ }
170
+ if (!planned)
171
+ throw new ConfigError('没有可发送的渠道,请配置环境凭据和接收人');
172
+ return failed ? 1 : 0;
173
+ }
174
+ catch (error) {
175
+ if (error instanceof Cancelled) {
176
+ context.out('已取消,未继续执行。');
177
+ return 130;
178
+ }
179
+ context.err(error instanceof ConfigError || error instanceof CredentialError ? error.message : '操作失败,请检查文件、权限或输入;不输出可能含秘密的底层异常');
180
+ return 2;
181
+ }
182
+ }
183
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
184
+ process.exitCode = await main();
185
+ }