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 +8 -6
- package/lib/client.js +92 -10
- package/lib/core/ask.js +2 -2
- package/lib/core/exec.js +68 -0
- package/lib/core/github/client.js +10 -0
- package/lib/core/github/token.js +79 -0
- package/lib/core/index.js +2 -27
- package/lib/core/mail/account.js +5 -3
- package/lib/core/mail/sync.js +6 -3
- package/lib/core/secrets.js +71 -23
- package/lib/core/settings.js +7 -3
- package/lib/dsh/api.js +17 -11
- package/lib/dsh/runtime.js +5 -1
- package/lib/types/core/ask.d.ts +1 -0
- package/lib/types/core/exec.d.ts +28 -0
- package/lib/types/core/github/client.d.ts +2 -0
- package/lib/types/core/github/token.d.ts +39 -0
- package/lib/types/core/index.d.ts +3 -12
- package/lib/types/core/mail/sync.d.ts +2 -2
- package/lib/types/core/secrets.d.ts +13 -3
- package/lib/types/core/settings.d.ts +4 -2
- package/lib/types/dsh/runtime.d.ts +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
|
|
55
55
|
同一封邮件的往来回复算一件事。你在邮件客户端里看过之后,对应的事项会自动关闭。
|
|
56
56
|
|
|
57
|
-
**密码放在系统钥匙串**:macOS 钥匙串、Windows 凭据管理器、Linux 密钥环(Secret Service
|
|
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
|
-
|
|
100
|
+
按顺序取,token 不写进任何文件:
|
|
101
101
|
|
|
102
102
|
1. 环境变量(默认 `GITHUB_TOKEN`)
|
|
103
|
-
2.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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\
|
|
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:
|
|
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) => `
|
|
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.
|
|
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
|
}
|
package/lib/core/exec.js
ADDED
|
@@ -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
|
-
}
|
package/lib/core/mail/account.js
CHANGED
|
@@ -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 邮箱网页版:设置 →
|
|
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
|
-
|
|
57
|
-
|
|
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'));
|
package/lib/core/mail/sync.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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}`;
|
package/lib/core/secrets.js
CHANGED
|
@@ -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 {
|
|
13
|
+
import { findProgram, runProgram } from "./exec.js";
|
|
14
|
+
import { bi } from "./i18n.js";
|
|
14
15
|
export const SERVICE = 'dsh-mishu';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
async
|
|
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. */
|
package/lib/core/settings.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
86
|
-
|
|
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
|
|
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());
|
package/lib/dsh/runtime.js
CHANGED
|
@@ -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
|
|
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);
|
package/lib/types/core/ask.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|