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.
@@ -8,15 +8,18 @@
8
8
  */
9
9
  import { applyVerdict, closeReadElsewhere } from "../fold.js";
10
10
  import { bi } from "../i18n.js";
11
+ import { presetById } from "./account.js";
11
12
  import { judgeMail } from "./rules.js";
12
13
  export const MAIL_SOURCE = 'mail';
13
14
  /** A connection or login failure, said so a person knows what to fix. */
14
15
  export function describeMailError(error, account) {
15
16
  const raw = error.responseText ?? error.message ?? String(error);
16
17
  const auth = /auth|login|password|credential|AUTHENTICATIONFAILED|LOGIN failed/i.test(raw);
17
- return auth
18
- ? bi(`登录失败:服务器不接受这个用户名或密码(${raw})。多数邮箱要填客户端授权码或应用专用密码,不是网页登录密码。`, `Login failed: the server rejected the user name or password (${raw}). Most providers need an app password, not your web password.`)
19
- : bi(`连接 ${account.host}:${account.port} 失败:${raw}`, `Could not reach ${account.host}:${account.port}: ${raw}`);
18
+ if (!auth)
19
+ return bi(`连接 ${account.host}:${account.port} 失败:${raw}`, `Could not reach ${account.host}:${account.port}: ${raw}`);
20
+ const preset = presetById(account.preset);
21
+ const howTo = preset.id === 'custom' ? undefined : preset.howTo;
22
+ return bi(`登录失败:服务器不接受这个用户名或密码(${raw})。多数邮箱要先开启 IMAP,再填客户端授权码或应用专用密码,不是网页登录密码。${howTo === undefined ? '' : `\n${preset.label.zh}:${howTo.zh}`}`, `Login failed: the server rejected the user name or password (${raw}). Most providers need IMAP turned on and an app password, not your web password.${howTo === undefined ? '' : `\n${preset.label.en}: ${howTo.en}`}`);
20
23
  }
21
24
  export function mailReportKey(accountId) {
22
25
  return `mail.lastSync.${accountId}`;
@@ -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
+ }
@@ -10,17 +10,26 @@
10
10
  * stored under the service name "dsh-mishu". When no keychain is available,
11
11
  * `secretFromEnv` is the documented fallback.
12
12
  */
13
- import { execFile } from 'node:child_process';
13
+ import { findProgram, runProgram } from "./exec.js";
14
+ import { bi } from "./i18n.js";
14
15
  export const SERVICE = 'dsh-mishu';
