dsh-mishu 0.5.0 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/lib/client.js +137 -12
- 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 +3 -27
- package/lib/core/mail/account.js +5 -3
- package/lib/core/mail/sync.js +6 -3
- package/lib/core/restart.js +149 -0
- package/lib/core/secrets.js +71 -23
- package/lib/core/settings.js +7 -3
- package/lib/dsh/api.js +29 -11
- package/lib/dsh/runtime.js +8 -2
- 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 +4 -12
- package/lib/types/core/mail/sync.d.ts +2 -2
- package/lib/types/core/restart.d.ts +73 -0
- 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 +12 -1
- package/package.json +1 -1
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}`;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restarting DSH after plugins were reinstalled or upgraded.
|
|
3
|
+
*
|
|
4
|
+
* In the desktop app, DSH runs as a child of the app's main process
|
|
5
|
+
* (Electron as Node, `ELECTRON_RUN_AS_NODE=1`). The `dsh` command runs on
|
|
6
|
+
* the same binary the same way, so the test is the parent: only when the
|
|
7
|
+
* parent process IS this app binary is there an app to restart (from a
|
|
8
|
+
* terminal the parent is a shell, and `__CFBundleIdentifier` names the
|
|
9
|
+
* terminal app — never trusted). A restart asks the app to
|
|
10
|
+
* quit the normal way — so it still asks first when a task is running — and
|
|
11
|
+
* opens it again once it is gone. That is done by a small detached helper,
|
|
12
|
+
* because this process ends with the app.
|
|
13
|
+
*
|
|
14
|
+
* macOS AppleScript `quit`, then `open -b <bundle id>` (tested)
|
|
15
|
+
* Windows `taskkill /PID` without /F (a close request), then the app again
|
|
16
|
+
* Linux SIGTERM to the app, then the app again
|
|
17
|
+
*
|
|
18
|
+
* DSH started with the `dsh` command is not restarted from here: its web
|
|
19
|
+
* address (with a new sign-in token) is printed in that terminal, which a
|
|
20
|
+
* detached replacement could not reach. The page says what to do instead.
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
23
|
+
import { readlinkSync } from 'node:fs';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { findProgram } from "./exec.js";
|
|
27
|
+
import { bi } from "./i18n.js";
|
|
28
|
+
/** `<x>.app` holding a macOS executable, if any. */
|
|
29
|
+
function appBundle(execPath) {
|
|
30
|
+
const at = execPath.indexOf('.app/');
|
|
31
|
+
return at < 0 ? undefined : execPath.slice(0, at + 4);
|
|
32
|
+
}
|
|
33
|
+
function parentExecutable(ppid, platform) {
|
|
34
|
+
try {
|
|
35
|
+
if (platform === 'linux')
|
|
36
|
+
return readlinkSync(`/proc/${ppid}/exe`);
|
|
37
|
+
if (platform === 'win32') {
|
|
38
|
+
const ps = findProgram('powershell');
|
|
39
|
+
return ps === undefined ? undefined : execFileSync(ps, ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${ppid}).Path`], { timeout: 10_000, windowsHide: true }).toString().trim();
|
|
40
|
+
}
|
|
41
|
+
return execFileSync('/bin/ps', ['-o', 'comm=', '-p', String(ppid)], { timeout: 5000 }).toString().trim();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function bundleIdOf(app) {
|
|
48
|
+
try {
|
|
49
|
+
return execFileSync('/usr/bin/defaults', ['read', `${app}/Contents/Info`, 'CFBundleIdentifier'], { timeout: 5000 }).toString().trim();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function here() {
|
|
56
|
+
const facts = { env: process.env, platform: process.platform, execPath: process.execPath, ppid: process.ppid, electron: process.versions.electron, parentExe: undefined, bundleId: undefined };
|
|
57
|
+
if (facts.electron === undefined || facts.env.ELECTRON_RUN_AS_NODE !== '1' || facts.ppid <= 1)
|
|
58
|
+
return facts;
|
|
59
|
+
facts.parentExe = parentExecutable(facts.ppid, facts.platform);
|
|
60
|
+
const app = facts.platform === 'darwin' ? appBundle(facts.execPath) : undefined;
|
|
61
|
+
if (app !== undefined)
|
|
62
|
+
facts.bundleId = bundleIdOf(app);
|
|
63
|
+
return facts;
|
|
64
|
+
}
|
|
65
|
+
function samePath(a, b, platform) {
|
|
66
|
+
return a !== undefined && (platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
67
|
+
}
|
|
68
|
+
const FROM_TERMINAL = bi('这个 DSH 是用 dsh 命令启动的,小秘书没法替它重启:回到运行 dsh 的那个终端按 Ctrl+C,再运行同一条命令。', 'This DSH was started with the dsh command, so the secretary cannot restart it: press Ctrl+C in the terminal running dsh, then run the same command again.');
|
|
69
|
+
/** How DSH can be restarted from here, or why it cannot. */
|
|
70
|
+
export function restartPlan(facts = here()) {
|
|
71
|
+
const desktop = facts.electron !== undefined && facts.env.ELECTRON_RUN_AS_NODE === '1' && facts.ppid > 1 && samePath(facts.parentExe, facts.execPath, facts.platform);
|
|
72
|
+
if (!desktop)
|
|
73
|
+
return { kind: 'none', why: FROM_TERMINAL };
|
|
74
|
+
if (facts.platform === 'darwin') {
|
|
75
|
+
const bundleId = facts.bundleId ?? '';
|
|
76
|
+
if (/^[\w.-]+$/.test(bundleId))
|
|
77
|
+
return { kind: 'macos-app', bundleId, appPid: facts.ppid };
|
|
78
|
+
return { kind: 'none', why: bi('没找到桌面版的应用标识,没法替它重启。请退出 DSH 再打开。', 'Could not tell which app this is, so it cannot be restarted from here. Quit DSH and open it again.') };
|
|
79
|
+
}
|
|
80
|
+
if (facts.platform === 'win32' || facts.platform === 'linux')
|
|
81
|
+
return { kind: 'desktop-app', platform: facts.platform, exe: facts.execPath, appPid: facts.ppid };
|
|
82
|
+
return { kind: 'none', why: bi('这个系统上没法替桌面版重启。请退出 DSH 再打开。', 'The desktop app cannot be restarted from here on this system. Quit DSH and open it again.') };
|
|
83
|
+
}
|
|
84
|
+
const MAC_TOOLS = { osascript: '/usr/bin/osascript', open: '/usr/bin/open' };
|
|
85
|
+
/**
|
|
86
|
+
* Source of the detached helper: ask the app to quit, wait until it is gone
|
|
87
|
+
* (giving up quietly when you cancel the quit), open it again, and check it
|
|
88
|
+
* came back. Every step is noted in `log`, the only account of a restart
|
|
89
|
+
* once this process is gone.
|
|
90
|
+
*
|
|
91
|
+
* The helper itself runs as Node (`ELECTRON_RUN_AS_NODE=1`), and whatever it
|
|
92
|
+
* launches inherits its environment — `open` included ("Opened applications
|
|
93
|
+
* inherit environment variables", open(1)). So the app is always started
|
|
94
|
+
* without that variable; with it, the app starts as bare Node and exits.
|
|
95
|
+
*/
|
|
96
|
+
export function restartHelperSource(plan, log, tools = MAC_TOOLS) {
|
|
97
|
+
const mac = plan.kind === 'macos-app';
|
|
98
|
+
const app = mac ? `application id ${JSON.stringify(plan.bundleId)}` : '';
|
|
99
|
+
const quit = mac
|
|
100
|
+
? `spawnSync(${JSON.stringify(tools.osascript)}, ['-e', ${JSON.stringify(`tell ${app} to quit`)}], { timeout: 180000 })`
|
|
101
|
+
: plan.platform === 'win32'
|
|
102
|
+
? `spawnSync('taskkill', ['/PID', String(pid)], { windowsHide: true })`
|
|
103
|
+
: `process.kill(pid, 'SIGTERM')`;
|
|
104
|
+
// Answers whether the app runs again; only macOS can ask, elsewhere the launch is trusted.
|
|
105
|
+
const running = mac
|
|
106
|
+
? `String(spawnSync(${JSON.stringify(tools.osascript)}, ['-e', ${JSON.stringify(`${app} is running`)}], { timeout: 10000 }).stdout).trim() === 'true'`
|
|
107
|
+
: 'true';
|
|
108
|
+
const open = mac
|
|
109
|
+
? `const r = spawnSync(${JSON.stringify(tools.open)}, ['-b', ${JSON.stringify(plan.bundleId)}], { env: clean, timeout: 60000 }); note('open exited ' + r.status + (String(r.stderr || '').trim() ? ': ' + String(r.stderr).trim() : ''))`
|
|
110
|
+
: `spawn(${JSON.stringify(plan.exe)}, [], { detached: true, stdio: 'ignore', env: clean }).unref()`;
|
|
111
|
+
return [
|
|
112
|
+
"const { spawn, spawnSync } = require('node:child_process')",
|
|
113
|
+
"const fs = require('node:fs')",
|
|
114
|
+
`const pid = ${plan.appPid}`,
|
|
115
|
+
`const log = ${JSON.stringify(log)}`,
|
|
116
|
+
"const clean = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'ELECTRON_RUN_AS_NODE'))",
|
|
117
|
+
"const note = (line) => { try { fs.appendFileSync(log, new Date().toISOString() + ' ' + line + '\\n') } catch {} }",
|
|
118
|
+
'const sleep = (ms) => new Promise(r => setTimeout(r, ms))',
|
|
119
|
+
"const alive = () => { try { process.kill(pid, 0); return true } catch (e) { return e.code === 'EPERM' } }",
|
|
120
|
+
`const running = () => { try { return ${running} } catch { return false } }`,
|
|
121
|
+
`const open = () => { try { ${open} } catch (e) { note('could not open the app: ' + (e && e.message)) } }`,
|
|
122
|
+
'const main = async () => {',
|
|
123
|
+
// Let the page receive its answer before the app starts closing.
|
|
124
|
+
' await sleep(400)',
|
|
125
|
+
" note('asking the app (pid ' + pid + ') to quit')",
|
|
126
|
+
` try { ${quit} } catch (e) { note('quit request failed: ' + (e && e.message)) }`,
|
|
127
|
+
' const until = Date.now() + 180000',
|
|
128
|
+
' while (alive() && Date.now() < until) await sleep(300)',
|
|
129
|
+
" if (alive()) { note('the app is still running (the quit was cancelled?); not reopening'); return }",
|
|
130
|
+
' await sleep(800)',
|
|
131
|
+
' for (let attempt = 1; attempt <= 2; attempt++) {',
|
|
132
|
+
" note('opening the app (attempt ' + attempt + ')')",
|
|
133
|
+
' open()',
|
|
134
|
+
' const by = Date.now() + 20000',
|
|
135
|
+
' while (!running() && Date.now() < by) await sleep(500)',
|
|
136
|
+
" if (running()) { note('the app is running again'); return }",
|
|
137
|
+
' }',
|
|
138
|
+
" note('the app did not come back; open it yourself')",
|
|
139
|
+
'}',
|
|
140
|
+
"main().catch(e => note('helper failed: ' + (e && e.stack || e)))",
|
|
141
|
+
].join('\n');
|
|
142
|
+
}
|
|
143
|
+
/** Start the helper and return where it logs. Runs with this process's Node (Electron as Node in the app). */
|
|
144
|
+
export function scheduleRestart(plan) {
|
|
145
|
+
const log = join(tmpdir(), `dsh-mishu-restart-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.log`);
|
|
146
|
+
const helper = spawn(process.execPath, ['-e', restartHelperSource(plan, log)], { detached: true, stdio: 'ignore', env: process.env, windowsHide: true });
|
|
147
|
+
helper.unref();
|
|
148
|
+
return { log };
|
|
149
|
+
}
|
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());
|
|
@@ -234,6 +240,18 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
234
240
|
}
|
|
235
241
|
return { view: await settingsView(lang) };
|
|
236
242
|
},
|
|
243
|
+
/** Whether the page's "restart DSH" button can work here, and if not, what to do instead. */
|
|
244
|
+
'/restart/info': async (body) => {
|
|
245
|
+
const plan = runtime.restarter.plan();
|
|
246
|
+
return plan.kind === 'none' ? { supported: false, why: plan.why[langOf(body)] } : { supported: true };
|
|
247
|
+
},
|
|
248
|
+
'/restart': async (body) => {
|
|
249
|
+
const plan = runtime.restarter.plan();
|
|
250
|
+
if (plan.kind === 'none')
|
|
251
|
+
throw new ApiError(plan.why[langOf(body)]);
|
|
252
|
+
const { log } = runtime.restarter.schedule(plan);
|
|
253
|
+
return { restarting: true, log };
|
|
254
|
+
},
|
|
237
255
|
'/mail/sync': async (body) => {
|
|
238
256
|
const lang = langOf(body);
|
|
239
257
|
const account = runtime.accounts().find(a => a.id === Number(body.id));
|
package/lib/dsh/runtime.js
CHANGED
|
@@ -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 { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, resolveGithubToken, secretAccount, secretFromEnv, syncGithub, syncMail, systemSecrets, tickManual, bi, } from "../core/index.js";
|
|
7
|
+
import { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, resolveGithubToken, secretAccount, secretFromEnv, syncGithub, syncMail, systemSecrets, tickManual, bi, restartPlan, scheduleRestart, } from "../core/index.js";
|
|
8
8
|
export class Runtime {
|
|
9
9
|
store;
|
|
10
10
|
config;
|
|
@@ -12,6 +12,8 @@ export class Runtime {
|
|
|
12
12
|
fetcher;
|
|
13
13
|
logger;
|
|
14
14
|
syncing;
|
|
15
|
+
/** How DSH restarts; tests swap it so they never restart anything. */
|
|
16
|
+
restarter = { plan: () => restartPlan(), schedule: scheduleRestart };
|
|
15
17
|
constructor(config, logger, secrets = systemSecrets(), fetcher = new ImapFetcher()) {
|
|
16
18
|
this.config = config;
|
|
17
19
|
this.logger = logger;
|
|
@@ -60,11 +62,15 @@ export class Runtime {
|
|
|
60
62
|
}
|
|
61
63
|
return secretFromEnv(secretAccount(id));
|
|
62
64
|
}
|
|
65
|
+
/** The GitHub token: environment, then the keychain, then the GitHub CLI. */
|
|
66
|
+
githubToken() {
|
|
67
|
+
return resolveGithubToken({ envName: this.config.github.tokenEnv, useGhCli: this.config.github.useGhCli, secrets: this.secrets });
|
|
68
|
+
}
|
|
63
69
|
async syncGithubNow(store) {
|
|
64
70
|
const settings = this.settings();
|
|
65
71
|
if (!settings.githubEnabled)
|
|
66
72
|
return;
|
|
67
|
-
const token = await
|
|
73
|
+
const token = await this.githubToken();
|
|
68
74
|
if ('error' in token) {
|
|
69
75
|
const report = { at: Date.now(), notifications: 0, opened: 0, closed: 0, wouldMarkRead: 0, markedRead: 0, checkFailures: 0, apiCalls: 0, error: token.error };
|
|
70
76
|
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,9 @@ 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 { restartPlan, scheduleRestart, type RestartPlan } from './restart.ts';
|
|
26
|
+
export { MemorySecrets, secretEnvName, secretFromEnv, systemSecrets, type SecretStatus, type SecretStore } from './secrets.ts';
|
|
25
27
|
export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings, type Defaults, type UserSettings } from './settings.ts';
|
|
26
28
|
export declare const DEFAULT_HOME = "~/.mishu";
|
|
27
29
|
/** `~` and `~/…` expand to the home directory on every platform. */
|
|
@@ -30,13 +32,3 @@ export declare function mishuPaths(home: string): {
|
|
|
30
32
|
root: string;
|
|
31
33
|
db: string;
|
|
32
34
|
};
|
|
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;
|