dsh-mishu 0.5.0 → 0.5.4

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 CHANGED
@@ -54,7 +54,7 @@
54
54
 
55
55
  同一封邮件的往来回复算一件事。你在邮件客户端里看过之后,对应的事项会自动关闭。
56
56
 
57
- **密码放在系统钥匙串**:macOS 钥匙串、Windows 凭据管理器、Linux 密钥环(Secret Service)。密码只在设置页输入一次,直接交给钥匙串,不写进任何文件,接口也从不返回它。没有钥匙串的电脑,可以改用环境变量 `DSH_MISHU_SECRET_MAIL_<编号>`。
57
+ **密码放在系统钥匙串**:macOS 钥匙串、Windows 凭据管理器、Linux 密钥环(Secret Service)。密码只在设置页输入一次,直接交给钥匙串,不写进任何文件,接口也从不返回它。没有钥匙串的电脑(比如没有桌面的 Linux 服务器),设置页会说明原因;账户照样能保存,密码改用环境变量 `DSH_MISHU_SECRET_MAIL_<编号>` 提供。
58
58
 
59
59
  ### 用法
60
60
 
@@ -72,6 +72,8 @@
72
72
 
73
73
  **设置**:点页面顶部的"设置":语言、各类别提前几天提醒、GitHub(开关、token 从哪来、测试连接、在 GitHub 上标已读的方式)、邮箱账户(添加、测试连接、现在收一次、编辑、删除)。飞书即将接入。
74
74
 
75
+ **重启 DSH**:页面顶部"设置"后面的按键。重装或升级插件后点它,桌面版会按正常方式退出(有任务在跑时会先问你),再自动打开。用 `dsh` 命令启动的 DSH 没法从页面重启(新的登录地址只会打印在那个终端里),按键会告诉你回终端按 Ctrl+C 再运行一次。Windows 和 Linux 桌面版的重启还没在真机上测过。
76
+
75
77
  **文字命令**:
76
78
 
77
79
  ```
@@ -97,10 +99,11 @@ agent 可用的工具:`mishu_add`(交代,需要你审批)、`mishu_list`
97
99
 
98
100
  ### GitHub token
99
101
 
100
- mishu 自己不保存 token。按顺序取:
102
+ 按顺序取,token 不写进任何文件:
101
103
 
102
104
  1. 环境变量(默认 `GITHUB_TOKEN`)
103
- 2. `gh auth token`:装了 GitHub CLI,并运行过 `gh auth login` 就行
105
+ 2. 你在设置页填的 token:先拿到 GitHub 上检查,再存进系统钥匙串。要用 classic token,勾选 `notifications`(看私有仓库再勾 `repo`);GitHub 的通知接口不认 fine-grained token
106
+ 3. `gh auth token`:装了 GitHub CLI,并运行过 `gh auth login` 就行。从 Dock、开始菜单启动的 DSH 拿到的 PATH 往往很短,所以小秘书除了 PATH,还会去 Homebrew、`~/.local/bin`、winget、Scoop 等常见安装位置找 `gh`
104
107
 
105
108
  ### 安装和配置
106
109
 
@@ -177,7 +180,7 @@ Default warning times: documents 90 days, bills 3, birthdays 7, renewals 7, ever
177
180
 
178
181
  A thread of replies is one item. Once you read it in your mail client, its item closes by itself.
179
182
 
180
- **Passwords live in the system keychain**: the macOS Keychain, Windows Credential Manager or the Linux keyring (Secret Service). You type a password once on the settings page; it goes straight to the keychain, is never written to a file and is never returned by any endpoint. Without a keychain, use the environment variable `DSH_MISHU_SECRET_MAIL_<id>`.
183
+ **Passwords live in the system keychain**: the macOS Keychain, Windows Credential Manager or the Linux keyring (Secret Service). You type a password once on the settings page; it goes straight to the keychain, is never written to a file and is never returned by any endpoint. On a computer without one (a Linux server with no desktop, say), the settings page says why; accounts still save, and each password comes from the environment variable `DSH_MISHU_SECRET_MAIL_<id>`.
181
184
 
182
185
  ### Usage
183
186
 
@@ -195,6 +198,8 @@ Cards can be dragged: onto "Done for you" to mark done, onto "Keeping for you" t
195
198
 
196
199
  **Settings**: the "Settings" tab at the top of the page: language, warning days per category, GitHub (on/off, where the token comes from, connection test, how to mark read), mail accounts (add, test, check now, edit, delete). Feishu / Lark is coming.
197
200
 
201
+ **Restart DSH**: the button after "Settings" at the top of the page. After reinstalling or upgrading plugins, click it: the desktop app quits the normal way (asking first if a task is running) and opens again. DSH started with the `dsh` command cannot be restarted from the page (its new sign-in address is printed only in that terminal), so the button tells you to press Ctrl+C there and run it again. Restarting the Windows and Linux desktop apps is not yet tested on real machines.
202
+
198
203
  **Text commands**:
199
204
 
200
205
  ```