15
- function run(file, args, input) {
16
- return new Promise((resolve) => {
17
- const child = execFile(file, args, { timeout: 15_000, windowsHide: true, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
18
- const code = error === null ? 0 : typeof error.code === 'number' ? error.code : 1;
19
- resolve({ code, stdout: String(stdout), stderr: String(stderr) });
20
- });
21
- child.stdin?.end(input ?? '');
22
- });
16
+ /** A helper program, found by full path so a short PATH does not hide it. */
17
+ class Helper {
18
+ name;
19
+ path;
20
+ constructor(name) { this.name = name; }
21
+ find() {
22
+ this.path ??= findProgram(this.name);
23
+ return this.path;
24
+ }
25
+ async run(args, input) {
26
+ const path = this.find();
27
+ if (path === undefined)
28
+ return { code: 127, stdout: '', stderr: '', failure: `${this.name} not found` };
29
+ return runProgram(path, args, input);
30
+ }
23
31
  }
32
+ const detail = (r) => (r.stderr.trim() || r.failure || `exit ${r.code}`).slice(0, 300);
24
33
  /** Accounts are plain ids we choose ("mail:3"); refuse anything else so they are safe in every helper. */
25
34
  function checkAccount(account) {
26
35
  if (!/^[\w:.-]{1,80}$/.test(account))
@@ -32,32 +41,47 @@ function checkSecret(secret) {
32
41
  }
33
42
  class MacKeychain {
34
43
  kind = 'macos';
35
- async available() { return (await run('security', ['help'])).code === 0 || (await run('security', ['list-keychains'])).code === 0; }
44
+ security = new Helper('security');
45
+ async status() {
46
+ const r = await this.security.run(['list-keychains']);
47
+ if (r.code === 0)
48
+ return { available: true };
49
+ return { available: false, why: bi(`macOS 钥匙串用不了(${detail(r)})`, `the macOS Keychain is not usable (${detail(r)})`) };
50
+ }
36
51
  async get(account) {
37
52
  checkAccount(account);
38
- const r = await run('security', ['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
53
+ const r = await this.security.run(['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
39
54
  return r.code === 0 ? r.stdout.replace(/\n$/, '') : undefined;
40
55
  }
41
56
  async set(account, secret) {
42
57
  checkAccount(account);
43
58
  checkSecret(secret);
44
59
  // `-w` last: security prompts twice and reads both answers from stdin.
45
- const r = await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-l', `${SERVICE} ${account}`, '-w'], `${secret}\n${secret}\n`);
60
+ const r = await this.security.run(['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-l', `${SERVICE} ${account}`, '-w'], `${secret}\n${secret}\n`);
46
61
  if (r.code !== 0)
47
62
  throw new Error(`Keychain refused to save the password (${r.stderr.trim() || r.code})`);
48
63
  }
49
64
  async delete(account) {
50
65
  checkAccount(account);
51
- await run('security', ['delete-generic-password', '-s', SERVICE, '-a', account]);
66
+ await this.security.run(['delete-generic-password', '-s', SERVICE, '-a', account]);
52
67
  }
53
68
  }
54
69
  const PS_PRELUDE = '[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];$v=New-Object Windows.Security.Credentials.PasswordVault;';
55
70
  class WindowsVault {
56
71
  kind = 'windows';
72
+ powershell = new Helper('powershell');
57
73
  ps(script, input) {
58
- return run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_PRELUDE + script], input);
74
+ return this.powershell.run(['-NoProfile', '-NonInteractive', '-Command', PS_PRELUDE + script], input);
75
+ }
76
+ async status() {
77
+ if (this.powershell.find() === undefined) {
78
+ return { available: false, why: bi('没找到 Windows PowerShell(powershell.exe),小秘书靠它使用 Windows 凭据管理器', 'Windows PowerShell (powershell.exe) was not found; the secretary uses it to reach Windows Credential Manager') };
79
+ }
80
+ const r = await this.ps('exit 0');
81
+ if (r.code === 0)
82
+ return { available: true };
83
+ return { available: false, why: bi(`Windows 凭据管理器用不了(${detail(r)})`, `Windows Credential Manager is not usable (${detail(r)})`) };
59
84
  }
60
- async available() { return (await this.ps('exit 0')).code === 0; }
61
85
  async get(account) {
62
86
  checkAccount(account);
63
87
  const r = await this.ps(`try{$c=$v.Retrieve('${SERVICE}','${account}');$c.RetrievePassword();[Console]::Out.Write($c.Password)}catch{exit 3}`);
@@ -77,31 +101,55 @@ class WindowsVault {
77
101
  }
78
102
  class SecretTool {
79
103
  kind = 'linux';
80
- async available() { return (await run('secret-tool', ['--version'])).code === 0; }
104
+ tool = new Helper('secret-tool');
105
+ async status() {
106
+ if (this.tool.find() === undefined) {
107
+ return {
108
+ available: false,
109
+ why: bi('没找到 secret-tool。Debian/Ubuntu 装 libsecret-tools(sudo apt install libsecret-tools),Fedora 装 libsecret(sudo dnf install libsecret),Arch 装 libsecret', 'secret-tool was not found. Install libsecret-tools on Debian/Ubuntu (sudo apt install libsecret-tools), libsecret on Fedora (sudo dnf install libsecret) or Arch'),
110
+ };
111
+ }
112
+ // A lookup that finds nothing exits 1 quietly; a missing keyring service complains on stderr.
113
+ const r = await this.tool.run(['lookup', 'service', SERVICE, 'account', 'probe']);
114
+ if (r.code === 0 || (r.code === 1 && r.stderr.trim() === '' && r.failure === undefined))
115
+ return { available: true };
116
+ return {
117
+ available: false,
118
+ why: bi(`secret-tool 连不上钥匙串服务(${detail(r)})。要在桌面会话里运行着 GNOME Keyring 或 KWallet;没有桌面的服务器上一般没有`, `secret-tool cannot reach a keyring service (${detail(r)}). It needs GNOME Keyring or KWallet running in a desktop session; servers without a desktop usually have none`),
119
+ };
120
+ }
81
121
  async get(account) {
82
122
  checkAccount(account);
83
- const r = await run('secret-tool', ['lookup', 'service', SERVICE, 'account', account]);
123
+ const r = await this.tool.run(['lookup', 'service', SERVICE, 'account', account]);
84
124
  return r.code === 0 && r.stdout !== '' ? r.stdout.replace(/\n$/, '') : undefined;
85
125
  }
86
126
  async set(account, secret) {
87
127
  checkAccount(account);
88
128
  checkSecret(secret);
89
- const r = await run('secret-tool', ['store', '--label', `${SERVICE} ${account}`, 'service', SERVICE, 'account', account], secret);
129
+ const r = await this.tool.run(['store', '--label', `${SERVICE} ${account}`, 'service', SERVICE, 'account', account], secret);
90
130
  if (r.code !== 0)
91
131
  throw new Error(`Secret Service refused to save the password (${r.stderr.trim() || r.code})`);
92
132
  }
93
133
  async delete(account) {
94
134
  checkAccount(account);
95
- await run('secret-tool', ['clear', 'service', SERVICE, 'account', account]);
135
+ await this.tool.run(['clear', 'service', SERVICE, 'account', account]);
96
136
  }
97
137
  }
98
- /** For tests: secrets in memory only. */
138
+ /** For tests: secrets in memory only; `unavailable` plays a machine without a keychain. */
99
139
  export class MemorySecrets {
100
140
  kind = 'memory';
101
141
  map = new Map();
102
- async available() { return true; }
103
- async get(account) { return this.map.get(account); }
104
- async set(account, secret) { checkAccount(account); checkSecret(secret); this.map.set(account, secret); }
142
+ unavailable;
143
+ constructor(unavailable) { this.unavailable = unavailable; }
144
+ async status() { return this.unavailable === undefined ? { available: true } : { available: false, why: this.unavailable }; }
145
+ async get(account) { return this.unavailable === undefined ? this.map.get(account) : undefined; }
146
+ async set(account, secret) {
147
+ checkAccount(account);
148
+ checkSecret(secret);
149
+ if (this.unavailable !== undefined)
150
+ throw new Error(this.unavailable.en);
151
+ this.map.set(account, secret);
152
+ }
105
153
  async delete(account) { this.map.delete(account); }
106
154
  }
107
155
  /** The keychain of the platform this runs on. */
@@ -9,6 +9,7 @@ import { MishuError, bi } from "./i18n.js";
9
9
  import { resolveAccount, secretAccount } from "./mail/account.js";
10
10
  import { MAIL_SOURCE } from "./mail/sync.js";
11
11
  import { CATEGORIES } from "./recur.js";
12
+ import { secretFromEnv } from "./secrets.js";
12
13
  const SETTINGS_KEY = 'settings';
13
14
  const ACCOUNTS_KEY = 'mail.accounts';
14
15
  const NEXT_ID_KEY = 'mail.nextId';
@@ -56,7 +57,9 @@ export function mailAccounts(store) {
56
57
  /**
57
58
  * Add (no id) or update an account. A new password, when given, goes to the
58
59
  * keychain before the account is saved, so a failed keychain write leaves
59
- * nothing half-saved.
60
+ * nothing half-saved. Without a usable keychain the password is not kept
61
+ * anywhere: the account is saved and reads it from the environment variable
62
+ * shown on the settings page (`secretEnvName`).
60
63
  */
61
64
  export async function saveMailAccount(store, secrets, input, password, now) {
62
65
  const accounts = mailAccounts(store);
@@ -65,9 +68,10 @@ export async function saveMailAccount(store, secrets, input, password, now) {
65
68
  throw new MishuError(bi(`没有 #${input.id} 这个邮箱账户`, `there is no mail account #${input.id}`));
66
69
  const id = existing?.id ?? (store.get(NEXT_ID_KEY) ?? 1);
67
70
  const account = resolveAccount({ ...existing, ...input }, id);
68
- if (password !== undefined && password !== '')
71
+ const keychain = (await secrets.status()).available;
72
+ if (keychain && password !== undefined && password !== '')
69
73
  await secrets.set(secretAccount(id), password);
70
- else if (existing === undefined && await secrets.get(secretAccount(id)) === undefined) {
74
+ else if (keychain && existing === undefined && await secrets.get(secretAccount(id)) === undefined && secretFromEnv(secretAccount(id)) === undefined) {
71
75
  throw new MishuError(bi('新邮箱要填密码(授权码)', 'a new account needs its password (app password)'));
72
76
  }
73
77
  store.set(ACCOUNTS_KEY, existing === undefined ? [...accounts, account] : accounts.map(a => (a.id === id ? account : a)));
package/lib/dsh/api.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * travel inward: they go straight to the keychain, and no endpoint ever
10
10
  * returns one.
11
11
  */
12
- import { addDays, addSeries, buildAsk, buildBoard, buildDetail, dayOf, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, resolveGithubToken, resolveSeries, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, } from "../core/index.js";
12
+ import { addDays, addSeries, bi, buildAsk, buildBoard, buildDetail, dayOf, deleteGithubToken, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, NEW_TOKEN_URL, resolveSeries, saveGithubToken, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, } from "../core/index.js";
13
13
  import { resolveAccount } from "../core/mail/account.js";
14
14
  export const API_BASE = '/api/dsh-mishu';
15
15
  class ApiError extends Error {
@@ -82,14 +82,8 @@ export function apiHandlers(runtime, clock = Date.now) {
82
82
  /** Everything the settings page shows. No secret is ever included. */
83
83
  const settingsView = async (lang) => {
84
84
  const store = need(lang);
85
- const token = await resolveGithubToken(runtime.config.github.tokenEnv, runtime.config.github.useGhCli);
86
- let available = false;
87
- try {
88
- available = await runtime.secrets.available();
89
- }
90
- catch {
91
- available = false;
92
- }
85
+ const token = await runtime.githubToken();
86
+ const secrets = await runtime.secrets.status().catch((error) => ({ available: false, why: bi(error.message, error.message) }));
93
87
  const accounts = await Promise.all(runtime.accounts().map(async (a) => ({
94
88
  ...a,
95
89
  hasPassword: (await runtime.mailPassword(a.id)) !== undefined,
@@ -102,9 +96,10 @@ export function apiHandlers(runtime, clock = Date.now) {
102
96
  github: {
103
97
  tokenEnv: runtime.config.github.tokenEnv,
104
98
  token: 'error' in token ? { found: false, text: token.error[lang] } : { found: true, from: token.from },
99
+ newTokenUrl: NEW_TOKEN_URL,
105
100
  lastSync: store.get('github.lastSync') ?? null,
106
101
  },
107
- secrets: { kind: runtime.secrets.kind, available },
102
+ secrets: { kind: runtime.secrets.kind, available: secrets.available, ...secrets.available ? {} : { why: secrets.why[lang] } },
108
103
  accounts,
109
104
  presets: MAIL_PRESETS.map(p => ({ id: p.id, label: p.label[lang], host: p.host, port: p.port, secure: p.secure, supported: p.supported, howTo: p.howTo[lang] })),
110
105
  };
@@ -187,6 +182,7 @@ export function apiHandlers(runtime, clock = Date.now) {
187
182
  ...ask.id === undefined ? {} : { id: Number(ask.id) },
188
183
  ...ask.accountId === undefined ? {} : { accountId: Number(ask.accountId) },
189
184
  ...ask.preset === undefined ? {} : { preset: String(ask.preset) },
185
+ ...typeof ask.error === 'string' && ask.error !== '' ? { error: ask.error.slice(0, 1000) } : {},
190
186
  };
191
187
  const prompt = buildAsk(need(lang), request, { now: clock(), lang, markRead: runtime.settings().markRead, accounts: runtime.accounts() });
192
188
  return { prompt };
@@ -200,12 +196,22 @@ export function apiHandlers(runtime, clock = Date.now) {
200
196
  },
201
197
  '/github/test': async (body) => {
202
198
  const lang = langOf(body);
203
- const token = await resolveGithubToken(runtime.config.github.tokenEnv, runtime.config.github.useGhCli);
199
+ const token = await runtime.githubToken();
204
200
  if ('error' in token)
205
201
  throw new ApiError(token.error[lang]);
206
202
  const user = await new GitHubClient({ token: token.token }).user();
207
203
  return { login: user.login, from: token.from };
208
204
  },
205
+ /** A pasted token: checked with GitHub, then kept in the keychain only. */
206
+ '/github/token/save': async (body) => {
207
+ const lang = langOf(body);
208
+ const { login } = await saveGithubToken(runtime.secrets, typeof body.token === 'string' ? body.token : '');
209
+ return { login, view: await settingsView(lang) };
210
+ },
211
+ '/github/token/delete': async (body) => {
212
+ await deleteGithubToken(runtime.secrets);
213
+ return { view: await settingsView(langOf(body)) };
214
+ },
209
215
  '/mail/save': async (body) => {
210
216
  const lang = langOf(body);
211
217
  const account = await saveMailAccount(need(lang), runtime.secrets, accountInput(body.account), password(body), clock());
@@ -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));
@@ -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 resolveGithubToken(this.config.github.tokenEnv, this.config.github.useGhCli);
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);
@@ -28,6 +28,7 @@ export type AskRequest = {
28
28
  } | {
29
29
  kind: 'mail-setup';
30
30
  preset: string;
31
+ error?: string;
31
32
  };
32
33
  export interface AskOptions {
33
34
  now: number;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Running the few helper programs the secretary uses (`security`,
3
+ * `secret-tool`, PowerShell, `gh`).
4
+ *
5
+ * An app started from the Dock, the Start menu or a service manager often
6
+ * gets a short PATH that misses where package managers put programs, so a
7
+ * program installed with Homebrew, winget or `~/.local/bin` looks missing.
8
+ * `findProgram` looks on PATH first, then in those usual places.
9
+ */
10
+ export interface RunResult {
11
+ code: number;
12
+ stdout: string;
13
+ stderr: string;
14
+ /** Node's own message when the program could not start or was killed. */
15
+ failure?: string;
16
+ }
17
+ interface Where {
18
+ platform?: NodeJS.Platform;
19
+ env?: NodeJS.ProcessEnv;
20
+ home?: string;
21
+ }
22
+ /** The usual install places of command-line programs, after PATH. */
23
+ export declare function usualDirs({ platform, env, home }?: Where): string[];
24
+ /** The full path of a program, or `undefined` when it is not installed anywhere we know of. */
25
+ export declare function findProgram(name: string, where?: Where): string | undefined;
26
+ /** Run a program; `input` goes to its stdin (secrets never go on the command line). Never rejects. */
27
+ export declare function runProgram(file: string, args: string[], input?: string, timeout?: number): Promise<RunResult>;
28
+ export {};
@@ -131,6 +131,8 @@ export declare class GitHubClient {
131
131
  user(): Promise<{
132
132
  login: string;
133
133
  }>;
134
+ /** Whether this token may read notifications (fine-grained tokens get 403). */
135
+ canReadNotifications(): Promise<boolean>;
134
136
  repo(fullName: string): Promise<RepoInfo>;
135
137
  pull(apiUrl: string): Promise<PullInfo>;
136
138
  issue(apiUrl: string): Promise<IssueInfo>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Where the GitHub token comes from, in this order:
3
+ *
4
+ * 1. the environment variable named in the config (GITHUB_TOKEN)
5
+ * 2. a token you pasted on the settings page, kept in the system keychain
6
+ * 3. the GitHub CLI (`gh auth token`), which keeps its own in the keychain
7
+ *
8
+ * A token is never written to the notebook or a file.
9
+ */
10
+ import { type Bi } from '../i18n.ts';
11
+ import type { SecretStore } from '../secrets.ts';
12
+ import { type FetchLike } from './client.ts';
13
+ /** The keychain entry for a token pasted on the settings page. */
14
+ export declare const GITHUB_SECRET = "github";
15
+ /** Opens GitHub's "new classic token" page with the two scopes ticked. */
16
+ export declare const NEW_TOKEN_URL = "https://github.com/settings/tokens/new?scopes=notifications,repo&description=DSH%20mishu";
17
+ export type TokenSource = 'env' | 'keychain' | 'gh';
18
+ export type TokenResult = {
19
+ token: string;
20
+ from: TokenSource;
21
+ } | {
22
+ error: Bi;
23
+ };
24
+ /** `ghPath` is for tests: a fixed path, or `null` for "not installed". */
25
+ export declare function resolveGithubToken(options: {
26
+ envName: string;
27
+ useGhCli: boolean;
28
+ secrets?: SecretStore;
29
+ ghPath?: string | null;
30
+ }): Promise<TokenResult>;
31
+ /**
32
+ * Check a pasted token with GitHub, then keep it in the keychain. It must
33
+ * read notifications, which only classic tokens with the `notifications`
34
+ * scope can (fine-grained tokens cannot reach that API).
35
+ */
36
+ export declare function saveGithubToken(secrets: SecretStore, raw: string, fetch?: FetchLike): Promise<{
37
+ login: string;
38
+ }>;
39
+ export declare function deleteGithubToken(secrets: SecretStore): Promise<void>;
@@ -2,7 +2,6 @@
2
2
  * mishu core — everything that does not depend on DSH.
3
3
  * Nothing under `src/core` may import DSH; `tests/boundary.test.ts` enforces it.
4
4
  */
5
- import { type Bi } from './i18n.ts';
6
5
  export { buildAsk, detailText, type AskRequest } from './ask.ts';
7
6
  export { buildBoard, buildDetail, type BoardCard, type BoardData, type CardAction, type DoneGroup, type MatterDetail } from './board.ts';
8
7
  export type { CardExplain } from './explain.ts';
@@ -11,6 +10,7 @@ export { GitHubClient, GitHubError, type FetchLike, type Notification } from './
11
10
  export { gatherFacts, Lookups, parseCheckSuiteTitle, type Facts } from './github/facts.ts';
12
11
  export { CLOSING_REASONS, judge, type Explain, type Verdict } from './github/rules.ts';
13
12
  export { syncGithub, type GithubSyncReport, type MarkReadMode } from './github/sync.ts';
13
+ export { deleteGithubToken, GITHUB_SECRET, NEW_TOKEN_URL, resolveGithubToken, saveGithubToken, type TokenResult, type TokenSource } from './github/token.ts';
14
14
  export { bi, decode, detectLang, errorText, LANGS, MishuError, pick, type Bi, type Lang } from './i18n.ts';
15
15
  export { addSeries, describeActiveSeries, describeSeries, resolveSeries, stopSeries, tickManual, upcoming, type ResolvedSeries, type SeriesInput, } from './manual.ts';
16
16
  export { DEFAULT_DEMOTION, isActive, isDemoted, setStatusByUser, snoozeByUser, type DemotionPolicy } from './matters.ts';
@@ -21,7 +21,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 { MemorySecrets, secretEnvName, secretFromEnv, systemSecrets, type SecretStore } from './secrets.ts';
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 { MailAccount, MailFetcher } from './account.ts';
11
+ import { type MailAccount, type MailFetcher } from './account.ts';
12
12
  export declare const MAIL_SOURCE = "mail";
13
13
  export interface MailSyncReport {
14
14
  at: number;
@@ -19,7 +19,7 @@ export interface MailSyncReport {
19
19
  error?: Bi;
20
20
  }
21
21
  /** A connection or login failure, said so a person knows what to fix. */
22
- export declare function describeMailError(error: unknown, account: Pick<MailAccount, 'host' | 'port'>): Bi;
22
+ export declare function describeMailError(error: unknown, account: Pick<MailAccount, 'host' | 'port' | 'preset'>): Bi;
23
23
  export declare function mailReportKey(accountId: number): string;
24
24
  export declare function syncMail(options: {
25
25
  store: MishuStore;