dsh-mishu 0.5.0 → 0.5.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/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
 
@@ -97,10 +97,11 @@ agent 可用的工具:`mishu_add`(交代,需要你审批)、`mishu_list`
97
97
 
98
98
  ### GitHub token
99
99
 
100
- mishu 自己不保存 token。按顺序取:
100
+ 按顺序取,token 不写进任何文件:
101
101
 
102
102
  1. 环境变量(默认 `GITHUB_TOKEN`)
103
- 2. `gh auth token`:装了 GitHub CLI,并运行过 `gh auth login` 就行
103
+ 2. 你在设置页填的 token:先拿到 GitHub 上检查,再存进系统钥匙串。要用 classic token,勾选 `notifications`(看私有仓库再勾 `repo`);GitHub 的通知接口不认 fine-grained token
104
+ 3. `gh auth token`:装了 GitHub CLI,并运行过 `gh auth login` 就行。从 Dock、开始菜单启动的 DSH 拿到的 PATH 往往很短,所以小秘书除了 PATH,还会去 Homebrew、`~/.local/bin`、winget、Scoop 等常见安装位置找 `gh`
104
105
 
105
106
  ### 安装和配置
106
107
 
@@ -177,7 +178,7 @@ Default warning times: documents 90 days, bills 3, birthdays 7, renewals 7, ever
177
178
 
178
179
  A thread of replies is one item. Once you read it in your mail client, its item closes by itself.
179
180
 
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>`.
181
+ **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
182
 
182
183
  ### Usage
183
184
 
@@ -220,10 +221,11 @@ Tools for the agent: `mishu_add` (needs your approval), `mishu_list`, `mishu_det
220
221
 
221
222
  ### GitHub token
222
223
 
223
- mishu never stores a token. It uses, in order:
224
+ It uses, in order, and never writes a token to a file:
224
225
 
225
226
  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`
227
+ 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
228
+ 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
229
 
228
230
  ### Install and configure
229
231
 
package/lib/client.js CHANGED
@@ -64,6 +64,8 @@ var settingsApi = {
64
64
  load: (lang) => post("/settings", { lang }),
65
65
  save: (lang, settings) => post("/settings/save", { lang, settings }),
66
66
  testGithub: (lang) => post("/github/test", { lang }),
67
+ saveToken: (lang, token) => post("/github/token/save", { lang, token }),
68
+ deleteToken: (lang) => post("/github/token/delete", { lang }),
67
69
  saveMail: (lang, account, password) => post("/mail/save", { lang, account, password }),
68
70
  testMail: (lang, account, password) => post("/mail/test", { lang, account, password }),
69
71
  deleteMail: (lang, id) => post("/mail/delete", { lang, id }),
@@ -147,6 +149,16 @@ var zh = {
147
149
  sToken: "Token",
148
150
  sTokenEnv: (name) => `\u4ECE\u73AF\u5883\u53D8\u91CF ${name} \u8BFB\u53D6`,
149
151
  sTokenGh: "\u7528 GitHub CLI\uFF08gh\uFF09\u767B\u5F55\u7684\u8D26\u53F7\uFF0Ctoken \u5728\u7CFB\u7EDF\u94A5\u5319\u4E32\u91CC",
152
+ sTokenKeychain: "\u4F60\u5728\u8FD9\u91CC\u586B\u7684 token\uFF0C\u5B58\u5728\u7CFB\u7EDF\u94A5\u5319\u4E32\u91CC",
153
+ sTokenPaste: "\u586B\u4E00\u4E2A GitHub token",
154
+ 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",
155
+ sTokenNew: "\u53BB GitHub \u751F\u6210\uFF08\u6743\u9650\u5DF2\u52FE\u597D\uFF09",
156
+ sTokenSave: "\u68C0\u67E5\u5E76\u4FDD\u5B58",
157
+ sTokenSaved: (login) => `\u4FDD\u5B58\u597D\u4E86\uFF0C\u8D26\u53F7 ${login}`,
158
+ sTokenReplace: "\u6362\u4E00\u4E2A token",
159
+ sTokenRemove: "\u79FB\u9664 token",
160
+ sTokenRemoveConfirm: "\u4ECE\u94A5\u5319\u4E32\u91CC\u79FB\u9664\u8FD9\u4E2A token\uFF1F",
161
+ 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
162
  sTest: "\u6D4B\u8BD5\u8FDE\u63A5",
151
163
  sTesting: "\u6B63\u5728\u6D4B\u8BD5\u2026",
152
164
  sGithubOk: (login) => `\u8FDE\u4E0A\u4E86\uFF0C\u8D26\u53F7 ${login}`,
@@ -160,7 +172,8 @@ var zh = {
160
172
  sMail: "\u90AE\u7BB1",
161
173
  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
174
  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",
175
+ 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`,
176
+ sPasswordTestOnly: "\u6CA1\u6709\u94A5\u5319\u4E32\uFF1A\u8FD9\u91CC\u586B\u7684\u53EA\u7528\u6765\u6D4B\u8BD5\u8FDE\u63A5\uFF0C\u4E0D\u4F1A\u4FDD\u5B58",
164
177
  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
178
  sNoAccounts: "\u8FD8\u6CA1\u6709\u6DFB\u52A0\u90AE\u7BB1\u3002",
166
179
  sAddAccount: "+ \u6DFB\u52A0\u90AE\u7BB1",
