dsh-mishu 0.5.4 → 0.6.2

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/lib/dsh/config.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import Schema from '@deepseek-ai/schemastery';
2
- import { CLOSING_REASONS, DEFAULT_HOME, DEFAULT_LEAD_DAYS } from "../core/index.js";
2
+ import { CLOSING_REASONS, DEFAULT_HOME, GITHUB_CLIENT_ID, DEFAULT_LEAD_DAYS } from "../core/index.js";
3
3
  export const Config = Schema.object({
4
4
  home: Schema.string().default(DEFAULT_HOME).description('Notebook directory (mishu.db)'),
5
5
  language: Schema.union(['auto', 'zh', 'en']).default('auto').description('Language: auto (system locale), zh (中文) or en (English)'),
@@ -8,6 +8,7 @@ export const Config = Schema.object({
8
8
  intervalMinutes: Schema.natural().min(5).default(15).description('Minutes between GitHub syncs'),
9
9
  tokenEnv: Schema.string().default('GITHUB_TOKEN').description('Environment variable with a GitHub token'),
10
10
  useGhCli: Schema.boolean().default(true).description('Fall back to `gh auth token`'),
11
+ clientId: Schema.string().default(GITHUB_CLIENT_ID).description('GitHub OAuth App client id for "Sign in with GitHub" (device flow)'),
11
12
  markRead: Schema.union(['off', 'shadow', 'on']).default('shadow').description('Mark resolved notifications read on GitHub: off / shadow (only record) / on'),
12
13
  markReadReasons: Schema.array(Schema.union([...CLOSING_REASONS])).default(['merged', 'closed', 'recovered', 'superseded', 'ref-gone'])
13
14
  .description('Closing reasons allowed to mark read'),
@@ -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, restartPlan, scheduleRestart, } from "../core/index.js";
7
+ import { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, FileSecrets, GithubDeviceLogin, KeychainOrFile, mishuPaths, 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,12 +12,15 @@ export class Runtime {
12
12
  fetcher;
13
13
  logger;
14
14
  syncing;
15
+ /** "Sign in with GitHub" in progress, if any; tests swap it to replay GitHub. */
16
+ githubLogin;
15
17
  /** How DSH restarts; tests swap it so they never restart anything. */
16
- restarter = { plan: () => restartPlan(), schedule: scheduleRestart };
17
- constructor(config, logger, secrets = systemSecrets(), fetcher = new ImapFetcher()) {
18
+ restarter = { plan: () => restartPlan(), schedule: plan => scheduleRestart(plan) };
19
+ constructor(config, logger, secrets = new KeychainOrFile(systemSecrets(), new FileSecrets(mishuPaths(config.home).secrets)), fetcher = new ImapFetcher()) {
18
20
  this.config = config;
19
21
  this.logger = logger;
20
22
  this.secrets = secrets;
23
+ this.githubLogin = new GithubDeviceLogin({ clientId: config.github.clientId, secrets });
21
24
  this.fetcher = fetcher;
22
25
  }
23
26
  defaults() {
@@ -92,7 +95,7 @@ export class Runtime {
92
95
  if (password === undefined) {
93
96
  const report = {
94
97
  at: Date.now(), account: account.id, messages: 0, opened: 0, closed: 0,
95
- error: bi('没有找到这个邮箱的密码(授权码)。到设置页重新填一次。', 'No password (app password) found for this account. Enter it again on the settings page.'),
98
+ error: bi('没有找到这个邮箱的密码(授权码)。在设置页点这个邮箱的「编辑」,重新填一次。', 'No password (app password) found for this account. On the settings page, click Edit on this account and enter it again.'),
96
99
  };
97
100
  store.set(mailReportKey(account.id), report);
98
101
  return report;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * "Sign in with GitHub" without pasting a token: GitHub's device flow.
3
+ *
4
+ * The secretary asks GitHub for a short code, you enter it at
5
+ * github.com/login/device (on any computer) and approve, and the secretary
6
+ * receives a token, checks it like a pasted one and keeps it in the
7
+ * keychain (or the secrets file). It needs the client id of a GitHub OAuth
8
+ * App with device flow enabled; the id is public, there is no secret.
9
+ *
10
+ * https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
11
+ */
12
+ import { type Bi } from '../i18n.ts';
13
+ import type { SecretStore } from '../secrets.ts';
14
+ import type { FetchLike } from './client.ts';
15
+ /** Notifications, and repo so CI runs, pull requests and issues of private repositories can be looked up. */
16
+ export declare const DEVICE_SCOPES = "notifications repo";
17
+ export type DeviceState = {
18
+ state: 'idle';
19
+ } | {
20
+ state: 'waiting';
21
+ userCode: string;
22
+ verificationUri: string;
23
+ expiresAt: number;
24
+ } | {
25
+ state: 'done';
26
+ login: string;
27
+ } | {
28
+ state: 'failed';
29
+ message: Bi;
30
+ };
31
+ /** `fetch` with a form body, which the device flow endpoints take. */
32
+ export type PostLike = (url: string, init: {
33
+ method: 'POST';
34
+ headers: Record<string, string>;
35
+ body: string;
36
+ }) => Promise<{
37
+ status: number;
38
+ text(): Promise<string>;
39
+ }>;
40
+ interface Options {
41
+ clientId: string;
42
+ secrets: SecretStore;
43
+ post?: PostLike;
44
+ /** For checking the token once it arrives; tests replay GitHub. */
45
+ fetch?: FetchLike;
46
+ sleep?: (ms: number) => Promise<void>;
47
+ now?: () => number;
48
+ }
49
+ export declare class GithubDeviceLogin {
50
+ private readonly options;
51
+ private current;
52
+ /** Bumped by every start and cancel, so an older poll loop stops quietly. */
53
+ private generation;
54
+ constructor(options: Options);
55
+ status(): DeviceState;
56
+ cancel(): void;
57
+ /** Ask GitHub for a code; polling for your approval runs in the background. */
58
+ start(): Promise<DeviceState>;
59
+ private poll;
60
+ private form;
61
+ private now;
62
+ private sleep;
63
+ }
64
+ export {};
@@ -12,6 +12,8 @@ import type { SecretStore } from '../secrets.ts';
12
12
  import { type FetchLike } from './client.ts';
13
13
  /** The keychain entry for a token pasted on the settings page. */
14
14
  export declare const GITHUB_SECRET = "github";
15
+ /** The OAuth App behind "Sign in with GitHub" (device flow, "DSH 小秘书"); public, not a secret. */
16
+ export declare const GITHUB_CLIENT_ID = "Ov23liejlNOyARQKgy5x";
15
17
  /** Opens GitHub's "new classic token" page with the two scopes ticked. */
16
18
  export declare const NEW_TOKEN_URL = "https://github.com/settings/tokens/new?scopes=notifications,repo&description=DSH%20mishu";
17
19
  export type TokenSource = 'env' | 'keychain' | 'gh';
@@ -10,20 +10,21 @@ export { GitHubClient, GitHubError, type FetchLike, type Notification } from './
10
10
  export { gatherFacts, Lookups, parseCheckSuiteTitle, type Facts } from './github/facts.ts';
11
11
  export { CLOSING_REASONS, judge, type Explain, type Verdict } from './github/rules.ts';
12
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';
13
+ export { DEVICE_SCOPES, GithubDeviceLogin, type DeviceState } from './github/device.ts';
14
+ export { deleteGithubToken, GITHUB_CLIENT_ID, GITHUB_SECRET, NEW_TOKEN_URL, resolveGithubToken, saveGithubToken, type TokenResult, type TokenSource } from './github/token.ts';
14
15
  export { bi, decode, detectLang, errorText, LANGS, MishuError, pick, type Bi, type Lang } from './i18n.ts';
15
16
  export { addSeries, describeActiveSeries, describeSeries, resolveSeries, stopSeries, tickManual, upcoming, type ResolvedSeries, type SeriesInput, } from './manual.ts';
16
17
  export { DEFAULT_DEMOTION, isActive, isDemoted, setStatusByUser, snoozeByUser, type DemotionPolicy } from './matters.ts';
17
18
  export { CATEGORIES, CATEGORY_LABELS, DEFAULT_LEAD_DAYS, REPEATS, type Category, type Repeat } from './recur.ts';
18
19
  export { MishuStore, type Matter, type MatterStatus, type Series } from './store.ts';
19
20
  export { addDays, dayOf, formatMoment } from './time.ts';
20
- export { MAIL_PRESETS, presetById, secretAccount, type MailAccount, type MailFetcher, type MailMessage, type MailPreset } from './mail/account.ts';
21
+ export { MAIL_PRESETS, normalizePassword, presetById, presetForAddress, providerMismatch, secretAccount, type MailAccount, type MailFetcher, type MailMessage, type MailPreset } from './mail/account.ts';
21
22
  export { ImapFetcher } from './mail/imap.ts';
22
23
  export { judgeMail } from './mail/rules.ts';
23
24
  export { describeMailError, MAIL_SOURCE, mailReportKey, syncMail, type MailSyncReport } from './mail/sync.ts';
24
25
  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';
26
+ export { restartPlan, scheduleRestart, type RestartPlan, type RestartStarted } from './restart.ts';
27
+ export { FileSecrets, KeychainOrFile, MemorySecrets, secretEnvName, secretFromEnv, systemSecrets, type SecretStatus, type SecretStore } from './secrets.ts';
27
28
  export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings, type Defaults, type UserSettings } from './settings.ts';
28
29
  export declare const DEFAULT_HOME = "~/.mishu";
29
30
  /** `~` and `~/…` expand to the home directory on every platform. */
@@ -31,4 +32,5 @@ export declare function expandHome(path: string): string;
31
32
  export declare function mishuPaths(home: string): {
32
33
  root: string;
33
34
  db: string;
35
+ secrets: string;
34
36
  };
@@ -6,6 +6,14 @@
6
6
  * system keychain under `mail:<id>`, never in the notebook.
7
7
  */
8
8
  import { type Bi } from '../i18n.ts';
9
+ /** One step of getting a provider's app password, with the page it happens on. */
10
+ export interface MailStep {
11
+ text: Bi;
12
+ link?: {
13
+ url: string;
14
+ label: Bi;
15
+ };
16
+ }
9
17
  export interface MailPreset {
10
18
  id: string;
11
19
  label: Bi;
@@ -14,12 +22,28 @@ export interface MailPreset {
14
22
  secure: boolean;
15
23
  /** False when the provider no longer allows password logins over IMAP. */
16
24
  supported: boolean;
17
- /** Where to get the password this provider expects. */
25
+ /** Numbered steps to the password this provider expects, each with its page to open. */
26
+ steps: MailStep[];
27
+ /** The same as one paragraph, for error messages and "+AI"; a short note where there are no steps. */
18
28
  howTo: Bi;
29
+ /** Address domains that belong to this provider, for picking it from the address. */
30
+ domains: string[];
31
+ /** Length of the app password or code, when the provider fixes it (spaces aside). */
32
+ passwordLength?: number;
19
33
  /** A web link that finds one message by Message-ID, when the provider has one. */
20
34
  findMessage?: (messageId: string) => string;
21
35
  }
22
36
  export declare const MAIL_PRESETS: readonly MailPreset[];
37
+ /** The provider an address belongs to, from its domain; `undefined` when we do not know it. */
38
+ export declare function presetForAddress(address: string): MailPreset | undefined;
39
+ /**
40
+ * The password as the server wants it. App passwords and codes are shown in
41
+ * groups ("abcd efgh ijkl mnop") and copied with the spaces; they never
42
+ * contain one, so spaces go when the provider fixes the length.
43
+ */
44
+ export declare function normalizePassword(presetId: string, password: string): string;
45
+ /** An account whose address belongs to another provider than its server ("…@gmail.com" on imap.qq.com). */
46
+ export declare function providerMismatch(account: Pick<MailAccount, 'address' | 'preset' | 'host'>): Bi | undefined;
23
47
  export declare function presetById(id: string): MailPreset;
24
48
  export interface MailAccount {
25
49
  id: number;
@@ -18,8 +18,14 @@ export interface MailSyncReport {
18
18
  closed: number;
19
19
  error?: Bi;
20
20
  }
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' | 'preset'>): Bi;
21
+ /**
22
+ * A connection or login failure, said so a person knows what to fix. With
23
+ * `steps` (the default) a login failure also repeats where the provider's
24
+ * app password comes from; the add-account dialog shows those steps already.
25
+ */
26
+ export declare function describeMailError(error: unknown, account: Pick<MailAccount, 'host' | 'port' | 'preset'>, options?: {
27
+ steps?: boolean;
28
+ }): Bi;
23
29
  export declare function mailReportKey(accountId: number): string;
24
30
  export declare function syncMail(options: {
25
31
  store: MishuStore;
@@ -15,6 +15,11 @@
15
15
  * Windows `taskkill /PID` without /F (a close request), then the app again
16
16
  * Linux SIGTERM to the app, then the app again
17
17
  *
18
+ * On a server set up with dsh-vps, DSH is a child of the dsh-gate login
19
+ * gateway (`$GATE_HOME/gate/server.js`, run by systemd), which starts DSH
20
+ * again whenever it exits and picks up the new sign-in token itself. There
21
+ * a restart is simply DSH exiting cleanly; the page reconnects.
22
+ *
18
23
  * DSH started with the `dsh` command is not restarted from here: its web
19
24
  * address (with a new sign-in token) is printed in that terminal, which a
20
25
  * detached replacement could not reach. The page says what to do instead.
@@ -29,10 +34,17 @@ export type RestartPlan = {
29
34
  platform: 'win32' | 'linux';
30
35
  exe: string;
31
36
  appPid: number;
37
+ } | {
38
+ kind: 'gate';
39
+ gate: string;
32
40
  } | {
33
41
  kind: 'none';
34
42
  why: Bi;
35
43
  };
44
+ /** Plans carried out by the detached helper (the desktop app quits and reopens). */
45
+ export type AppRestartPlan = Extract<RestartPlan, {
46
+ kind: 'macos-app' | 'desktop-app';
47
+ }>;
36
48
  export interface ProcessFacts {
37
49
  env: NodeJS.ProcessEnv;
38
50
  platform: NodeJS.Platform;
@@ -43,6 +55,8 @@ export interface ProcessFacts {
43
55
  parentExe: string | undefined;
44
56
  /** macOS: CFBundleIdentifier from the Info.plist of the .app holding `execPath`. */
45
57
  bundleId: string | undefined;
58
+ /** Linux: the parent's command line, to recognise the dsh-gate supervisor. */
59
+ parentArgs: string[] | undefined;
46
60
  }
47
61
  /** How DSH can be restarted from here, or why it cannot. */
48
62
  export declare function restartPlan(facts?: ProcessFacts): RestartPlan;
@@ -62,12 +76,13 @@ export interface MacTools {
62
76
  * inherit environment variables", open(1)). So the app is always started
63
77
  * without that variable; with it, the app starts as bare Node and exits.
64
78
  */
65
- export declare function restartHelperSource(plan: Exclude<RestartPlan, {
66
- kind: 'none';
67
- }>, log: string, tools?: MacTools): string;
68
- /** Start the helper and return where it logs. Runs with this process's Node (Electron as Node in the app). */
79
+ export declare function restartHelperSource(plan: AppRestartPlan, log: string, tools?: MacTools): string;
80
+ /** What the page is told: where the helper logs, or that it should wait and reconnect. */
81
+ export interface RestartStarted {
82
+ log?: string;
83
+ reconnect?: boolean;
84
+ }
85
+ /** Start the restart: under dsh-gate, exit; in the desktop app, start the helper and say where it logs. Runs with this process's Node (Electron as Node in the app). */
69
86
  export declare function scheduleRestart(plan: Exclude<RestartPlan, {
70
87
  kind: 'none';
71
- }>): {
72
- log: string;
73
- };
88
+ }>, exit?: () => void): RestartStarted;
@@ -7,21 +7,34 @@
7
7
  *
8
8
  * Secrets travel to the helper on stdin only, never as command-line
9
9
  * arguments, which other processes on the machine could read. Everything is
10
- * stored under the service name "dsh-mishu". When no keychain is available,
11
- * `secretFromEnv` is the documented fallback.
10
+ * stored under the service name "dsh-mishu".
11
+ *
12
+ * A computer without a usable keychain (a Linux server with no desktop,
13
+ * say) keeps them in `secrets.json` in the notebook directory instead:
14
+ * mode 600, so only your own system account can read it. That is how gh,
15
+ * git and Thunderbird manage on such machines, and it is said plainly on
16
+ * the settings page: protected by file permissions, not encrypted.
17
+ * `secretFromEnv` remains a last resort.
12
18
  */
13
19
  import { type Bi } from './i18n.ts';
14
20
  export declare const SERVICE = "dsh-mishu";
15
- /** Whether secrets can be kept here, and if not, why and what to do. */
21
+ /**
22
+ * Whether secrets can be kept here, and if not, why and what to do. `file`
23
+ * says they go to a file because the keychain could not be used.
24
+ */
16
25
  export type SecretStatus = {
17
26
  available: true;
27
+ file?: {
28
+ path: string;
29
+ why: Bi;
30
+ };
18
31
  } | {
19
32
  available: false;
20
33
  why: Bi;
21
34
  };
22
35
  export interface SecretStore {
23
36
  /** Where secrets go, for the settings page ("macOS 钥匙串"). */
24
- readonly kind: 'macos' | 'windows' | 'linux' | 'memory';
37
+ readonly kind: 'macos' | 'windows' | 'linux' | 'file' | 'memory';
25
38
  status(): Promise<SecretStatus>;
26
39
  get(account: string): Promise<string | undefined>;
27
40
  set(account: string, secret: string): Promise<void>;
@@ -43,3 +56,36 @@ export declare function systemSecrets(platform?: NodeJS.Platform): SecretStore;
43
56
  /** Fallback when no keychain is available: `DSH_MISHU_SECRET_MAIL_3` for account "mail:3". */
44
57
  export declare function secretEnvName(account: string): string;
45
58
  export declare function secretFromEnv(account: string): string | undefined;
59
+ /**
60
+ * Secrets in one JSON file, readable by your system account only (600).
61
+ * Written to a temporary file first and renamed, so a crash never leaves
62
+ * half a file; a file that cannot be read is never overwritten.
63
+ */
64
+ export declare class FileSecrets implements SecretStore {
65
+ readonly kind: "file";
66
+ readonly path: string;
67
+ constructor(path: string);
68
+ status(): Promise<SecretStatus>;
69
+ private read;
70
+ private write;
71
+ get(account: string): Promise<string | undefined>;
72
+ set(account: string, secret: string): Promise<void>;
73
+ delete(account: string): Promise<void>;
74
+ }
75
+ /**
76
+ * The keychain when it works, else the file. Reads look in both, so a
77
+ * secret saved to the file before a keychain was set up is still found;
78
+ * saving to the keychain removes the file's copy.
79
+ */
80
+ export declare class KeychainOrFile implements SecretStore {
81
+ private readonly keychain;
82
+ private readonly file;
83
+ private usable;
84
+ constructor(keychain: SecretStore, file: FileSecrets);
85
+ get kind(): "linux" | "macos" | "windows" | "file" | "memory";
86
+ private keychainStatus;
87
+ status(): Promise<SecretStatus>;
88
+ get(account: string): Promise<string | undefined>;
89
+ set(account: string, secret: string): Promise<void>;
90
+ delete(account: string): Promise<void>;
91
+ }
@@ -12,6 +12,8 @@ export interface MishuConfig {
12
12
  tokenEnv: string;
13
13
  /** Fall back to `gh auth token` (the GitHub CLI's keychain entry). */
14
14
  useGhCli: boolean;
15
+ /** Client id of the GitHub OAuth App used by "Sign in with GitHub"; empty hides the button. */
16
+ clientId: string;
15
17
  /** off: never touch GitHub; shadow: record what would be marked read; on: mark it. */
16
18
  markRead: MarkReadMode;
17
19
  /** Which closing reasons may mark read when `markRead` is on. */
@@ -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 RestartPlan, type SecretStore, type TokenResult } from '../core/index.ts';
7
+ import { GithubDeviceLogin, type Defaults, type Lang, type MailAccount, type MailFetcher, type MailSyncReport, type MishuStore, type RestartPlan, type RestartStarted, 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;
@@ -17,14 +17,14 @@ export declare class Runtime {
17
17
  readonly fetcher: MailFetcher;
18
18
  private readonly logger;
19
19
  private syncing;
20
+ /** "Sign in with GitHub" in progress, if any; tests swap it to replay GitHub. */
21
+ githubLogin: GithubDeviceLogin;
20
22
  /** How DSH restarts; tests swap it so they never restart anything. */
21
23
  restarter: {
22
24
  plan(): RestartPlan;
23
25
  schedule(plan: Exclude<RestartPlan, {
24
26
  kind: 'none';
25
- }>): {
26
- log: string;
27
- };
27
+ }>): RestartStarted;
28
28
  };
29
29
  constructor(config: MishuConfig, logger: Logger, secrets?: SecretStore, fetcher?: MailFetcher);
30
30
  defaults(): Defaults;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mishu",
3
- "version": "0.5.4",
3
+ "version": "0.6.2",
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",