oh-my-dws 0.0.0-stage → 0.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.
- package/README.md +192 -2
- package/bin/oh-my-dws.js +384 -0
- package/docs//344/275/277/347/224/250/350/257/264/346/230/216.md +189 -0
- package/package.json +50 -4
- package/src/actions.js +452 -0
- package/src/archive.js +339 -0
- package/src/auth.js +127 -0
- package/src/dws.js +50 -0
- package/src/media.js +90 -0
- package/src/paths.js +40 -0
- package/src/server.js +318 -0
- package/src/util.js +85 -0
- package/web/index.html +1409 -0
package/README.md
CHANGED
|
@@ -1,3 +1,193 @@
|
|
|
1
|
-
#
|
|
1
|
+
# oh-my-dws
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把钉钉项目群的消息**归档到本地**,并用一个本地网页控制台浏览、搜索、增量拉取、导出 Markdown。
|
|
4
|
+
按「一群一目录」组织,数据全部落在 `~/.oh-my-dws`,代码与聊天数据完全分开。
|
|
5
|
+
|
|
6
|
+
- 运行时:**Node.js ≥ 18**(零第三方依赖,只用标准库)
|
|
7
|
+
- 数据源:本机 `dws`(钉钉 CLI,需已登录 `dws auth login`)
|
|
8
|
+
- 网络:只监听 `127.0.0.1`,不上传任何数据
|
|
9
|
+
|
|
10
|
+
## 安装
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# 直接用(不安装)
|
|
14
|
+
npx oh-my-dws start
|
|
15
|
+
|
|
16
|
+
# 或全局安装
|
|
17
|
+
npm i -g oh-my-dws
|
|
18
|
+
oh-my-dws start # 后台起服务并打开浏览器(默认 25258)
|
|
19
|
+
|
|
20
|
+
# 或从源码跑
|
|
21
|
+
git clone https://github.com/duzhenxun/oh-my-dws.git
|
|
22
|
+
cd oh-my-dws && node bin/oh-my-dws.js start
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
macOS 也可以双击仓库里的 `启动.command`。
|
|
26
|
+
|
|
27
|
+
## 用法
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
oh-my-dws start # 后台启动(默认 http://127.0.0.1:25258)
|
|
31
|
+
oh-my-dws status # 看 PID / 端口 / URL / 版本
|
|
32
|
+
oh-my-dws restart # 重启
|
|
33
|
+
oh-my-dws stop # 停止
|
|
34
|
+
oh-my-dws update # 升到 npm 最新版(在跑会自动重启)
|
|
35
|
+
|
|
36
|
+
oh-my-dws # 前台启动,Ctrl+C 结束(老用法)
|
|
37
|
+
oh-my-dws 26000 # 前台 + 指定端口(位置参数兼容写法)
|
|
38
|
+
oh-my-dws start --port 26000 --no-open
|
|
39
|
+
oh-my-dws start --data /Volumes/ext/dws # 数据目录换到移动盘
|
|
40
|
+
oh-my-dws --help
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| 命令 / 选项 | 说明 |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `start` | 后台启动,日志落 `~/.oh-my-dws/_运行/server.log`,PID 写 `server.json` |
|
|
46
|
+
| `stop` / `restart` | 停止 / 重启后台服务(先 SIGTERM,超时再 SIGKILL) |
|
|
47
|
+
| `status` | 运行状态(同时探 `/api/health`);退出码 `0` 在跑 / `3` 未跑 / `4` 进程在但无响应 |
|
|
48
|
+
| `update` | `npm i -g oh-my-dws@latest`,正在运行则自动重启 |
|
|
49
|
+
| `-p, --port <端口>` | 监听端口,默认 `25258` |
|
|
50
|
+
| `--data <目录>` | 聊天数据目录,默认 `~/.oh-my-dws` |
|
|
51
|
+
| `--no-open` | 不自动打开浏览器 |
|
|
52
|
+
| `-v, --version` / `-h, --help` | 版本 / 帮助 |
|
|
53
|
+
|
|
54
|
+
环境变量:`OH_MY_DWS_HOME`(等价 `--data`)、`NO_OPEN=1`(等价 `--no-open`)。
|
|
55
|
+
|
|
56
|
+
完整使用说明见 [docs/使用说明.md](docs/使用说明.md)。
|
|
57
|
+
|
|
58
|
+
## 目录结构
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
oh-my-dws/ # 代码(npm 包)
|
|
62
|
+
├── bin/oh-my-dws.js # CLI 入口
|
|
63
|
+
├── src/
|
|
64
|
+
│ ├── server.js # HTTP 服务与路由
|
|
65
|
+
│ ├── actions.js # 拉取/纳管/发送/删除/打开钉钉
|
|
66
|
+
│ ├── archive.js # 归档读写与 Markdown/索引生成
|
|
67
|
+
│ ├── media.js # 图片/文件下载与缓存
|
|
68
|
+
│ ├── auth.js # dws 登录态与登录
|
|
69
|
+
│ ├── dws.js # dws 调用封装
|
|
70
|
+
│ ├── paths.js / util.js # 路径与时间/文件工具
|
|
71
|
+
├── web/index.html # 网页界面(单文件,无外部依赖)
|
|
72
|
+
├── docs/使用说明.md # 面向使用者的完整说明
|
|
73
|
+
├── 启动.command # macOS 双击启动
|
|
74
|
+
└── README.md
|
|
75
|
+
|
|
76
|
+
~/.oh-my-dws/ # 数据(聊天相关文件全在这里,可整体备份/迁移)
|
|
77
|
+
├── 群索引.md # 群名 → 群 ID / 成员数 / 文件位置(每次拉取后自动重建)
|
|
78
|
+
├── server.json # 后台服务的 PID/端口(`start` 写、`stop` 删)
|
|
79
|
+
├── _运行/server.log # 后台服务日志
|
|
80
|
+
├── _原始数据/ # dws 原始 JSON(便于二次加工/复核)
|
|
81
|
+
│ ├── <群简称>_消息.json
|
|
82
|
+
│ ├── <群简称>_成员.json
|
|
83
|
+
│ └── dws-login.log # 网页点「登录 dws」时的后台日志
|
|
84
|
+
├── _媒体/ # 消息图片/文件缓存(可随时删,会重新拉)
|
|
85
|
+
├── _回收站/ # 删除归档时的暂存区(可手动恢复)
|
|
86
|
+
└── <群简称>/
|
|
87
|
+
├── 群信息与人员ID.md # 群 ID、群主、成员表(openDingtalkId / memberDingtalkId)
|
|
88
|
+
└── 群消息-YYYYMMDD-YYYYMMDD.md # 消息全文,带时间、发送者、messageId
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
首次启动会自动创建 `~/.oh-my-dws/_原始数据`。
|
|
92
|
+
|
|
93
|
+
## 功能
|
|
94
|
+
|
|
95
|
+
- 左侧会话列表(可搜索),分 **@我 聚合 / 单聊 / 群聊** 三组;右侧看某个会话的**全部消息**
|
|
96
|
+
- **侧栏默认收起**:收起后是 32px 窄条(`»` 展开、显示当前会话首字头像);展开后悬停侧栏会在右边缘出现 `«` 把手一点就收;**⌘/Ctrl + B** 随时切;**拖右边缘可调宽**(200–560px,双击回默认 310px),宽度和收起状态都记在 localStorage
|
|
97
|
+
- 侧栏圆点:只有**真有新消息**时才亮绿点并显示 `+N 新`;无新消息是空心灰圈(`complete=false` 才变红);打开该会话即自动清零已阅
|
|
98
|
+
- **默认倒序**:最新一条在最上面,打开即见最新(工具栏可切「最早在前」)
|
|
99
|
+
- 消息区渲染钉钉正文的 `**加粗**`、`` `代码` ``、链接、@人,按日期分段
|
|
100
|
+
- 每条消息带**头像**(按人员 ID 稳定配色 + 姓名首字;钉钉接口不返回真实头像 URL)
|
|
101
|
+
- **引用/回复消息显示成引用块**(灰底竖线),点击引用块直接跳到被引用的那条消息
|
|
102
|
+
- **图片直接看图**:`[图片消息]` 通过 `dws chat +messages-resource-download` 下载到 `_媒体/<群简称>/` 并内联显示(懒加载,滚到才下载,已下载走缓存),点击在页面内弹窗预览大图;没网络/无权限时退化成「图片加载失败」
|
|
103
|
+
- 顶部 **拉取最新消息**:从「最后一条消息时间 − 1 分钟」拉到当前,按 `messageId` 去重合并,回写 JSON + Markdown + 群索引;新消息高亮
|
|
104
|
+
- **侧栏顶部账号胶囊**:头像(姓名首字 + 按账号稳定配色)+ 姓名 · 组织,绿点=已登录 / 橙点=未登录;胶囊右侧是 **+ 新群**;点击胶囊弹出「dws 登录信息」面板,展示状态、组织、账号、profile、token/refresh 到期、最近登录(来自 `dws auth status` + `dws profile list`),底部三个按钮:**刷新 / 退出登录 / 登录(重新登录)**;**未登录时是「登录 dws」按钮**,后台跑 `dws auth login` 走 OAuth loopback 网页授权(自动开浏览器,日志落 `_原始数据/dws-login.log`),登录后自动刷新;**退出登录**走 `dws auth logout -y`(二次确认,清本机登录态,之后需重新扫码授权才能拉消息)
|
|
105
|
+
- **搜索群 / 拉取全部最新收进右键菜单**:侧栏空白处(或任意会话上)右键即见;搜索框默认收起,点「🔍 搜索群…」才展开(Esc 收起)
|
|
106
|
+
- **拉取全部最新**(右键菜单「⬇️ 拉取全部最新」):所有会话依次增量刷新
|
|
107
|
+
- **右键会话**:成员 / 人员 ID、拉取最新消息、复制会话 ID、删除归档(移到 `_回收站/`,可恢复);下面隔开的全局项:搜索群、拉取全部最新
|
|
108
|
+
- **工具栏一行到底**:搜索框 + 发送者 / 来源群 / 时间范围(全部·今天·近 4 档)/ 排序(最新在前·最早在前)+ 条数
|
|
109
|
+
- **发消息默认收起**:右下角「✏️ 发消息」悬浮按钮,点开才展开浮层(`⌘/Ctrl + Enter` 发送,× 或 Esc 收起);有草稿自动展开;点发送即直接投递(不弹确认框),发送后自动回读归档确认落库;草稿按会话保存;可勾选是否带「AI 角标」(默认**不带**)
|
|
110
|
+
- **@我 聚合**:把「**已归档群聊**」里的 `@我` 消息汇总成一条时间线。侧栏在「@我的消息」下标出**来源群子级 + 条数**;每条消息带 📍 群名标签、左侧竖条按群配色;「来源群」页看群排行。**只扫已归档的群**,新增/删除群后点「重新汇总」修正
|
|
111
|
+
- 每条 @我 消息底部三个按钮:
|
|
112
|
+
- **🗄 归档定位** = 跳到左侧该群归档并高亮这条消息(精确可靠)
|
|
113
|
+
- **💬 打开钉钉** = 唤起桌面钉钉 + 只把「群名」写进剪贴板(粘进钉钉顶部「搜索或提问」框即可命中);钉钉**没有开放「按会话/消息直达」的协议**(官方 AppLink 只收 chatId,开放接口拿不到反向映射),所以进群仍需粘贴群名搜索;完整定位信息走旁边的「复制定位信息」
|
|
114
|
+
- **📋 复制定位信息** = 只复制不动钉钉
|
|
115
|
+
- **成员 / 人员 ID**:左侧会话**右键 →「👥 成员 / 人员 ID」**弹窗查看(姓名、群昵称、角色、openDingtalkId、memberDingtalkId);@我 聚合弹「来源群」表,单聊弹参与者
|
|
116
|
+
- **+ 新群**(账号胶囊右边):关键词调 `dws chat +chat-search` 找群;也可手动填「名称 + openConversationId + 单聊/群聊」直接归档
|
|
117
|
+
- **会话项悬停出「⋯」**:点击等同右键(成员 / 复制 ID / 拉取 / 删除),不用猜在哪右键
|
|
118
|
+
- 标题一行装下全部信息;**会话 ID 默认隐藏**,悬停标题行才显示,点击即复制
|
|
119
|
+
- URL 可深链:`#<群简称>`
|
|
120
|
+
|
|
121
|
+
> **发消息是以你本人钉钉身份发送的真消息**:群聊走 `chat +messages-send --group <cid>`,单聊走 `--open-dingtalk-id <对方>`;点「发送」即直接投递(服务端带 `--yes`),不弹确认框。
|
|
122
|
+
> 单聊没有成员接口,参与者由消息发送者推断写入 `群信息与人员ID.md`。
|
|
123
|
+
> 删除默认进 `_回收站/<简称>__<时间戳>/`,确认不需要再手动清空。
|
|
124
|
+
> `群索引.md` 由服务自动重建,手改会被覆盖;其他手工文件不会被触碰。
|
|
125
|
+
|
|
126
|
+
## 约定
|
|
127
|
+
|
|
128
|
+
- **群简称**:取群名主干(如 `11.12-11.16闻香识美人`),去掉 `【待排期】` 等状态前缀。
|
|
129
|
+
- **群 ID 唯一**:`openConversationId`(形如 `cidE6Ix+J4uPPaRd1TCp/hfEw==`)是拉消息、拉成员的唯一稳定 ID,群名会变,ID 不变。
|
|
130
|
+
- **人员 ID**:发送者过滤、@人、发消息一律用 `openDingTalkId`,不要用展示名。
|
|
131
|
+
- **字段大小写不一致**:`+chat-members-list` 返回有时是 `openDingtalkId`、有时是 `openDingTalkId`,解析按两者兜底(`src/archive.js` 的 `openIdOf`)。
|
|
132
|
+
- **群主不一定在 users[0]**:成员顺序不固定,按 `role == "群主"` 找。
|
|
133
|
+
- **完整性字段**:每份消息文件保留 `complete` / `hasMore` / 条数 / 范围,不完整要显式标注,不得当全量使用。
|
|
134
|
+
|
|
135
|
+
## 常用 dws 命令
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# 搜群,拿 openConversationId
|
|
139
|
+
dws chat +chat-search --query "<群名关键词>" --format json
|
|
140
|
+
|
|
141
|
+
# 拉群成员(含姓名、角色、openDingTalkId)
|
|
142
|
+
dws chat +chat-members-list --group "<cid>" --format json
|
|
143
|
+
|
|
144
|
+
# 拉群消息(全部时间,正序,忽略表情回应)
|
|
145
|
+
dws chat +chat-messages --group "<cid>" --order asc --page-all --page-limit 50 --no-reactions --format json
|
|
146
|
+
|
|
147
|
+
# 拉指定时间范围
|
|
148
|
+
dws chat +chat-messages --group "<cid>" --start "2026-11-12T00:00:00+08:00" --end "2026-11-17T00:00:00+08:00" --order asc --page-all --no-reactions --format json
|
|
149
|
+
|
|
150
|
+
# 按关键词搜消息(默认近 7 天)
|
|
151
|
+
dws chat +search-msg --chat-query "<群名>" --query "<关键词>" --start "..." --end "..." --page-all --format json
|
|
152
|
+
|
|
153
|
+
# 下载消息里的图片/文件(仅在需要时开)
|
|
154
|
+
dws chat +chat-messages --group "<cid>" --page-all --download-resources --output-dir "<目录>" --format json
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## 开发
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
node bin/oh-my-dws.js --no-open --port 26000 # 本地调试(前台)
|
|
161
|
+
npm run check # 语法 + 网页 id 引用自检
|
|
162
|
+
npm pack --dry-run # 检查发布内容
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## 发版(打 tag 自动发 npm)
|
|
166
|
+
|
|
167
|
+
仓库里已有 `.github/workflows/release.yml`:**推 `v*` tag 就自动 `npm publish`**。
|
|
168
|
+
|
|
169
|
+
1. 一次性(二选一):
|
|
170
|
+
- **可信发布(推荐)**:npm 包页面 `/access` → Trusted Publishers → Add publisher → GitHub Actions,
|
|
171
|
+
填 `duzhenxun` / `oh-my-dws` / `release.yml`,Environment 留空;不用任何 token
|
|
172
|
+
- **或用 token**:npm 建 **Automation** token,存进 GitHub Secrets,名字 `NPM_TOKEN`
|
|
173
|
+
2. 以后一条命令发版:
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
npm run release # 0.0.1 → 0.0.2(改版本 + commit + tag + push)
|
|
177
|
+
npm run release:minor # → 0.1.0
|
|
178
|
+
npm run release:major # → 1.0.0
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
CI 会校验 **tag 与 `package.json` 版本一致**、跑 `npm run check`、`npm pack --dry-run`、
|
|
182
|
+
已发布过则跳过,最后 `npm publish --access public` 并建 GitHub Release。
|
|
183
|
+
细节与报错处理见 [docs/使用说明.md](docs/使用说明.md)。
|
|
184
|
+
|
|
185
|
+
## 注意
|
|
186
|
+
|
|
187
|
+
- 图片消息正文只返回 `mediaId`,需要实际图片时由网页按需下载。
|
|
188
|
+
- 消息正文落盘视为内部资料,注意不要外发;`_原始数据` 可能含敏感字段时请先脱敏。
|
|
189
|
+
- 历史上有 Python 实现(`server.py`),已迁移为 Node.js,旧文件保留在 git 历史里。
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
package/bin/oh-my-dws.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* oh-my-dws 命令行入口。
|
|
6
|
+
*
|
|
7
|
+
* 前台:oh-my-dws [--port 25258] [--data 目录] [--no-open]
|
|
8
|
+
* 后台:oh-my-dws start|stop|restart|status|update
|
|
9
|
+
*
|
|
10
|
+
* start 会以 detached 子进程跑 `__serve`,PID/端口写进 <数据目录>/server.json,
|
|
11
|
+
* 日志写进 <数据目录>/_运行/server.log,所以 stop/status 不依赖当前终端。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
17
|
+
|
|
18
|
+
const pkg = require('../package.json');
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PORT = 25258;
|
|
21
|
+
const COMMANDS = ['start', 'stop', 'restart', 'status', 'update', 'serve', '__serve'];
|
|
22
|
+
|
|
23
|
+
const HELP = `oh-my-dws ${pkg.version} — 钉钉群消息归档(本地网页控制台)
|
|
24
|
+
|
|
25
|
+
用法
|
|
26
|
+
oh-my-dws 前台启动(默认 http://127.0.0.1:${DEFAULT_PORT})
|
|
27
|
+
oh-my-dws start [选项] 后台启动(脱离终端,日志落 _运行/server.log)
|
|
28
|
+
oh-my-dws stop 停止后台服务
|
|
29
|
+
oh-my-dws restart [选项] 重启后台服务
|
|
30
|
+
oh-my-dws status 查看运行状态(PID、端口、URL、数据目录)
|
|
31
|
+
oh-my-dws update 升级到 npm 最新版,并在运行中时自动重启
|
|
32
|
+
|
|
33
|
+
选项(start / restart / 前台启动通用)
|
|
34
|
+
-p, --port <端口> 监听端口(默认 ${DEFAULT_PORT})
|
|
35
|
+
--data <目录> 聊天数据目录(默认 ~/.oh-my-dws,等价于 OH_MY_DWS_HOME)
|
|
36
|
+
--no-open 不自动打开浏览器
|
|
37
|
+
-v, --version 查看版本
|
|
38
|
+
-h, --help 查看帮助
|
|
39
|
+
|
|
40
|
+
示例
|
|
41
|
+
oh-my-dws start # 后台起服务并打开浏览器
|
|
42
|
+
oh-my-dws start --port 26000 --no-open
|
|
43
|
+
oh-my-dws status
|
|
44
|
+
oh-my-dws restart
|
|
45
|
+
oh-my-dws stop
|
|
46
|
+
oh-my-dws update
|
|
47
|
+
oh-my-dws # 前台跑,Ctrl+C 结束
|
|
48
|
+
oh-my-dws 26000 # 前台 + 指定端口(位置参数兼容写法)
|
|
49
|
+
|
|
50
|
+
数据目录结构(可整体备份/迁移)
|
|
51
|
+
~/.oh-my-dws/
|
|
52
|
+
├── 群索引.md
|
|
53
|
+
├── server.json 后台服务的 PID / 端口(start 写入,stop 删除)
|
|
54
|
+
├── _运行/ server.log(后台日志)
|
|
55
|
+
├── _原始数据/ CLI 原始 JSON + dws-login.log
|
|
56
|
+
├── _媒体/ 消息图片/文件缓存
|
|
57
|
+
├── _回收站/ 删除的归档(可恢复)
|
|
58
|
+
└── <群简称>/ 群信息与人员ID.md、群消息-*.md
|
|
59
|
+
|
|
60
|
+
依赖:dws(钉钉 CLI,需已登录 dws auth login)
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
function parseArgs(argv) {
|
|
64
|
+
const opts = {
|
|
65
|
+
cmd: '',
|
|
66
|
+
port: DEFAULT_PORT,
|
|
67
|
+
portSet: false,
|
|
68
|
+
dataDir: process.env.OH_MY_DWS_HOME || '',
|
|
69
|
+
noOpen: false,
|
|
70
|
+
help: false,
|
|
71
|
+
version: false,
|
|
72
|
+
rest: [],
|
|
73
|
+
};
|
|
74
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
75
|
+
const a = argv[i];
|
|
76
|
+
if (a === '-h' || a === '--help') opts.help = true;
|
|
77
|
+
else if (a === '-v' || a === '--version') opts.version = true;
|
|
78
|
+
else if (a === '--no-open') opts.noOpen = true;
|
|
79
|
+
else if (a === '-p' || a === '--port') {
|
|
80
|
+
opts.port = Number(argv[i + 1]);
|
|
81
|
+
opts.portSet = true;
|
|
82
|
+
i += 1;
|
|
83
|
+
} else if (a.startsWith('--port=')) {
|
|
84
|
+
opts.port = Number(a.slice(7));
|
|
85
|
+
opts.portSet = true;
|
|
86
|
+
} else if (a === '--data') {
|
|
87
|
+
opts.dataDir = argv[i + 1] || '';
|
|
88
|
+
i += 1;
|
|
89
|
+
} else if (a.startsWith('--data=')) opts.dataDir = a.slice(7);
|
|
90
|
+
else opts.rest.push(a);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 第一个位置参数若是已知命令就当命令,否则按老写法视作端口
|
|
94
|
+
const first = opts.rest[0];
|
|
95
|
+
if (first && COMMANDS.includes(first)) {
|
|
96
|
+
opts.cmd = first;
|
|
97
|
+
opts.rest.shift();
|
|
98
|
+
} else if (first && /^\d+$/.test(first)) {
|
|
99
|
+
opts.port = Number(first);
|
|
100
|
+
opts.portSet = true;
|
|
101
|
+
opts.rest.shift();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!Number.isInteger(opts.port) || opts.port <= 0 || opts.port > 65535) opts.port = DEFAULT_PORT;
|
|
105
|
+
return opts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
109
|
+
|
|
110
|
+
if (opts.help) {
|
|
111
|
+
process.stdout.write(HELP);
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
if (opts.version) {
|
|
115
|
+
process.stdout.write(`${pkg.version}\n`);
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 必须在 require 业务模块之前设置:paths.js 在 require 时就确定数据目录
|
|
120
|
+
if (opts.dataDir) process.env.OH_MY_DWS_HOME = path.resolve(opts.dataDir);
|
|
121
|
+
if (opts.noOpen) process.env.NO_OPEN = '1';
|
|
122
|
+
|
|
123
|
+
const P = require('../src/paths');
|
|
124
|
+
|
|
125
|
+
const STATE_FILE = path.join(P.DATA_DIR, 'server.json');
|
|
126
|
+
const RUN_DIR = path.join(P.DATA_DIR, '_运行');
|
|
127
|
+
const LOG_FILE = path.join(RUN_DIR, 'server.log');
|
|
128
|
+
|
|
129
|
+
const out = (s) => process.stdout.write(`${s}\n`);
|
|
130
|
+
const err = (s) => process.stderr.write(`${s}\n`);
|
|
131
|
+
|
|
132
|
+
/* ---------------- 状态文件 ---------------- */
|
|
133
|
+
|
|
134
|
+
function readState() {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writeState(s) {
|
|
143
|
+
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
|
144
|
+
fs.writeFileSync(STATE_FILE, `${JSON.stringify(s, null, 2)}\n`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function clearState() {
|
|
148
|
+
try {
|
|
149
|
+
fs.unlinkSync(STATE_FILE);
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function pidAlive(pid) {
|
|
154
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
155
|
+
try {
|
|
156
|
+
process.kill(pid, 0);
|
|
157
|
+
return true;
|
|
158
|
+
} catch (e) {
|
|
159
|
+
return e.code === 'EPERM';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 当前是否在跑:状态文件里的 pid 还活着就算 */
|
|
164
|
+
function currentState() {
|
|
165
|
+
const st = readState();
|
|
166
|
+
if (!st || !pidAlive(st.pid)) return null;
|
|
167
|
+
return st;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function urlOf(port) {
|
|
171
|
+
return `http://127.0.0.1:${port}/`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function probeHealth(port, timeout = 1200) {
|
|
175
|
+
try {
|
|
176
|
+
const ac = new AbortController();
|
|
177
|
+
const t = setTimeout(() => ac.abort(), timeout);
|
|
178
|
+
const r = await fetch(`${urlOf(port)}api/health`, { signal: ac.signal });
|
|
179
|
+
clearTimeout(t);
|
|
180
|
+
if (!r.ok) return null;
|
|
181
|
+
return await r.json();
|
|
182
|
+
} catch {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function openBrowser(url) {
|
|
188
|
+
const cmd = process.platform === 'darwin'
|
|
189
|
+
? ['open', [url]]
|
|
190
|
+
: process.platform === 'win32'
|
|
191
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
192
|
+
: ['xdg-open', [url]];
|
|
193
|
+
spawnSync(cmd[0], cmd[1], { stdio: 'ignore' });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* ---------------- 命令实现 ---------------- */
|
|
197
|
+
|
|
198
|
+
async function cmdStatus() {
|
|
199
|
+
const st = readState();
|
|
200
|
+
if (!st) {
|
|
201
|
+
out(`● 未运行(没有找到 ${STATE_FILE})`);
|
|
202
|
+
out(` 数据目录:${P.DATA_DIR}`);
|
|
203
|
+
return 3;
|
|
204
|
+
}
|
|
205
|
+
if (!pidAlive(st.pid)) {
|
|
206
|
+
out('● 未运行(状态文件是上次异常退出留下的,已清理)');
|
|
207
|
+
clearState();
|
|
208
|
+
return 3;
|
|
209
|
+
}
|
|
210
|
+
const health = await probeHealth(st.port);
|
|
211
|
+
out(`● 运行中 PID ${st.pid}`);
|
|
212
|
+
out(` URL:${urlOf(st.port)}`);
|
|
213
|
+
out(` 端口:${st.port}`);
|
|
214
|
+
out(` 启动:${st.startedAt || '—'}`);
|
|
215
|
+
out(` 版本:${health ? health.version : `${st.version || pkg.version}(HTTP 未响应)`}`);
|
|
216
|
+
out(` 数据:${health ? health.dataDir : st.dataDir || P.DATA_DIR}`);
|
|
217
|
+
out(` 日志:${LOG_FILE}`);
|
|
218
|
+
return health ? 0 : 4;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/* status 退出码:0=运行中 3=未运行 4=进程在但 HTTP 无响应 1=异常 */
|
|
222
|
+
|
|
223
|
+
async function cmdStart() {
|
|
224
|
+
const running = currentState();
|
|
225
|
+
if (running) {
|
|
226
|
+
out(`● 已在运行:${urlOf(running.port)} (PID ${running.pid})`);
|
|
227
|
+
out(' 想重启用 oh-my-dws restart,想换端口先 oh-my-dws stop');
|
|
228
|
+
if (!opts.noOpen) openBrowser(urlOf(running.port));
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
fs.mkdirSync(RUN_DIR, { recursive: true });
|
|
233
|
+
const log = fs.openSync(LOG_FILE, 'a');
|
|
234
|
+
const args = [__filename, '__serve', '--port', String(opts.port), '--no-open'];
|
|
235
|
+
if (opts.dataDir) args.push('--data', P.DATA_DIR);
|
|
236
|
+
const child = spawn(process.execPath, args, {
|
|
237
|
+
detached: true,
|
|
238
|
+
stdio: ['ignore', log, log],
|
|
239
|
+
env: { ...process.env, NO_OPEN: '1' },
|
|
240
|
+
});
|
|
241
|
+
fs.closeSync(log);
|
|
242
|
+
child.unref();
|
|
243
|
+
|
|
244
|
+
writeState({
|
|
245
|
+
pid: child.pid,
|
|
246
|
+
port: opts.port,
|
|
247
|
+
url: urlOf(opts.port),
|
|
248
|
+
version: pkg.version,
|
|
249
|
+
dataDir: P.DATA_DIR,
|
|
250
|
+
startedAt: new Date().toISOString(),
|
|
251
|
+
log: LOG_FILE,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// 等它真的能响应(最多 ~6s),失败就把日志尾巴打出来
|
|
255
|
+
let health = null;
|
|
256
|
+
for (let i = 0; i < 30; i += 1) {
|
|
257
|
+
health = await probeHealth(opts.port, 400);
|
|
258
|
+
if (health) break;
|
|
259
|
+
if (!pidAlive(child.pid)) break;
|
|
260
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!health) {
|
|
264
|
+
if (pidAlive(child.pid)) {
|
|
265
|
+
out(`● 已启动(PID ${child.pid}),但 ${urlOf(opts.port)} 还没响应`);
|
|
266
|
+
} else {
|
|
267
|
+
err('启动失败,日志尾部:');
|
|
268
|
+
try {
|
|
269
|
+
err(fs.readFileSync(LOG_FILE, 'utf8').split('\n').slice(-15).join('\n'));
|
|
270
|
+
} catch {}
|
|
271
|
+
clearState();
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
out(`● 已启动:${urlOf(opts.port)} (PID ${child.pid})`);
|
|
276
|
+
out(` 数据:${P.DATA_DIR}`);
|
|
277
|
+
out(` 日志:${LOG_FILE}`);
|
|
278
|
+
}
|
|
279
|
+
if (!opts.noOpen) openBrowser(urlOf(opts.port));
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function stopByState(st, quiet = false) {
|
|
284
|
+
if (!st) return 0;
|
|
285
|
+
if (!pidAlive(st.pid)) {
|
|
286
|
+
clearState();
|
|
287
|
+
if (!quiet) out('● 未运行(状态文件已清理)');
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
process.kill(st.pid, 'SIGTERM');
|
|
292
|
+
} catch (e) {
|
|
293
|
+
clearState();
|
|
294
|
+
err(`停止失败:${e.message}`);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
for (let i = 0; i < 40; i += 1) {
|
|
298
|
+
if (!pidAlive(st.pid)) break;
|
|
299
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
300
|
+
}
|
|
301
|
+
if (pidAlive(st.pid)) {
|
|
302
|
+
try {
|
|
303
|
+
process.kill(st.pid, 'SIGKILL');
|
|
304
|
+
} catch {}
|
|
305
|
+
}
|
|
306
|
+
clearState();
|
|
307
|
+
if (!quiet) out(`● 已停止 PID ${st.pid}`);
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function cmdStop() {
|
|
312
|
+
const st = readState();
|
|
313
|
+
if (!st) {
|
|
314
|
+
out('● 未运行');
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
return stopByState(st);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function cmdRestart() {
|
|
321
|
+
const st = readState();
|
|
322
|
+
if (st) {
|
|
323
|
+
await stopByState(st, true);
|
|
324
|
+
out('● 已停止旧进程');
|
|
325
|
+
}
|
|
326
|
+
return cmdStart();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function cmdUpdate() {
|
|
330
|
+
const running = currentState();
|
|
331
|
+
out('→ 安装最新版:npm install -g oh-my-dws@latest');
|
|
332
|
+
const r = spawnSync('npm', ['install', '-g', 'oh-my-dws@latest'], { stdio: 'inherit' });
|
|
333
|
+
if (r.status !== 0) {
|
|
334
|
+
err('升级失败(可试试 sudo npm install -g oh-my-dws@latest)');
|
|
335
|
+
return 1;
|
|
336
|
+
}
|
|
337
|
+
if (running) {
|
|
338
|
+
out('→ 旧进程还在跑,重启以使用新版本…');
|
|
339
|
+
await stopByState(running, true);
|
|
340
|
+
return cmdStart();
|
|
341
|
+
}
|
|
342
|
+
out('● 已升级;下次 oh-my-dws start 就会用新版本');
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* 前台服务(默认行为,也是 start 拉起的子进程) */
|
|
347
|
+
function runForeground() {
|
|
348
|
+
const { createServer } = require('../src/server');
|
|
349
|
+
return createServer({ port: opts.port, open: !opts.noOpen }).then(({ server }) => {
|
|
350
|
+
const shutdown = () => {
|
|
351
|
+
server.close(() => process.exit(0));
|
|
352
|
+
setTimeout(() => process.exit(0), 1500).unref();
|
|
353
|
+
};
|
|
354
|
+
process.on('SIGINT', shutdown);
|
|
355
|
+
process.on('SIGTERM', shutdown);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const dispatch = {
|
|
360
|
+
start: cmdStart,
|
|
361
|
+
stop: cmdStop,
|
|
362
|
+
restart: cmdRestart,
|
|
363
|
+
status: cmdStatus,
|
|
364
|
+
update: cmdUpdate,
|
|
365
|
+
serve: runForeground,
|
|
366
|
+
__serve: runForeground,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
(async () => {
|
|
370
|
+
const fn = dispatch[opts.cmd || 'serve'] || runForeground;
|
|
371
|
+
try {
|
|
372
|
+
const code = await fn();
|
|
373
|
+
if (typeof code === 'number' && opts.cmd && opts.cmd !== 'serve' && opts.cmd !== '__serve') {
|
|
374
|
+
process.exit(code);
|
|
375
|
+
}
|
|
376
|
+
} catch (e) {
|
|
377
|
+
if (e && e.code === 'EADDRINUSE') {
|
|
378
|
+
err(`端口 ${opts.port} 已被占用,换个端口:oh-my-dws start --port ${opts.port + 1}`);
|
|
379
|
+
} else {
|
|
380
|
+
err(`启动失败:${(e && e.message) || e}`);
|
|
381
|
+
}
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
})();
|