@@ -220,10 +225,11 @@ Tools for the agent: `mishu_add` (needs your approval), `mishu_list`, `mishu_det
220
225
 
221
226
  ### GitHub token
222
227
 
223
- mishu never stores a token. It uses, in order:
228
+ It uses, in order, and never writes a token to a file:
224
229
 
225
230
  1. an environment variable (default `GITHUB_TOKEN`)
226
- 2. `gh auth token`: if the GitHub CLI is installed and you have run `gh auth login`
231
+ 2. a token you paste on the settings page: checked with GitHub first, then kept in the system keychain. Use a classic token with the `notifications` scope (plus `repo` for private repositories); GitHub's notifications API does not take fine-grained tokens
232
+ 3. `gh auth token`: if the GitHub CLI is installed and you have run `gh auth login`. DSH started from the Dock or Start menu often gets a short PATH, so besides PATH the secretary looks for `gh` where Homebrew, `~/.local/bin`, winget and Scoop put it
227
233
 
228
234
  ### Install and configure
229
235
 
package/lib/client.js CHANGED
@@ -60,10 +60,16 @@ var mishuApi = {
60
60
  add: (lang, item) => post("/add", { lang, item }),
61
61
  ask: (lang, ask) => post("/ask", { lang, ask })
62
62
  };
63
+ var restartApi = {
64
+ info: (lang) => post("/restart/info", { lang }),
65
+ restart: (lang) => post("/restart", { lang })
66
+ };
63
67
  var settingsApi = {
64
68
  load: (lang) => post("/settings", { lang }),
65
69
  save: (lang, settings) => post("/settings/save", { lang, settings }),
66
70
  testGithub: (lang) => post("/github/test", { lang }),
71
+ saveToken: (lang, token) => post("/github/token/save", { lang, token }),
72
+ deleteToken: (lang) => post("/github/token/delete", { lang }),
67
73
  saveMail: (lang, account, password) => post("/mail/save", { lang, account, password }),
68
74
  testMail: (lang, account, password) => post("/mail/test", { lang, account, password }),
69
75
  deleteMail: (lang, id) => post("/mail/delete", { lang, id }),
@@ -132,6 +138,10 @@ var zh = {
132
138
  resize: "\u62D6\u52A8\u8C03\u6574\u5BBD\u5EA6\uFF0C\u53CC\u51FB\u6062\u590D\u7B49\u5BBD",
133
139
  tabBoard: "\u770B\u677F",
134
140
  tabSettings: "\u8BBE\u7F6E",
141
+ restart: "\u91CD\u542F DSH",
142
+ restartTip: "\u91CD\u88C5\u6216\u5347\u7EA7\u63D2\u4EF6\u540E\uFF0C\u53EF\u4EE5\u7528\u8FD9\u4E2A\u6309\u952E\u91CD\u542F\u672C DSH",
143
+ restartConfirm: "\u91CD\u542F DSH\uFF1F\u5B89\u88C5\u63D2\u4EF6\u6216\u8005\u63D2\u4EF6\u66F4\u65B0\u540E\u9700\u8981\u91CD\u542F DSH\u3002",
144
+ restarting: "\u6B63\u5728\u91CD\u542F\u2026\u7A97\u53E3\u4F1A\u5173\u6389\u518D\u81EA\u52A8\u6253\u5F00",
135
145
  all: "\u5168\u90E8",
136
146
  sourceGithub: "GitHub",
137
147
  sourceMail: "\u90AE\u4EF6",
@@ -147,6 +157,16 @@ var zh = {
147
157
  sToken: "Token",
148
158
  sTokenEnv: (name) => `\u4ECE\u73AF\u5883\u53D8\u91CF ${name} \u8BFB\u53D6`,
149
159
  sTokenGh: "\u7528 GitHub CLI\uFF08gh\uFF09\u767B\u5F55\u7684\u8D26\u53F7\uFF0Ctoken \u5728\u7CFB\u7EDF\u94A5\u5319\u4E32\u91CC",
160
+ sTokenKeychain: "\u4F60\u5728\u8FD9\u91CC\u586B\u7684 token\uFF0C\u5B58\u5728\u7CFB\u7EDF\u94A5\u5319\u4E32\u91CC",
161
+ sTokenPaste: "\u586B\u4E00\u4E2A GitHub token",
162
+ sTokenHow: "\u7528 classic token\uFF0C\u52FE\u9009 notifications\uFF1B\u8981\u770B\u79C1\u6709\u4ED3\u5E93\u518D\u52FE repo\u3002GitHub \u7684\u901A\u77E5\u63A5\u53E3\u4E0D\u8BA4 fine-grained token\u3002token \u53EA\u5B58\u8FDB\u7CFB\u7EDF\u94A5\u5319\u4E32\uFF0C\u4E0D\u5199\u8FDB\u4EFB\u4F55\u6587\u4EF6\u3002",
163
+ sTokenNew: "\u53BB GitHub \u751F\u6210\uFF08\u6743\u9650\u5DF2\u52FE\u597D\uFF09",
164
+ sTokenSave: "\u68C0\u67E5\u5E76\u4FDD\u5B58",
165
+ sTokenSaved: (login) => `\u4FDD\u5B58\u597D\u4E86\uFF0C\u8D26\u53F7 ${login}`,
166
+ sTokenReplace: "\u6362\u4E00\u4E2A token",
167
+ sTokenRemove: "\u79FB\u9664 token",
168
+ sTokenRemoveConfirm: "\u4ECE\u94A5\u5319\u4E32\u91CC\u79FB\u9664\u8FD9\u4E2A token\uFF1F",
169
+ sTokenNoKeychain: (env, why) => `\u8FD9\u53F0\u7535\u8111\u6CA1\u6709\u53EF\u7528\u7684\u7CFB\u7EDF\u94A5\u5319\u4E32\uFF08${why}\uFF09\uFF0Ctoken \u6CA1\u6CD5\u4FDD\u5B58\u3002\u53EF\u4EE5\u5728\u542F\u52A8 DSH \u7684\u73AF\u5883\u91CC\u8BBE\u7F6E ${env}\uFF0C\u518D\u91CD\u542F DSH\u3002`,
150
170
  sTest: "\u6D4B\u8BD5\u8FDE\u63A5",
151
171
  sTesting: "\u6B63\u5728\u6D4B\u8BD5\u2026",
152
172
  sGithubOk: (login) => `\u8FDE\u4E0A\u4E86\uFF0C\u8D26\u53F7 ${login}`,
@@ -160,7 +180,8 @@ var zh = {
160
180
  sMail: "\u90AE\u7BB1",
161
181
  sMailPrivacy: "\u53EA\u8BFB\uFF1A\u5C0F\u79D8\u4E66\u7528\u53EA\u8BFB\u65B9\u5F0F\u6253\u5F00\u6536\u4EF6\u7BB1\uFF0C\u53EA\u770B\u53D1\u4EF6\u4EBA\u3001\u6536\u4EF6\u4EBA\u3001\u6807\u9898\u548C\u51E0\u4E2A\u90AE\u4EF6\u5934\uFF0C\u4E0D\u8BFB\u6B63\u6587\uFF0C\u4E5F\u4E0D\u4F1A\u628A\u90AE\u4EF6\u6807\u6210\u5DF2\u8BFB\u3002",
162
182
  sSecretsAt: (kind) => `\u5BC6\u7801\u4FDD\u5B58\u5728\uFF1A${kind}\uFF08\u52A0\u5BC6\u4FDD\u5B58\uFF0C\u4E0D\u5199\u8FDB\u4EFB\u4F55\u6587\u4EF6\uFF09`,
163
- sSecretsNone: "\u8FD9\u53F0\u7535\u8111\u4E0A\u6CA1\u627E\u5230\u53EF\u7528\u7684\u7CFB\u7EDF\u94A5\u5319\u4E32\uFF0C\u6CA1\u6CD5\u4FDD\u5B58\u5BC6\u7801\u3002\u53EF\u4EE5\u6539\u7528\u73AF\u5883\u53D8\u91CF\uFF0C\u540D\u5B57\u89C1\u6BCF\u4E2A\u8D26\u6237\u4E0B\u9762\u3002",
183
+ sSecretsNone: (why) => `\u8FD9\u53F0\u7535\u8111\u4E0A\u6CA1\u6709\u53EF\u7528\u7684\u7CFB\u7EDF\u94A5\u5319\u4E32\uFF1A${why}\u3002\u5BC6\u7801\u4E0D\u4F1A\u88AB\u4FDD\u5B58\uFF1B\u8D26\u6237\u7167\u6837\u80FD\u4FDD\u5B58\uFF0C\u5BC6\u7801\u7528\u73AF\u5883\u53D8\u91CF\u63D0\u4F9B\uFF0C\u53D8\u91CF\u540D\u5199\u5728\u6BCF\u4E2A\u8D26\u6237\u4E0B\u9762\u3002`,
184
+ sPasswordTestOnly: "\u6CA1\u6709\u94A5\u5319\u4E32\uFF1A\u8FD9\u91CC\u586B\u7684\u53EA\u7528\u6765\u6D4B\u8BD5\u8FDE\u63A5\uFF0C\u4E0D\u4F1A\u4FDD\u5B58",
164
185
  secretKinds: { macos: "macOS \u94A5\u5319\u4E32", windows: "Windows \u51ED\u636E\u7BA1\u7406\u5668", linux: "Linux \u5BC6\u94A5\u73AF\uFF08Secret Service\uFF09", memory: "\u5185\u5B58\uFF08\u6D4B\u8BD5\u7528\uFF09" },
165
186
  sNoAccounts: "\u8FD8\u6CA1\u6709\u6DFB\u52A0\u90AE\u7BB1\u3002",
166
187
  sAddAccount: "+ \u6DFB\u52A0\u90AE\u7BB1",
@@ -180,7 +201,7 @@ var zh = {
180
201
  sSince: "\u53EA\u770B\u6700\u8FD1\u51E0\u5929\u7684\u672A\u8BFB\u90AE\u4EF6",
181
202
  sEnabled: "\u542F\u7528",
182
203
  sMailOk: (n) => `\u8FDE\u4E0A\u4E86\uFF0C\u6700\u8FD1\u6709 ${n} \u5C01\u672A\u8BFB\u90AE\u4EF6`,
183
- sEnvHint: (name) => `\u6CA1\u6709\u94A5\u5319\u4E32\u65F6\uFF0C\u53EF\u4EE5\u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF ${name}`,
204
+ sEnvHint: (name) => `\u6CA1\u6709\u627E\u5230\u5BC6\u7801\u3002\u70B9\u300C\u7F16\u8F91\u300D\u91CD\u65B0\u586B\u4E00\u6B21\uFF1B\u8FD9\u53F0\u7535\u8111\u6CA1\u6709\u94A5\u5319\u4E32\u7684\u8BDD\uFF0C\u5728\u542F\u52A8 DSH \u7684\u73AF\u5883\u91CC\u8BBE\u7F6E ${name}=\u6388\u6743\u7801\uFF0C\u518D\u91CD\u542F DSH\u3002`,
184
205
  sNeverChecked: "\u8FD8\u6CA1\u6536\u8FC7",
185
206
  sFeishu: "\u98DE\u4E66",
186
207
  sComing: "\u5373\u5C06\u63A5\u5165",
@@ -245,6 +266,10 @@ var en = {
245
266
  resize: "Drag to resize; double-click to make them equal",
246
267
  tabBoard: "Board",
247
268
  tabSettings: "Settings",
269
+ restart: "Restart DSH",
270
+ restartTip: "After reinstalling or upgrading plugins, use this to restart DSH",
271
+ restartConfirm: "Restart DSH? DSH needs a restart after you install or update plugins.",
272
+ restarting: "Restarting\u2026 the window closes and opens again",
248
273
  all: "All",
249
274
  sourceGithub: "GitHub",
250
275
  sourceMail: "Mail",
@@ -260,6 +285,16 @@ var en = {
260
285
  sToken: "Token",
261
286
  sTokenEnv: (name) => `Read from the environment variable ${name}`,
262
287
  sTokenGh: "The account the GitHub CLI (gh) is signed in to; the token stays in the system keychain",
288
+ sTokenKeychain: "The token you pasted here, kept in the system keychain",
289
+ sTokenPaste: "Paste a GitHub token",
290
+ sTokenHow: "Use a classic token with the notifications scope, plus repo for private repositories. GitHub's notifications API does not take fine-grained tokens. The token goes to the system keychain only, never to a file.",
291
+ sTokenNew: "Create one on GitHub (scopes pre-ticked)",
292
+ sTokenSave: "Check and save",
293
+ sTokenSaved: (login) => `Saved; signed in as ${login}`,
294
+ sTokenReplace: "Use another token",
295
+ sTokenRemove: "Remove token",
296
+ sTokenRemoveConfirm: "Remove this token from the keychain?",
297
+ sTokenNoKeychain: (env, why) => `No usable system keychain on this computer (${why}), so the token cannot be saved. Set ${env} in the environment DSH starts from, then restart DSH.`,
263
298
  sTest: "Test connection",
264
299
  sTesting: "Testing\u2026",
265
300
  sGithubOk: (login) => `Connected as ${login}`,
@@ -273,7 +308,8 @@ var en = {
273
308
  sMail: "Mail",
274
309
  sMailPrivacy: "Read-only: the secretary opens your inbox read-only and looks at sender, recipients, subject and a few headers only. It never reads bodies and never marks mail read.",
275
310
  sSecretsAt: (kind) => `Passwords are kept in: ${kind} (encrypted, never written to a file)`,
276
- sSecretsNone: "No usable system keychain on this computer, so passwords cannot be saved. Use the environment variable shown under each account instead.",
311
+ sSecretsNone: (why) => `No usable system keychain on this computer: ${why}. Passwords are not saved; you can still save accounts and give each password through the environment variable shown under it.`,
312
+ sPasswordTestOnly: "No keychain: used only to test the connection, not saved",
277
313
  secretKinds: { macos: "the macOS Keychain", windows: "Windows Credential Manager", linux: "the Linux keyring (Secret Service)", memory: "memory (tests)" },
278
314
  sNoAccounts: "No mail account yet.",
279
315
  sAddAccount: "+ Add a mail account",
@@ -293,7 +329,7 @@ var en = {
293
329
  sSince: "Only look at unread mail from the last \u2026 days",
294
330
  sEnabled: "Enabled",
295
331
  sMailOk: (n) => `Connected; ${n} unread messages recently`,
296
- sEnvHint: (name) => `Without a keychain, set the environment variable ${name}`,
332
+ sEnvHint: (name) => `No password found. Click Edit and enter it again; if this computer has no keychain, set ${name}=<app password> in the environment DSH starts from, then restart DSH.`,
297
333
  sNeverChecked: "Not checked yet",
298
334
  sFeishu: "Feishu / Lark",
299
335
  sComing: "Coming soon",
@@ -366,6 +402,7 @@ function MishuSettings({ lang, locale, openChat: openChat2 }) {
366
402
  const [lead, setLead] = (0, import_react2.useState)({});
367
403
  const [githubTest, setGithubTest] = (0, import_react2.useState)();
368
404
  const [confirmOn, setConfirmOn] = (0, import_react2.useState)(false);
405
+ const [pasting, setPasting] = (0, import_react2.useState)(false);
369
406
  const [editing, setEditing] = (0, import_react2.useState)();
370
407
  const take = (result, saved) => {
371
408
  if (result.ok) {
@@ -426,15 +463,27 @@ function MishuSettings({ lang, locale, openChat: openChat2 }) {
426
463
  ] }),
427
464
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-row-explain", children: [
428
465
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "mishu-row-label", children: t.sToken }),
429
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: token.found ? "" : "mishu-danger", children: token.found ? token.from === "env" ? t.sTokenEnv(view.github.tokenEnv) : t.sTokenGh : token.text })
466
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: token.found ? "" : "mishu-danger", children: token.found ? token.from === "env" ? t.sTokenEnv(view.github.tokenEnv) : token.from === "keychain" ? t.sTokenKeychain : t.sTokenGh : token.text })
430
467
  ] }),
431
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-actions", children: [
468
+ token.found ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-actions", children: [
432
469
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn", onClick: () => {
433
470
  setGithubTest(t.sTesting);
434
471
  void settingsApi.testGithub(locale()).then((r) => setGithubTest(r.ok ? t.sGithubOk(r.login) : `${t.failed}${r.message}`));
435
472
  }, children: t.sTest }),
473
+ token.from === "env" || pasting ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn", onClick: () => setPasting(true), children: t.sTokenReplace }),
474
+ token.from === "keychain" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn mishu-btn-quiet", onClick: () => {
475
+ if (window.confirm(t.sTokenRemoveConfirm)) void settingsApi.deleteToken(locale()).then((r) => {
476
+ setGithubTest(void 0);
477
+ take(r);
478
+ });
479
+ }, children: t.sTokenRemove }) : null,
436
480
  githubTest === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "mishu-hint", children: githubTest })
437
- ] }),
481
+ ] }) : null,
482
+ !token.found || pasting ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(TokenForm, { view, lang, locale, onSaved: (next, login) => {
483
+ setPasting(false);
484
+ setGithubTest(t.sTokenSaved(login));
485
+ take({ ok: true, view: next });
486
+ }, onCancel: token.found ? () => setPasting(false) : void 0 }) : null,
438
487
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { className: "mishu-field", children: [
439
488
  t.sMarkRead,
440
489
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("select", { value: view.settings.markRead, onChange: (e) => {
@@ -461,7 +510,7 @@ function MishuSettings({ lang, locale, openChat: openChat2 }) {
461
510
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { className: "mishu-panel", children: [
462
511
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { children: t.sMail }),
463
512
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mishu-hint", children: t.sMailPrivacy }),
464
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: view.secrets.available ? "mishu-hint" : "mishu-hint mishu-warn", children: view.secrets.available ? t.sSecretsAt(secretKind) : t.sSecretsNone }),
513
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: view.secrets.available ? "mishu-hint" : "mishu-hint mishu-warn", children: view.secrets.available ? t.sSecretsAt(secretKind) : t.sSecretsNone(view.secrets.why ?? secretKind) }),
465
514
  view.accounts.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mishu-empty", children: t.sNoAccounts }) : view.accounts.map((account) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
466
515
  AccountRow,
467
516
  {
@@ -485,6 +534,45 @@ function MishuSettings({ lang, locale, openChat: openChat2 }) {
485
534
  ] })
486
535
  ] });
487
536
  }
537
+ function TokenForm({ view, lang, locale, onSaved, onCancel }) {
538
+ const t = TEXT[lang];
539
+ const [token, setToken] = (0, import_react2.useState)("");
540
+ const [busy, setBusy] = (0, import_react2.useState)(false);
541
+ const [message, setMessage] = (0, import_react2.useState)();
542
+ if (!view.secrets.available) {
543
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mishu-hint mishu-warn", children: t.sTokenNoKeychain(view.github.tokenEnv, view.secrets.why ?? "") });
544
+ }
545
+ const save = async () => {
546
+ setBusy(true);
547
+ setMessage(void 0);
548
+ const r = await settingsApi.saveToken(locale(), token);
549
+ setBusy(false);
550
+ if (r.ok) {
551
+ setToken("");
552
+ onSaved(r.view, r.login);
553
+ } else {
554
+ setMessage(r.message);
555
+ }
556
+ };
557
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-add", children: [
558
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { className: "mishu-field", children: [
559
+ t.sTokenPaste,
560
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { type: "password", value: token, autoComplete: "off", spellCheck: false, placeholder: "ghp_\u2026", onChange: (e) => {
561
+ setToken(e.target.value);
562
+ setMessage(void 0);
563
+ } })
564
+ ] }),
565
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mishu-hint", children: t.sTokenHow }),
566
+ message === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mishu-preview mishu-danger", children: message }),
567
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-actions", children: [
568
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn", onClick: () => window.open(view.github.newTokenUrl, "_blank", "noopener"), children: t.sTokenNew }),
569
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn primary", disabled: busy || token.trim() === "", onClick: () => {
570
+ void save();
571
+ }, children: busy ? t.sTesting : t.sTokenSave }),
572
+ onCancel === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn", onClick: onCancel, children: t.sCancel })
573
+ ] })
574
+ ] });
575
+ }
488
576
  function AccountRow({ account, lang, locale, openChat: openChat2, onEdit, onView }) {
489
577
  const t = TEXT[lang];
490
578
  const [busy, setBusy] = (0, import_react2.useState)(false);
@@ -578,11 +666,12 @@ function AccountForm({ draft, view, lang, locale, openChat: openChat2, onDone })
578
666
  ] }),
579
667
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { className: "mishu-field", children: [
580
668
  t.sPassword,
581
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { type: "password", value: password, autoComplete: "new-password", placeholder: existing ? t.sPasswordKeep : "", onChange: (e) => {
669
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { type: "password", value: password, autoComplete: "new-password", placeholder: existing && view.secrets.available ? t.sPasswordKeep : "", onChange: (e) => {
582
670
  setPassword(e.target.value);
583
671
  setMessage(void 0);
584
672
  } })
585
673
  ] }),
674
+ view.secrets.available ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mishu-hint mishu-warn", children: t.sPasswordTestOnly }),
586
675
  custom ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-row", children: [
587
676
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { className: "mishu-field", children: [
588
677
  t.sHost,
@@ -603,7 +692,10 @@ function AccountForm({ draft, view, lang, locale, openChat: openChat2, onDone })
603
692
  t.sEnabled
604
693
  ] })
605
694
  ] }),
606
- message === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: `mishu-preview ${message.ok ? "" : "mishu-danger"}`, children: message.text }),
695
+ message === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: `mishu-preview ${message.ok ? "" : "mishu-danger"}`, children: [
696
+ message.text,
697
+ message.ok || !preset.supported ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(AskAI, { topic: { kind: "mail-setup", preset: preset.id, error: message.text }, lang, locale, openChat: openChat2, compact: true })
698
+ ] }),
607
699
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-actions", children: [
608
700
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn", disabled: busy || !preset.supported, onClick: () => {
609
701
  void test();
@@ -796,7 +888,10 @@ function MishuBoard({ locale, openChat: openChat2 }) {
796
888
  );
797
889
  if (board === void 0) {
798
890
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "mishu-page", children: [
799
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "mishu-head", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h1", { children: t.title }) }),
891
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "mishu-head", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "mishu-title", children: [
892
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("h1", { children: t.title }),
893
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RestartButton, { lang, locale })
894
+ ] }) }),
800
895
  error === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "mishu-empty", children: t.loading }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "mishu-error", children: [
801
896
  t.failed,
802
897
  error
@@ -840,7 +935,8 @@ function MishuBoard({ locale, openChat: openChat2 }) {
840
935
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "mishu-tabs", role: "tablist", children: [
841
936
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", role: "tab", "aria-selected": tab === "board", className: tab === "board" ? "active" : "", onClick: () => setTab("board"), children: t.tabBoard }),
842
937
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", role: "tab", "aria-selected": tab === "settings", className: tab === "settings" ? "active" : "", onClick: () => setTab("settings"), children: t.tabSettings })
843
- ] })
938
+ ] }),
939
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RestartButton, { lang, locale })
844
940
  ] }),
845
941
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "mishu-sources", children: [
846
942
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("span", { children: [
@@ -1086,6 +1182,33 @@ function AddForm({ lang, locale, onAdded }) {
1086
1182
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { className: "mishu-hint", children: t.chatHint })
1087
1183
  ] });
1088
1184
  }
1185
+ function RestartButton({ lang, locale }) {
1186
+ const t = TEXT[lang];
1187
+ const [why, setWhy] = (0, import_react3.useState)();
1188
+ const [status, setStatus] = (0, import_react3.useState)();
1189
+ (0, import_react3.useEffect)(() => {
1190
+ void restartApi.info(locale()).then((r) => {
1191
+ if (r.ok && !r.supported) setWhy(r.why);
1192
+ });
1193
+ }, [locale]);
1194
+ const restart = async () => {
1195
+ if (why !== void 0) {
1196
+ setStatus({ ok: false, text: why });
1197
+ return;
1198
+ }
1199
+ if (!window.confirm(t.restartConfirm)) return;
1200
+ setStatus({ ok: true, text: t.restarting });
1201
+ const r = await restartApi.restart(locale());
1202
+ if (!r.ok) setStatus({ ok: false, text: r.message });
1203
+ };
1204
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
1205
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", className: "mishu-btn", title: why === void 0 ? t.restartTip : `${t.restartTip}
1206
+ ${why}`, disabled: status?.ok === true, onClick: () => {
1207
+ void restart();
1208
+ }, children: t.restart }),
1209
+ status === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("span", { className: status.ok ? "mishu-hint" : "mishu-hint mishu-warn", children: status.text })
1210
+ ] });
1211
+ }
1089
1212
 
1090
1213
  // src/client/styles.ts
1091
1214
  var v = (name, fallback) => `var(--dsw-alias-${name}, ${fallback})`;
@@ -1160,6 +1283,8 @@ var STYLES = `
1160
1283
  .mishu-field input,.mishu-field select{font:inherit;font-size:13px;color:${TEXT2};background:${LAYER2};border:1px solid ${LINE_STRONG};border-radius:6px;padding:4px 6px;width:100%}