@@ -180,7 +193,7 @@ var zh = {
180
193
  sSince: "\u53EA\u770B\u6700\u8FD1\u51E0\u5929\u7684\u672A\u8BFB\u90AE\u4EF6",
181
194
  sEnabled: "\u542F\u7528",
182
195
  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}`,
196
+ 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
197
  sNeverChecked: "\u8FD8\u6CA1\u6536\u8FC7",
185
198
  sFeishu: "\u98DE\u4E66",
186
199
  sComing: "\u5373\u5C06\u63A5\u5165",
@@ -260,6 +273,16 @@ var en = {
260
273
  sToken: "Token",
261
274
  sTokenEnv: (name) => `Read from the environment variable ${name}`,
262
275
  sTokenGh: "The account the GitHub CLI (gh) is signed in to; the token stays in the system keychain",
276
+ sTokenKeychain: "The token you pasted here, kept in the system keychain",
277
+ sTokenPaste: "Paste a GitHub token",
278
+ 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.",
279
+ sTokenNew: "Create one on GitHub (scopes pre-ticked)",
280
+ sTokenSave: "Check and save",
281
+ sTokenSaved: (login) => `Saved; signed in as ${login}`,
282
+ sTokenReplace: "Use another token",
283
+ sTokenRemove: "Remove token",
284
+ sTokenRemoveConfirm: "Remove this token from the keychain?",
285
+ 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
286
  sTest: "Test connection",
264
287
  sTesting: "Testing\u2026",
265
288
  sGithubOk: (login) => `Connected as ${login}`,
@@ -273,7 +296,8 @@ var en = {
273
296
  sMail: "Mail",
274
297
  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
298
  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.",
299
+ 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.`,
300
+ sPasswordTestOnly: "No keychain: used only to test the connection, not saved",
277
301
  secretKinds: { macos: "the macOS Keychain", windows: "Windows Credential Manager", linux: "the Linux keyring (Secret Service)", memory: "memory (tests)" },
278
302
  sNoAccounts: "No mail account yet.",
279
303
  sAddAccount: "+ Add a mail account",