1161
1284
  .mishu-row{display:grid;grid-template-columns:1fr 1fr;gap:6px}
1162
1285
  .mishu-preview{white-space:pre-wrap;font-size:12px;background:${LAYER2};border-radius:6px;padding:6px 8px;margin:4px 0 6px;color:${TEXT2}}
1286
+ .mishu-preview.mishu-danger{color:${DANGER}}
1287
+ .mishu-add>.mishu-hint{margin-bottom:8px}
1163
1288
  .mishu-title{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
1164
1289
  .mishu-tabs{display:flex;gap:2px;background:${LAYER1};border-radius:8px;padding:2px}
1165
1290
  .mishu-tabs button{font:inherit;font-size:13px;color:${MUTED};background:transparent;border:0;border-radius:6px;padding:3px 12px;cursor:pointer}
package/lib/core/ask.js CHANGED
@@ -125,8 +125,8 @@ export function buildAsk(store, request, options) {
125
125
  case 'mail-setup': {
126
126
  const preset = presetById(request.preset);
127
127
  return zh
128
- ? `我想让小秘书只读地查看我的「${preset.label.zh}」收件箱,需要开通 IMAP 并拿到客户端授权码(或应用专用密码)。请一步步教我怎么做,包括在网页上点哪里、要注意什么。\n\n小秘书给的说明:${preset.howTo.zh}\nIMAP 服务器:${preset.host || '(自己填)'}:${preset.port}`
129
- : `I want my secretary to read my ${preset.label.en} inbox (read-only), which needs IMAP turned on and an app password. Walk me through it step by step, including where to click and what to watch out for.\n\nThe secretary's note: ${preset.howTo.en}\nIMAP server: ${preset.host || '(enter your own)'}:${preset.port}`;
128
+ ? `我想让小秘书只读地查看我的「${preset.label.zh}」收件箱,需要开通 IMAP 并拿到客户端授权码(或应用专用密码)。请一步步教我怎么做,包括在网页上点哪里、要注意什么。\n\n小秘书给的说明:${preset.howTo.zh}\nIMAP 服务器:${preset.host || '(自己填)'}:${preset.port}${lines('\n测试连接时报错:', request.error)}`
129
+ : `I want my secretary to read my ${preset.label.en} inbox (read-only), which needs IMAP turned on and an app password. Walk me through it step by step, including where to click and what to watch out for.\n\nThe secretary's note: ${preset.howTo.en}\nIMAP server: ${preset.host || '(enter your own)'}:${preset.port}${lines('\nThe connection test said: ', request.error)}`;
130
130
  }
131
131
  }
132
132
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Running the few helper programs the secretary uses (`security`,
3
+ * `secret-tool`, PowerShell, `gh`).
4
+ *
5
+ * An app started from the Dock, the Start menu or a service manager often
6
+ * gets a short PATH that misses where package managers put programs, so a
7
+ * program installed with Homebrew, winget or `~/.local/bin` looks missing.
8
+ * `findProgram` looks on PATH first, then in those usual places.
9
+ */
10
+ import { execFile } from 'node:child_process';
11
+ import { accessSync, constants } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { posix, win32 } from 'node:path';
14
+ /** The usual install places of command-line programs, after PATH. */
15
+ export function usualDirs({ platform = process.platform, env = process.env, home = homedir() } = {}) {
16
+ if (platform === 'win32') {
17
+ const join = win32.join;
18
+ const local = env.LOCALAPPDATA ?? join(home, 'AppData', 'Local');
19
+ return [
20
+ join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0'),
21
+ join(env.ProgramFiles ?? 'C:\\Program Files', 'GitHub CLI'),
22
+ join(local, 'Programs', 'GitHub CLI'),
23
+ join(local, 'Microsoft', 'WinGet', 'Links'),
24
+ join(home, 'scoop', 'shims'),
25
+ join(env.ProgramData ?? 'C:\\ProgramData', 'chocolatey', 'bin'),
26
+ ];
27
+ }
28
+ const join = posix.join;
29
+ const common = ['/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', join(home, '.local', 'bin'), join(home, 'bin')];
30
+ return platform === 'darwin'
31
+ ? ['/opt/homebrew/bin', ...common, '/opt/local/bin']
32
+ : [...common, '/snap/bin', '/home/linuxbrew/.linuxbrew/bin', join(home, '.linuxbrew', 'bin')];
33
+ }
34
+ /** The full path of a program, or `undefined` when it is not installed anywhere we know of. */
35
+ export function findProgram(name, where = {}) {
36
+ const platform = where.platform ?? process.platform;
37
+ const env = where.env ?? process.env;
38
+ const path = platform === 'win32' ? win32 : posix;
39
+ const pathDirs = (env.PATH ?? env.Path ?? '').split(path.delimiter).filter(Boolean);
40
+ const names = platform === 'win32' && !/\.\w+$/.test(name) ? [`${name}.exe`, `${name}.cmd`] : [name];
41
+ for (const dir of [...pathDirs, ...usualDirs(where)]) {
42
+ for (const file of names) {
43
+ const full = path.join(dir, file);
44
+ try {
45
+ accessSync(full, platform === 'win32' ? constants.F_OK : constants.X_OK);
46
+ return full;
47
+ }
48
+ catch {
49
+ // not here
50
+ }
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+ /** Run a program; `input` goes to its stdin (secrets never go on the command line). Never rejects. */
56
+ export function runProgram(file, args, input, timeout = 15_000) {
57
+ return new Promise((resolve) => {
58
+ const child = execFile(file, args, { timeout, windowsHide: true, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
59
+ const code = error === null ? 0 : typeof error.code === 'number' ? error.code : 1;
60
+ const result = { code, stdout: String(stdout), stderr: String(stderr) };
61
+ if (error !== null && typeof error.code !== 'number')
62
+ result.failure = error.message;
63
+ resolve(result);
64
+ });
65
+ child.stdin?.on('error', () => { });
66
+ child.stdin?.end(input ?? '');
67
+ });
68
+ }
@@ -89,6 +89,16 @@ export class GitHubClient {
89
89
  user() {
90
90
  return this.get('/user');
91
91
  }
92
+ /** Whether this token may read notifications (fine-grained tokens get 403). */
93
+ async canReadNotifications() {
94
+ const url = `${API}/notifications?per_page=1`;
95
+ const { status, body } = await this.raw('GET', url);
96
+ if (status >= 200 && status < 300)
97
+ return true;
98
+ if (status === 403 || status === 404)
99
+ return false;
100
+ throw new GitHubError(status, url, body?.message ?? 'request failed');
101
+ }
92
102
  repo(fullName) {
93
103
  return this.get(`/repos/${fullName}`);
94
104
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Where the GitHub token comes from, in this order:
3
+ *
4
+ * 1. the environment variable named in the config (GITHUB_TOKEN)
5
+ * 2. a token you pasted on the settings page, kept in the system keychain
6
+ * 3. the GitHub CLI (`gh auth token`), which keeps its own in the keychain
7
+ *
8
+ * A token is never written to the notebook or a file.
9
+ */
10
+ import { findProgram, runProgram } from "../exec.js";
11
+ import { bi, MishuError } from "../i18n.js";
12
+ import { GitHubClient, GitHubError } from "./client.js";
13
+ /** The keychain entry for a token pasted on the settings page. */
14
+ export const GITHUB_SECRET = 'github';
15
+ /** Opens GitHub's "new classic token" page with the two scopes ticked. */
16
+ export const NEW_TOKEN_URL = 'https://github.com/settings/tokens/new?scopes=notifications,repo&description=DSH%20mishu';
17
+ /** `ghPath` is for tests: a fixed path, or `null` for "not installed". */
18
+ export async function resolveGithubToken(options) {
19
+ const { envName, useGhCli, secrets } = options;
20
+ const fromEnv = envName === '' ? undefined : process.env[envName]?.trim();
21
+ if (fromEnv)
22
+ return { token: fromEnv, from: 'env' };
23
+ const stored = await secrets?.get(GITHUB_SECRET).catch(() => undefined);
24
+ if (stored)
25
+ return { token: stored, from: 'keychain' };
26
+ const none = bi(envName === '' ? '没有 GitHub token' : `没有 GitHub token(环境变量 ${envName} 是空的,钥匙串里也没有存)`, envName === '' ? 'no GitHub token' : `no GitHub token (environment variable ${envName} is empty and none is saved in the keychain)`);
27
+ const paste = bi('在小秘书的设置页填一个 token', 'paste a token on the secretary\'s settings page');
28
+ if (!useGhCli)
29
+ return { error: bi(`${none.zh}。${paste.zh}。`, `${none.en}. ${cap(paste.en)}.`) };
30
+ const gh = options.ghPath === undefined ? findProgram('gh') : options.ghPath ?? undefined;
31
+ if (gh === undefined) {
32
+ return {
33
+ error: bi(`${none.zh},这台电脑上也没找到 GitHub CLI(gh)。${paste.zh},或者装上 gh 后运行 gh auth login。`, `${none.en}, and the GitHub CLI (gh) is not installed on this computer. ${cap(paste.en)}, or install gh and run gh auth login.`),
34
+ };
35
+ }
36
+ const r = await runProgram(gh, ['auth', 'token'], undefined, 5000);
37
+ const token = r.stdout.trim();
38
+ if (r.code === 0 && token !== '')
39
+ return { token, from: 'gh' };
40
+ const why = (r.stderr.trim() || r.failure || `exit ${r.code}`).split('\n')[0].slice(0, 200);
41
+ return {
42
+ error: bi(`${none.zh},gh 也没给出 token(${why})。运行 gh auth login 登录,或者${paste.zh}。`, `${none.en}, and gh gave no token (${why}). Run gh auth login, or ${paste.en}.`),
43
+ };
44
+ }
45
+ function cap(text) {
46
+ return text.charAt(0).toUpperCase() + text.slice(1);
47
+ }
48
+ /**
49
+ * Check a pasted token with GitHub, then keep it in the keychain. It must
50
+ * read notifications, which only classic tokens with the `notifications`
51
+ * scope can (fine-grained tokens cannot reach that API).
52
+ */
53
+ export async function saveGithubToken(secrets, raw, fetch) {
54
+ const token = raw.trim();
55
+ if (!/^[\w-]{20,255}$/.test(token)) {
56
+ throw new MishuError(bi('这不像 GitHub token:应该是一串没有空格的字母数字,classic token 以 ghp_ 开头', 'That does not look like a GitHub token: it should be letters and digits without spaces; classic tokens start with ghp_'));
57
+ }
58
+ const status = await secrets.status();
59
+ if (!status.available)
60
+ throw new MishuError(bi(`没法保存:${status.why.zh}`, `Cannot save it: ${status.why.en}`));
61
+ const gh = new GitHubClient({ token, ...fetch === undefined ? {} : { fetch } });
62
+ let login;
63
+ try {
64
+ login = (await gh.user()).login;
65
+ }
66
+ catch (error) {
67
+ if (error instanceof GitHubError && error.status === 401)
68
+ throw new MishuError(bi('GitHub 不认这个 token(可能填错了、过期了或被撤销了)', 'GitHub does not accept this token (mistyped, expired or revoked)'));
69
+ throw error;
70
+ }
71
+ if (!await gh.canReadNotifications()) {
72
+ throw new MishuError(bi(`这个 token 属于 ${login},但读不了通知。GitHub 的通知接口只认 classic token:生成时勾选 notifications(要看私有仓库再勾 repo)。`, `This token belongs to ${login} but cannot read notifications. GitHub's notifications API only takes classic tokens: tick notifications when you create it (and repo for private repositories).`));
73
+ }
74
+ await secrets.set(GITHUB_SECRET, token);
75
+ return { login };
76
+ }
77
+ export async function deleteGithubToken(secrets) {
78
+ await secrets.delete(GITHUB_SECRET);
79
+ }
package/lib/core/index.js CHANGED
@@ -2,10 +2,8 @@
2
2
  * mishu core — everything that does not depend on DSH.
3
3
  * Nothing under `src/core` may import DSH; `tests/boundary.test.ts` enforces it.
4
4
  */
5
- import { execFile } from 'node:child_process';
6
5
  import { homedir } from 'node:os';
7
6
  import { join } from 'node:path';
8
- import { bi } from "./i18n.js";
9
7
  export { buildAsk, detailText } from "./ask.js";
10
8
  export { buildBoard, buildDetail } from "./board.js";
11
9
  export { renderBrief, renderClosed, renderMatter, reasonLabel, sortOpen } from "./brief.js";
@@ -13,6 +11,7 @@ export { GitHubClient, GitHubError } from "./github/client.js";
13
11
  export { gatherFacts, Lookups, parseCheckSuiteTitle } from "./github/facts.js";
14
12
  export { CLOSING_REASONS, judge } from "./github/rules.js";
15
13
  export { syncGithub } from "./github/sync.js";
14
+ export { deleteGithubToken, GITHUB_SECRET, NEW_TOKEN_URL, resolveGithubToken, saveGithubToken } from "./github/token.js";
16
15
  export { bi, decode, detectLang, errorText, LANGS, MishuError, pick } from "./i18n.js";
17
16
  export { addSeries, describeActiveSeries, describeSeries, resolveSeries, stopSeries, tickManual, upcoming, } from "./manual.js";
18
17
  export { DEFAULT_DEMOTION, isActive, isDemoted, setStatusByUser, snoozeByUser } from "./matters.js";
@@ -23,6 +22,8 @@ export { MAIL_PRESETS, presetById, secretAccount } from "./mail/account.js";
23
22
  export { ImapFetcher } from "./mail/imap.js";
24
23
  export { judgeMail } from "./mail/rules.js";
25
24
  export { describeMailError, MAIL_SOURCE, mailReportKey, syncMail } from "./mail/sync.js";
25
+ export { findProgram } from "./exec.js";
26
+ export { restartPlan, scheduleRestart } from "./restart.js";
26
27
  export { MemorySecrets, secretEnvName, secretFromEnv, systemSecrets } from "./secrets.js";
27
28
  export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings } from "./settings.js";
28
29
  export const DEFAULT_HOME = '~/.mishu';
@@ -38,28 +39,3 @@ export function mishuPaths(home) {
38
39
  const root = expandHome(home);
39
40
  return { root, db: join(root, 'mishu.db') };
40
41
  }
41
- /**
42
- * A GitHub token without storing one: the named environment variable, else
43
- * the GitHub CLI (`gh auth token`, which reads the system credential store).
44
- */
45
- export async function resolveGithubToken(envName, useGhCli) {
46
- const fromEnv = envName === '' ? undefined : process.env[envName]?.trim();
47
- if (fromEnv)
48
- return { token: fromEnv, from: 'env' };
49
- if (!useGhCli)
50
- return { error: bi(`没有 GitHub token:环境变量 ${envName} 是空的`, `no GitHub token: environment variable ${envName} is empty`) };
51
- return new Promise((resolve) => {
52
- execFile('gh', ['auth', 'token'], { timeout: 5000, windowsHide: true }, (error, stdout) => {
53
- const token = stdout?.trim();
54
- if (error !== null || !token) {
55
- const why = error?.message ?? 'empty output';
56
- resolve({
57
- error: bi(`没有 GitHub token:环境变量 ${envName} 是空的,gh auth token 也没拿到(${why})。先运行 gh auth login,或设置 ${envName}`, `no GitHub token: environment variable ${envName} is empty and gh auth token failed (${why}). Run gh auth login, or set ${envName}`),
58
- });
59
- }
60
- else {
61
- resolve({ token, from: 'gh' });
62
- }
63
- });
64
- });
65
- }
@@ -9,7 +9,7 @@ import { bi, MishuError } from "../i18n.js";
9
9
  export const MAIL_PRESETS = [
10
10
  {
11
11
  id: 'qq', label: bi('QQ 邮箱', 'QQ Mail'), host: 'imap.qq.com', port: 993, secure: true, supported: true,
12
- howTo: bi('在 QQ 邮箱网页版:设置 → 账号 → 开启「IMAP/SMTP 服务」,按提示生成授权码。这里填授权码,不是 QQ 密码。', 'In QQ Mail on the web: Settings → Account → turn on "IMAP/SMTP" and generate an authorization code. Enter that code here, not your QQ password.'),
12
+ howTo: bi('在 QQ 邮箱网页版:设置 → 账号与安全 → 安全设置(旧版界面是 设置 → 账户),开启「IMAP/SMTP 服务」,按提示验证后生成 16 位授权码。这里填授权码,不是 QQ 密码;改过 QQ 密码后,旧授权码会失效。', 'In QQ Mail on the web: Settings → Account and Security → Security (Settings → Account in the older layout), turn on "IMAP/SMTP", verify, and generate a 16-character authorization code. Enter that code here, not your QQ password; changing your QQ password expires old codes.'),
13
13
  },
14
14
  {
15
15
  id: '163', label: bi('网易 163 邮箱', 'NetEase 163'), host: 'imap.163.com', port: 993, secure: true, supported: true,
@@ -53,8 +53,10 @@ export function resolveAccount(input, id) {
53
53
  if (!preset.supported)
54
54
  throw new MishuError(bi(`${preset.label.zh}暂时不支持:${preset.howTo.zh}`, `${preset.label.en} is not supported yet: ${preset.howTo.en}`));
55
55
  const host = preset.id === 'custom' ? String(input.host ?? '').trim() : preset.host;
56
- if (!/^[\w.-]+$/.test(host))
57
- throw new MishuError(bi('IMAP 服务器地址不对', 'invalid IMAP server'));
56
+ // A name with a dot (imap.example.com), a full IPv4 address, or localhost; a bare number is a port typed in the wrong box.
57
+ if (!/^[\w-]+(\.[\w-]+)+$/.test(host) && host !== 'localhost' || /^\d+$/.test(host)) {
58
+ throw new MishuError(bi(`IMAP 服务器地址不对:${host || '(空)'},应该像 imap.example.com`, `invalid IMAP server: ${host || '(empty)'}; it should look like imap.example.com`));
59
+ }
58
60
  const port = preset.id === 'custom' ? Number(input.port ?? 993) : preset.port;
59
61
  if (!Number.isInteger(port) || port < 1 || port > 65535)
60
62
  throw new MishuError(bi('端口要是 1 到 65535 的整数', 'the port must be a whole number from 1 to 65535'));