@@ -293,7 +317,7 @@ var en = {
293
317
  sSince: "Only look at unread mail from the last \u2026 days",
294
318
  sEnabled: "Enabled",
295
319
  sMailOk: (n) => `Connected; ${n} unread messages recently`,
296
- sEnvHint: (name) => `Without a keychain, set the environment variable ${name}`,
320
+ 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
321
  sNeverChecked: "Not checked yet",
298
322
  sFeishu: "Feishu / Lark",
299
323
  sComing: "Coming soon",
@@ -366,6 +390,7 @@ function MishuSettings({ lang, locale, openChat: openChat2 }) {
366
390
  const [lead, setLead] = (0, import_react2.useState)({});
367
391
  const [githubTest, setGithubTest] = (0, import_react2.useState)();
368
392
  const [confirmOn, setConfirmOn] = (0, import_react2.useState)(false);
393
+ const [pasting, setPasting] = (0, import_react2.useState)(false);
369
394
  const [editing, setEditing] = (0, import_react2.useState)();
370
395
  const take = (result, saved) => {
371
396
  if (result.ok) {
@@ -426,15 +451,27 @@ function MishuSettings({ lang, locale, openChat: openChat2 }) {
426
451
  ] }),
427
452
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-row-explain", children: [
428
453
  /* @__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 })
454
+ /* @__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
455
  ] }),
431
- /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-actions", children: [
456
+ token.found ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-actions", children: [
432
457
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn", onClick: () => {
433
458
  setGithubTest(t.sTesting);
434
459
  void settingsApi.testGithub(locale()).then((r) => setGithubTest(r.ok ? t.sGithubOk(r.login) : `${t.failed}${r.message}`));
435
460
  }, children: t.sTest }),
461
+ token.from === "env" || pasting ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn", onClick: () => setPasting(true), children: t.sTokenReplace }),
462
+ token.from === "keychain" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn mishu-btn-quiet", onClick: () => {
463
+ if (window.confirm(t.sTokenRemoveConfirm)) void settingsApi.deleteToken(locale()).then((r) => {
464
+ setGithubTest(void 0);
465
+ take(r);
466
+ });
467
+ }, children: t.sTokenRemove }) : null,
436
468
  githubTest === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "mishu-hint", children: githubTest })
437
- ] }),
469
+ ] }) : null,
470
+ !token.found || pasting ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(TokenForm, { view, lang, locale, onSaved: (next, login) => {
471
+ setPasting(false);
472
+ setGithubTest(t.sTokenSaved(login));
473
+ take({ ok: true, view: next });
474
+ }, onCancel: token.found ? () => setPasting(false) : void 0 }) : null,
438
475
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { className: "mishu-field", children: [
439
476
  t.sMarkRead,
440
477
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("select", { value: view.settings.markRead, onChange: (e) => {
@@ -461,7 +498,7 @@ function MishuSettings({ lang, locale, openChat: openChat2 }) {
461
498
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { className: "mishu-panel", children: [
462
499
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { children: t.sMail }),
463
500
  /* @__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 }),
501
+ /* @__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
502
  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
503
  AccountRow,
467
504
  {
@@ -485,6 +522,45 @@ function MishuSettings({ lang, locale, openChat: openChat2 }) {
485
522
  ] })
486
523
  ] });
487
524
  }
525
+ function TokenForm({ view, lang, locale, onSaved, onCancel }) {
526
+ const t = TEXT[lang];
527
+ const [token, setToken] = (0, import_react2.useState)("");
528
+ const [busy, setBusy] = (0, import_react2.useState)(false);
529
+ const [message, setMessage] = (0, import_react2.useState)();
530
+ if (!view.secrets.available) {
531
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mishu-hint mishu-warn", children: t.sTokenNoKeychain(view.github.tokenEnv, view.secrets.why ?? "") });
532
+ }
533
+ const save = async () => {
534
+ setBusy(true);
535
+ setMessage(void 0);
536
+ const r = await settingsApi.saveToken(locale(), token);
537
+ setBusy(false);
538
+ if (r.ok) {
539
+ setToken("");
540
+ onSaved(r.view, r.login);
541
+ } else {
542
+ setMessage(r.message);
543
+ }
544
+ };
545
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-add", children: [
546
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { className: "mishu-field", children: [
547
+ t.sTokenPaste,
548
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { type: "password", value: token, autoComplete: "off", spellCheck: false, placeholder: "ghp_\u2026", onChange: (e) => {
549
+ setToken(e.target.value);
550
+ setMessage(void 0);
551
+ } })
552
+ ] }),
553
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mishu-hint", children: t.sTokenHow }),
554
+ message === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mishu-preview mishu-danger", children: message }),
555
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-actions", children: [
556
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn", onClick: () => window.open(view.github.newTokenUrl, "_blank", "noopener"), children: t.sTokenNew }),
557
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn primary", disabled: busy || token.trim() === "", onClick: () => {
558
+ void save();
559
+ }, children: busy ? t.sTesting : t.sTokenSave }),
560
+ onCancel === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn", onClick: onCancel, children: t.sCancel })
561
+ ] })
562
+ ] });
563
+ }
488
564
  function AccountRow({ account, lang, locale, openChat: openChat2, onEdit, onView }) {
489
565
  const t = TEXT[lang];
490
566
  const [busy, setBusy] = (0, import_react2.useState)(false);
@@ -578,11 +654,12 @@ function AccountForm({ draft, view, lang, locale, openChat: openChat2, onDone })
578
654
  ] }),
579
655
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { className: "mishu-field", children: [
580
656
  t.sPassword,
581
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { type: "password", value: password, autoComplete: "new-password", placeholder: existing ? t.sPasswordKeep : "", onChange: (e) => {
657
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("input", { type: "password", value: password, autoComplete: "new-password", placeholder: existing && view.secrets.available ? t.sPasswordKeep : "", onChange: (e) => {
582
658
  setPassword(e.target.value);
583
659
  setMessage(void 0);
584
660
  } })
585
661
  ] }),
662
+ view.secrets.available ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mishu-hint mishu-warn", children: t.sPasswordTestOnly }),
586
663
  custom ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-row", children: [
587
664
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("label", { className: "mishu-field", children: [
588
665
  t.sHost,
@@ -603,7 +680,10 @@ function AccountForm({ draft, view, lang, locale, openChat: openChat2, onDone })
603
680
  t.sEnabled
604
681
  ] })
605
682
  ] }),
606
- message === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: `mishu-preview ${message.ok ? "" : "mishu-danger"}`, children: message.text }),
683
+ message === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: `mishu-preview ${message.ok ? "" : "mishu-danger"}`, children: [
684
+ message.text,
685
+ 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 })
686
+ ] }),
607
687
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "mishu-actions", children: [
608
688
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", className: "mishu-btn", disabled: busy || !preset.supported, onClick: () => {
609
689
  void test();
@@ -1160,6 +1240,8 @@ var STYLES = `
1160
1240
  .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
1241
  .mishu-row{display:grid;grid-template-columns:1fr 1fr;gap:6px}
1162
1242
  .mishu-preview{white-space:pre-wrap;font-size:12px;background:${LAYER2};border-radius:6px;padding:6px 8px;margin:4px 0 6px;color:${TEXT2}}
1243
+ .mishu-preview.mishu-danger{color:${DANGER}}
1244
+ .mishu-add>.mishu-hint{margin-bottom:8px}
1163
1245
  .mishu-title{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
1164
1246
  .mishu-tabs{display:flex;gap:2px;background:${LAYER1};border-radius:8px;padding:2px}
1165
1247
  .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,7 @@ 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
26
  export { MemorySecrets, secretEnvName, secretFromEnv, systemSecrets } from "./secrets.js";
27
27
  export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings } from "./settings.js";
28
28
  export const DEFAULT_HOME = '~/.mishu';
@@ -38,28 +38,3 @@ export function mishuPaths(home) {
38
38
  const root = expandHome(home);
39
39
  return { root, db: join(root, 'mishu.db') };
40
40
  }
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'));
@@ -8,15 +8,18 @@
8
8
  */
9
9
  import { applyVerdict, closeReadElsewhere } from "../fold.js";
10
10
  import { bi } from "../i18n.js";
11
+ import { presetById } from "./account.js";
11
12
  import { judgeMail } from "./rules.js";
12
13
  export const MAIL_SOURCE = 'mail';
13
14
  /** A connection or login failure, said so a person knows what to fix. */
14
15
  export function describeMailError(error, account) {
15
16
  const raw = error.responseText ?? error.message ?? String(error);
16
17
  const auth = /auth|login|password|credential|AUTHENTICATIONFAILED|LOGIN failed/i.test(raw);
17
- return auth
18
- ? bi(`登录失败:服务器不接受这个用户名或密码(${raw})。多数邮箱要填客户端授权码或应用专用密码,不是网页登录密码。`, `Login failed: the server rejected the user name or password (${raw}). Most providers need an app password, not your web password.`)
19
- : bi(`连接 ${account.host}:${account.port} 失败:${raw}`, `Could not reach ${account.host}:${account.port}: ${raw}`);
18
+ if (!auth)
19
+ return bi(`连接 ${account.host}:${account.port} 失败:${raw}`, `Could not reach ${account.host}:${account.port}: ${raw}`);
20
+ const preset = presetById(account.preset);
21
+ const howTo = preset.id === 'custom' ? undefined : preset.howTo;
22
+ return bi(`登录失败:服务器不接受这个用户名或密码(${raw})。多数邮箱要先开启 IMAP,再填客户端授权码或应用专用密码,不是网页登录密码。${howTo === undefined ? '' : `\n${preset.label.zh}:${howTo.zh}`}`, `Login failed: the server rejected the user name or password (${raw}). Most providers need IMAP turned on and an app password, not your web password.${howTo === undefined ? '' : `\n${preset.label.en}: ${howTo.en}`}`);
20
23
  }
21
24
  export function mailReportKey(accountId) {
22
25
  return `mail.lastSync.${accountId}`;
@@ -10,17 +10,26 @@
10
10
  * stored under the service name "dsh-mishu". When no keychain is available,
11
11
  * `secretFromEnv` is the documented fallback.
12
12
  */
13
- import { execFile } from 'node:child_process';
13
+ import { findProgram, runProgram } from "./exec.js";
14
+ import { bi } from "./i18n.js";
14
15
  export const SERVICE = 'dsh-mishu';
15
- function run(file, args, input) {
16
- return new Promise((resolve) => {
17
- const child = execFile(file, args, { timeout: 15_000, windowsHide: true, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
18
- const code = error === null ? 0 : typeof error.code === 'number' ? error.code : 1;
19
- resolve({ code, stdout: String(stdout), stderr: String(stderr) });
20
- });
21
- child.stdin?.end(input ?? '');
22
- });
16
+ /** A helper program, found by full path so a short PATH does not hide it. */
17
+ class Helper {
18
+ name;
19
+ path;
20
+ constructor(name) { this.name = name; }
21
+ find() {
22
+ this.path ??= findProgram(this.name);
23
+ return this.path;
24
+ }
25
+ async run(args, input) {
26
+ const path = this.find();
27
+ if (path === undefined)
28
+ return { code: 127, stdout: '', stderr: '', failure: `${this.name} not found` };
29
+ return runProgram(path, args, input);
30
+ }
23
31
  }
32
+ const detail = (r) => (r.stderr.trim() || r.failure || `exit ${r.code}`).slice(0, 300);
24
33
  /** Accounts are plain ids we choose ("mail:3"); refuse anything else so they are safe in every helper. */
25
34
  function checkAccount(account) {
26
35
  if (!/^[\w:.-]{1,80}$/.test(account))
@@ -32,32 +41,47 @@ function checkSecret(secret) {
32
41
  }
33
42
  class MacKeychain {
34
43
  kind = 'macos';
35
- async available() { return (await run('security', ['help'])).code === 0 || (await run('security', ['list-keychains'])).code === 0; }
44
+ security = new Helper('security');
45
+ async status() {
46
+ const r = await this.security.run(['list-keychains']);
47
+ if (r.code === 0)
48
+ return { available: true };
49
+ return { available: false, why: bi(`macOS 钥匙串用不了(${detail(r)})`, `the macOS Keychain is not usable (${detail(r)})`) };
50
+ }
36
51
  async get(account) {
37
52
  checkAccount(account);
38
- const r = await run('security', ['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
53
+ const r = await this.security.run(['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
39
54
  return r.code === 0 ? r.stdout.replace(/\n$/, '') : undefined;
40
55
  }
41
56
  async set(account, secret) {
42
57
  checkAccount(account);
43
58
  checkSecret(secret);
44
59
  // `-w` last: security prompts twice and reads both answers from stdin.
45
- const r = await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-l', `${SERVICE} ${account}`, '-w'], `${secret}\n${secret}\n`);
60
+ const r = await this.security.run(['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-l', `${SERVICE} ${account}`, '-w'], `${secret}\n${secret}\n`);
46
61
  if (r.code !== 0)
47
62
  throw new Error(`Keychain refused to save the password (${r.stderr.trim() || r.code})`);
48
63
  }
49
64
  async delete(account) {
50
65
  checkAccount(account);
51
- await run('security', ['delete-generic-password', '-s', SERVICE, '-a', account]);
66
+ await this.security.run(['delete-generic-password', '-s', SERVICE, '-a', account]);
52
67
  }
53
68
  }
54
69
  const PS_PRELUDE = '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];$v=New-Object Windows.Security.Credentials.PasswordVault;';
55
70
  class WindowsVault {
56
71
  kind = 'windows';
72
+ powershell = new Helper('powershell');
57
73
  ps(script, input) {
58
- return run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_PRELUDE + script], input);
74
+ return this.powershell.run(['-NoProfile', '-NonInteractive', '-Command', PS_PRELUDE + script], input);
75
+ }
76
+ async status() {
77
+ if (this.powershell.find() === undefined) {
78
+ return { available: false, why: bi('没找到 Windows PowerShell(powershell.exe),小秘书靠它使用 Windows 凭据管理器', 'Windows PowerShell (powershell.exe) was not found; the secretary uses it to reach Windows Credential Manager') };
79
+ }
80
+ const r = await this.ps('exit 0');
81
+ if (r.code === 0)
82
+ return { available: true };
83
+ return { available: false, why: bi(`Windows 凭据管理器用不了(${detail(r)})`, `Windows Credential Manager is not usable (${detail(r)})`) };
59
84
  }
60
- async available() { return (await this.ps('exit 0')).code === 0; }
61
85
  async get(account) {
62
86
  checkAccount(account);
63
87
  const r = await this.ps(`try{$c=$v.Retrieve('${SERVICE}','${account}');$c.RetrievePassword();[Console]::Out.Write($c.Password)}catch{exit 3}`);
@@ -77,31 +101,55 @@ class WindowsVault {
77
101
  }
78
102
  class SecretTool {
79
103
  kind = 'linux';
80
- async available() { return (await run('secret-tool', ['--version'])).code === 0; }
104
+ tool = new Helper('secret-tool');
105
+ async status() {
106
+ if (this.tool.find() === undefined) {
107
+ return {
108
+ available: false,
109
+ why: bi('没找到 secret-tool。Debian/Ubuntu 装 libsecret-tools(sudo apt install libsecret-tools),Fedora 装 libsecret(sudo dnf install libsecret),Arch 装 libsecret', 'secret-tool was not found. Install libsecret-tools on Debian/Ubuntu (sudo apt install libsecret-tools), libsecret on Fedora (sudo dnf install libsecret) or Arch'),
110
+ };
111
+ }
112
+ // A lookup that finds nothing exits 1 quietly; a missing keyring service complains on stderr.
113
+ const r = await this.tool.run(['lookup', 'service', SERVICE, 'account', 'probe']);
114
+ if (r.code === 0 || (r.code === 1 && r.stderr.trim() === '' && r.failure === undefined))
115
+ return { available: true };
116
+ return {
117
+ available: false,
118
+ why: bi(`secret-tool 连不上钥匙串服务(${detail(r)})。要在桌面会话里运行着 GNOME Keyring 或 KWallet;没有桌面的服务器上一般没有`, `secret-tool cannot reach a keyring service (${detail(r)}). It needs GNOME Keyring or KWallet running in a desktop session; servers without a desktop usually have none`),
119
+ };
120
+ }
81
121
  async get(account) {
82
122
  checkAccount(account);
83
- const r = await run('secret-tool', ['lookup', 'service', SERVICE, 'account', account]);
123
+ const r = await this.tool.run(['lookup', 'service', SERVICE, 'account', account]);
84
124
  return r.code === 0 && r.stdout !== '' ? r.stdout.replace(/\n$/, '') : undefined;
85
125
  }
86
126
  async set(account, secret) {
87
127
  checkAccount(account);
88
128
  checkSecret(secret);
89
- const r = await run('secret-tool', ['store', '--label', `${SERVICE} ${account}`, 'service', SERVICE, 'account', account], secret);
129
+ const r = await this.tool.run(['store', '--label', `${SERVICE} ${account}`, 'service', SERVICE, 'account', account], secret);
90
130
  if (r.code !== 0)
91
131
  throw new Error(`Secret Service refused to save the password (${r.stderr.trim() || r.code})`);
92
132
  }
93
133
  async delete(account) {
94
134
  checkAccount(account);
95
- await run('secret-tool', ['clear', 'service', SERVICE, 'account', account]);
135
+ await this.tool.run(['clear', 'service', SERVICE, 'account', account]);
96
136
  }
97
137
  }
98
- /** For tests: secrets in memory only. */
138
+ /** For tests: secrets in memory only; `unavailable` plays a machine without a keychain. */
99
139
  export class MemorySecrets {
100
140
  kind = 'memory';
101
141
  map = new Map();
102
- async available() { return true; }
103
- async get(account) { return this.map.get(account); }
104
- async set(account, secret) { checkAccount(account); checkSecret(secret); this.map.set(account, secret); }
142
+ unavailable;
143
+ constructor(unavailable) { this.unavailable = unavailable; }
144
+ async status() { return this.unavailable === undefined ? { available: true } : { available: false, why: this.unavailable }; }
145
+ async get(account) { return this.unavailable === undefined ? this.map.get(account) : undefined; }
146
+ async set(account, secret) {
147
+ checkAccount(account);
148
+ checkSecret(secret);
149
+ if (this.unavailable !== undefined)
150
+ throw new Error(this.unavailable.en);
151
+ this.map.set(account, secret);
152
+ }
105
153
  async delete(account) { this.map.delete(account); }
106
154
  }
107
155
  /** The keychain of the platform this runs on. */
@@ -9,6 +9,7 @@ import { MishuError, bi } from "./i18n.js";
9
9
  import { resolveAccount, secretAccount } from "./mail/account.js";
10
10
  import { MAIL_SOURCE } from "./mail/sync.js";
11
11
  import { CATEGORIES } from "./recur.js";
12
+ import { secretFromEnv } from "./secrets.js";
12
13
  const SETTINGS_KEY = 'settings';
13
14
  const ACCOUNTS_KEY = 'mail.accounts';
14
15
  const NEXT_ID_KEY = 'mail.nextId';
@@ -56,7 +57,9 @@ export function mailAccounts(store) {
56
57
  /**
57
58
  * Add (no id) or update an account. A new password, when given, goes to the
58
59
  * keychain before the account is saved, so a failed keychain write leaves
59
- * nothing half-saved.
60
+ * nothing half-saved. Without a usable keychain the password is not kept
61
+ * anywhere: the account is saved and reads it from the environment variable
62
+ * shown on the settings page (`secretEnvName`).
60
63
  */
61
64
  export async function saveMailAccount(store, secrets, input, password, now) {
62
65
  const accounts = mailAccounts(store);
@@ -65,9 +68,10 @@ export async function saveMailAccount(store, secrets, input, password, now) {
65
68
  throw new MishuError(bi(`没有 #${input.id} 这个邮箱账户`, `there is no mail account #${input.id}`));
66
69
  const id = existing?.id ?? (store.get(NEXT_ID_KEY) ?? 1);
67
70
  const account = resolveAccount({ ...existing, ...input }, id);
68
- if (password !== undefined && password !== '')
71
+ const keychain = (await secrets.status()).available;
72
+ if (keychain && password !== undefined && password !== '')
69
73
  await secrets.set(secretAccount(id), password);
70
- else if (existing === undefined && await secrets.get(secretAccount(id)) === undefined) {
74
+ else if (keychain && existing === undefined && await secrets.get(secretAccount(id)) === undefined && secretFromEnv(secretAccount(id)) === undefined) {
71
75
  throw new MishuError(bi('新邮箱要填密码(授权码)', 'a new account needs its password (app password)'));
72
76
  }
73
77
  store.set(ACCOUNTS_KEY, existing === undefined ? [...accounts, account] : accounts.map(a => (a.id === id ? account : a)));
package/lib/dsh/api.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * travel inward: they go straight to the keychain, and no endpoint ever
10
10
  * returns one.
11
11
  */
12
- import { addDays, addSeries, buildAsk, buildBoard, buildDetail, dayOf, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, resolveGithubToken, resolveSeries, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, } from "../core/index.js";
12
+ import { addDays, addSeries, bi, buildAsk, buildBoard, buildDetail, dayOf, deleteGithubToken, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, NEW_TOKEN_URL, resolveSeries, saveGithubToken, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, } from "../core/index.js";
13
13
  import { resolveAccount } from "../core/mail/account.js";
14
14
  export const API_BASE = '/api/dsh-mishu';
15
15
  class ApiError extends Error {
@@ -82,14 +82,8 @@ export function apiHandlers(runtime, clock = Date.now) {
82
82
  /** Everything the settings page shows. No secret is ever included. */
83
83
  const settingsView = async (lang) => {
84
84
  const store = need(lang);
85
- const token = await resolveGithubToken(runtime.config.github.tokenEnv, runtime.config.github.useGhCli);
86
- let available = false;
87
- try {
88
- available = await runtime.secrets.available();
89
- }
90
- catch {
91
- available = false;
92
- }
85
+ const token = await runtime.githubToken();
86
+ const secrets = await runtime.secrets.status().catch((error) => ({ available: false, why: bi(error.message, error.message) }));
93
87
  const accounts = await Promise.all(runtime.accounts().map(async (a) => ({
94
88
  ...a,
95
89
  hasPassword: (await runtime.mailPassword(a.id)) !== undefined,
@@ -102,9 +96,10 @@ export function apiHandlers(runtime, clock = Date.now) {
102
96
  github: {
103
97
  tokenEnv: runtime.config.github.tokenEnv,
104
98
  token: 'error' in token ? { found: false, text: token.error[lang] } : { found: true, from: token.from },
99
+ newTokenUrl: NEW_TOKEN_URL,
105
100
  lastSync: store.get('github.lastSync') ?? null,
106
101
  },
107
- secrets: { kind: runtime.secrets.kind, available },
102
+ secrets: { kind: runtime.secrets.kind, available: secrets.available, ...secrets.available ? {} : { why: secrets.why[lang] } },
108
103
  accounts,
109
104
  presets: MAIL_PRESETS.map(p => ({ id: p.id, label: p.label[lang], host: p.host, port: p.port, secure: p.secure, supported: p.supported, howTo: p.howTo[lang] })),
110
105
  };
@@ -187,6 +182,7 @@ export function apiHandlers(runtime, clock = Date.now) {
187
182
  ...ask.id === undefined ? {} : { id: Number(ask.id) },
188
183
  ...ask.accountId === undefined ? {} : { accountId: Number(ask.accountId) },
189
184
  ...ask.preset === undefined ? {} : { preset: String(ask.preset) },
185
+ ...typeof ask.error === 'string' && ask.error !== '' ? { error: ask.error.slice(0, 1000) } : {},
190
186
  };
191
187
  const prompt = buildAsk(need(lang), request, { now: clock(), lang, markRead: runtime.settings().markRead, accounts: runtime.accounts() });
192
188
  return { prompt };
@@ -200,12 +196,22 @@ export function apiHandlers(runtime, clock = Date.now) {
200
196
  },
201
197
  '/github/test': async (body) => {
202
198
  const lang = langOf(body);
203
- const token = await resolveGithubToken(runtime.config.github.tokenEnv, runtime.config.github.useGhCli);
199
+ const token = await runtime.githubToken();
204
200
  if ('error' in token)
205
201
  throw new ApiError(token.error[lang]);
206
202
  const user = await new GitHubClient({ token: token.token }).user();
207
203
  return { login: user.login, from: token.from };
208
204
  },
205
+ /** A pasted token: checked with GitHub, then kept in the keychain only. */
206
+ '/github/token/save': async (body) => {
207
+ const lang = langOf(body);
208
+ const { login } = await saveGithubToken(runtime.secrets, typeof body.token === 'string' ? body.token : '');
209
+ return { login, view: await settingsView(lang) };
210
+ },
211
+ '/github/token/delete': async (body) => {
212
+ await deleteGithubToken(runtime.secrets);
213
+ return { view: await settingsView(langOf(body)) };
214
+ },
209
215
  '/mail/save': async (body) => {
210
216
  const lang = langOf(body);
211
217
  const account = await saveMailAccount(need(lang), runtime.secrets, accountInput(body.account), password(body), clock());
@@ -60,11 +60,15 @@ export class Runtime {
60
60
  }
61
61
  return secretFromEnv(secretAccount(id));
62
62
  }
63
+ /** The GitHub token: environment, then the keychain, then the GitHub CLI. */
64
+ githubToken() {
65
+ return resolveGithubToken({ envName: this.config.github.tokenEnv, useGhCli: this.config.github.useGhCli, secrets: this.secrets });
66
+ }
63
67
  async syncGithubNow(store) {
64
68
  const settings = this.settings();
65
69
  if (!settings.githubEnabled)
66
70
  return;
67
- const token = await resolveGithubToken(this.config.github.tokenEnv, this.config.github.useGhCli);
71
+ const token = await this.githubToken();
68
72
  if ('error' in token) {
69
73
  const report = { at: Date.now(), notifications: 0, opened: 0, closed: 0, wouldMarkRead: 0, markedRead: 0, checkFailures: 0, apiCalls: 0, error: token.error };
70
74
  store.set('github.lastSync', report);
@@ -28,6 +28,7 @@ export type AskRequest = {
28
28
  } | {
29
29
  kind: 'mail-setup';
30
30
  preset: string;
31
+ error?: string;
31
32
  };
32
33
  export interface AskOptions {
33
34
  now: number;
@@ -0,0 +1,28 @@
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
+ export interface RunResult {
11
+ code: number;
12
+ stdout: string;
13
+ stderr: string;
14
+ /** Node's own message when the program could not start or was killed. */
15
+ failure?: string;
16
+ }
17
+ interface Where {
18
+ platform?: NodeJS.Platform;
19
+ env?: NodeJS.ProcessEnv;
20
+ home?: string;
21
+ }
22
+ /** The usual install places of command-line programs, after PATH. */
23
+ export declare function usualDirs({ platform, env, home }?: Where): string[];
24
+ /** The full path of a program, or `undefined` when it is not installed anywhere we know of. */
25
+ export declare function findProgram(name: string, where?: Where): string | undefined;
26
+ /** Run a program; `input` goes to its stdin (secrets never go on the command line). Never rejects. */
27
+ export declare function runProgram(file: string, args: string[], input?: string, timeout?: number): Promise<RunResult>;
28
+ export {};
@@ -131,6 +131,8 @@ export declare class GitHubClient {
131
131
  user(): Promise<{
132
132
  login: string;
133
133
  }>;
134
+ /** Whether this token may read notifications (fine-grained tokens get 403). */
135
+ canReadNotifications(): Promise<boolean>;
134
136
  repo(fullName: string): Promise<RepoInfo>;
135
137
  pull(apiUrl: string): Promise<PullInfo>;
136
138
  issue(apiUrl: string): Promise<IssueInfo>;
@@ -0,0 +1,39 @@
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 { type Bi } from '../i18n.ts';
11
+ import type { SecretStore } from '../secrets.ts';
12
+ import { type FetchLike } from './client.ts';
13
+ /** The keychain entry for a token pasted on the settings page. */
14
+ export declare const GITHUB_SECRET = "github";
15
+ /** Opens GitHub's "new classic token" page with the two scopes ticked. */
16
+ export declare const NEW_TOKEN_URL = "https://github.com/settings/tokens/new?scopes=notifications,repo&description=DSH%20mishu";
17
+ export type TokenSource = 'env' | 'keychain' | 'gh';
18
+ export type TokenResult = {
19
+ token: string;
20
+ from: TokenSource;
21
+ } | {
22
+ error: Bi;
23
+ };
24
+ /** `ghPath` is for tests: a fixed path, or `null` for "not installed". */
25
+ export declare function resolveGithubToken(options: {
26
+ envName: string;
27
+ useGhCli: boolean;
28
+ secrets?: SecretStore;
29
+ ghPath?: string | null;
30
+ }): Promise<TokenResult>;
31
+ /**
32
+ * Check a pasted token with GitHub, then keep it in the keychain. It must
33
+ * read notifications, which only classic tokens with the `notifications`
34
+ * scope can (fine-grained tokens cannot reach that API).
35
+ */
36
+ export declare function saveGithubToken(secrets: SecretStore, raw: string, fetch?: FetchLike): Promise<{
37
+ login: string;
38
+ }>;
39
+ export declare function deleteGithubToken(secrets: SecretStore): Promise<void>;
@@ -2,7 +2,6 @@
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 { type Bi } from './i18n.ts';
6
5
  export { buildAsk, detailText, type AskRequest } from './ask.ts';
7
6
  export { buildBoard, buildDetail, type BoardCard, type BoardData, type CardAction, type DoneGroup, type MatterDetail } from './board.ts';
8
7
  export type { CardExplain } from './explain.ts';
@@ -11,6 +10,7 @@ export { GitHubClient, GitHubError, type FetchLike, type Notification } from './
11
10
  export { gatherFacts, Lookups, parseCheckSuiteTitle, type Facts } from './github/facts.ts';
12
11
  export { CLOSING_REASONS, judge, type Explain, type Verdict } from './github/rules.ts';
13
12
  export { syncGithub, type GithubSyncReport, type MarkReadMode } from './github/sync.ts';
13
+ export { deleteGithubToken, GITHUB_SECRET, NEW_TOKEN_URL, resolveGithubToken, saveGithubToken, type TokenResult, type TokenSource } from './github/token.ts';
14
14
  export { bi, decode, detectLang, errorText, LANGS, MishuError, pick, type Bi, type Lang } from './i18n.ts';
15
15
  export { addSeries, describeActiveSeries, describeSeries, resolveSeries, stopSeries, tickManual, upcoming, type ResolvedSeries, type SeriesInput, } from './manual.ts';
16
16
  export { DEFAULT_DEMOTION, isActive, isDemoted, setStatusByUser, snoozeByUser, type DemotionPolicy } from './matters.ts';
@@ -21,7 +21,8 @@ export { MAIL_PRESETS, presetById, secretAccount, type MailAccount, type MailFet
21
21
  export { ImapFetcher } from './mail/imap.ts';
22
22
  export { judgeMail } from './mail/rules.ts';
23
23
  export { describeMailError, MAIL_SOURCE, mailReportKey, syncMail, type MailSyncReport } from './mail/sync.ts';
24
- export { MemorySecrets, secretEnvName, secretFromEnv, systemSecrets, type SecretStore } from './secrets.ts';
24
+ export { findProgram } from './exec.ts';
25
+ export { MemorySecrets, secretEnvName, secretFromEnv, systemSecrets, type SecretStatus, type SecretStore } from './secrets.ts';
25
26
  export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings, type Defaults, type UserSettings } from './settings.ts';
26
27
  export declare const DEFAULT_HOME = "~/.mishu";
27
28
  /** `~` and `~/…` expand to the home directory on every platform. */
@@ -30,13 +31,3 @@ export declare function mishuPaths(home: string): {
30
31
  root: string;
31
32
  db: string;
32
33
  };
33
- /**
34
- * A GitHub token without storing one: the named environment variable, else
35
- * the GitHub CLI (`gh auth token`, which reads the system credential store).
36
- */
37
- export declare function resolveGithubToken(envName: string, useGhCli: boolean): Promise<{
38
- token: string;
39
- from: 'env' | 'gh';
40
- } | {
41
- error: Bi;
42
- }>;
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { type Bi } from '../i18n.ts';
10
10
  import type { MishuStore } from '../store.ts';
11
- import type { MailAccount, MailFetcher } from './account.ts';
11
+ import { type MailAccount, type MailFetcher } from './account.ts';
12
12
  export declare const MAIL_SOURCE = "mail";
13
13
  export interface MailSyncReport {
14
14
  at: number;
@@ -19,7 +19,7 @@ export interface MailSyncReport {
19
19
  error?: Bi;
20
20
  }
21
21
  /** A connection or login failure, said so a person knows what to fix. */
22
- export declare function describeMailError(error: unknown, account: Pick<MailAccount, 'host' | 'port'>): Bi;
22
+ export declare function describeMailError(error: unknown, account: Pick<MailAccount, 'host' | 'port' | 'preset'>): Bi;
23
23
  export declare function mailReportKey(accountId: number): string;
24
24
  export declare function syncMail(options: {
25
25
  store: MishuStore;
@@ -10,20 +10,30 @@
10
10
  * stored under the service name "dsh-mishu". When no keychain is available,
11
11
  * `secretFromEnv` is the documented fallback.
12
12
  */
13
+ import { type Bi } from './i18n.ts';
13
14
  export declare const SERVICE = "dsh-mishu";
15
+ /** Whether secrets can be kept here, and if not, why and what to do. */
16
+ export type SecretStatus = {
17
+ available: true;
18
+ } | {
19
+ available: false;
20
+ why: Bi;
21
+ };
14
22
  export interface SecretStore {
15
23
  /** Where secrets go, for the settings page ("macOS 钥匙串"). */
16
24
  readonly kind: 'macos' | 'windows' | 'linux' | 'memory';
17
- available(): Promise<boolean>;
25
+ status(): Promise<SecretStatus>;
18
26
  get(account: string): Promise<string | undefined>;
19
27
  set(account: string, secret: string): Promise<void>;
20
28
  delete(account: string): Promise<void>;
21
29
  }
22
- /** For tests: secrets in memory only. */
30
+ /** For tests: secrets in memory only; `unavailable` plays a machine without a keychain. */
23
31
  export declare class MemorySecrets implements SecretStore {
24
32
  readonly kind: "memory";
25
33
  private readonly map;
26
- available(): Promise<boolean>;
34
+ private readonly unavailable;
35
+ constructor(unavailable?: Bi);
36
+ status(): Promise<SecretStatus>;
27
37
  get(account: string): Promise<string | undefined>;
28
38
  set(account: string, secret: string): Promise<void>;
29
39
  delete(account: string): Promise<void>;
@@ -9,7 +9,7 @@ import type { MarkReadMode } from './github/sync.ts';
9
9
  import { type Lang } from './i18n.ts';
10
10
  import { type MailAccount } from './mail/account.ts';
11
11
  import { type Category } from './recur.ts';
12
- import type { SecretStore } from './secrets.ts';
12
+ import { type SecretStore } from './secrets.ts';
13
13
  import type { MishuStore } from './store.ts';
14
14
  export interface Defaults {
15
15
  language: 'auto' | Lang;
@@ -30,7 +30,9 @@ export declare function mailAccounts(store: MishuStore): MailAccount[];
30
30
  /**
31
31
  * Add (no id) or update an account. A new password, when given, goes to the
32
32
  * keychain before the account is saved, so a failed keychain write leaves
33
- * nothing half-saved.
33
+ * nothing half-saved. Without a usable keychain the password is not kept
34
+ * anywhere: the account is saved and reads it from the environment variable
35
+ * shown on the settings page (`secretEnvName`).
34
36
  */
35
37
  export declare function saveMailAccount(store: MishuStore, secrets: SecretStore, input: Partial<MailAccount>, password: string | undefined, now: number): Promise<MailAccount>;
36
38
  /** Remove an account, its keychain entry, and close what is still open from it. */
@@ -4,7 +4,7 @@
4
4
  * what you set on the settings page on top), the keychain, and one sync of
5
5
  * every source at a time.
6
6
  */
7
- import { type Defaults, type Lang, type MailAccount, type MailFetcher, type MailSyncReport, type MishuStore, type SecretStore } from '../core/index.ts';
7
+ import { type Defaults, type Lang, type MailAccount, type MailFetcher, type MailSyncReport, type MishuStore, type SecretStore, type TokenResult } from '../core/index.ts';
8
8
  import type { MishuConfig } from './config.ts';
9
9
  interface Logger {
10
10
  info(message: string): void;
@@ -27,6 +27,8 @@ export declare class Runtime {
27
27
  brief(lang?: Lang): string;
28
28
  /** The password for an account: keychain first, then the documented environment variable. */
29
29
  mailPassword(id: number): Promise<string | undefined>;
30
+ /** The GitHub token: environment, then the keychain, then the GitHub CLI. */
31
+ githubToken(): Promise<TokenResult>;
30
32
  private syncGithubNow;
31
33
  /** One account; the result is also kept as its last report. */
32
34
  syncMailAccount(account: MailAccount): Promise<MailSyncReport | undefined>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mishu",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "DSH secretary (小秘书) for DeepSeek Harness (dsh): watches GitHub notifications and the dated things you tell it (bills, renewals, documents, birthdays), closes what is already resolved, and keeps a short list of what actually needs you. Bilingual (中文 / English), macOS / Windows / Linux. · DSH 小秘书:提醒、通知、待办、消息、收件箱、秘书。GitHub 通知按当前状态分诊,交代一句就记下到期和周期事项。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",