@toddzheng024/dscode-bundle 0.2.0 → 0.4.0
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/bin/apply_patch +5 -0
- package/cordis.patch.yml +27 -44
- package/package.json +9 -1
- package/plugins/credentials/index.mjs +50 -0
- package/plugins/email/cli.mjs +32 -0
- package/plugins/email/contacts.mjs +36 -0
- package/plugins/email/gmail-oauth.mjs +69 -0
- package/plugins/email/gmail-store.mjs +2 -0
- package/plugins/email/gmail.mjs +194 -0
- package/plugins/email/imap.mjs +136 -0
- package/plugins/email/inbox.d.mts +25 -0
- package/plugins/email/inbox.mjs +76 -0
- package/plugins/email/smtp.mjs +69 -0
- package/plugins/email/store.mjs +32 -0
- package/plugins/email-tools/index.mjs +44 -0
- package/plugins/memory/index.mjs +3 -0
- package/plugins/session-bridge/communication.mjs +30 -11
- package/plugins/tui-tools/index.mjs +13 -9
- package/presets/dscode/agent.cordis.yml +16 -0
- package/vendor/tui/.dscode-viewport-probe-78546.mjs +39239 -0
- package/vendor/tui/dscode-email/cli.mjs +32 -0
- package/vendor/tui/dscode-email/contacts.mjs +36 -0
- package/vendor/tui/dscode-email/gmail-oauth.mjs +69 -0
- package/vendor/tui/dscode-email/gmail-store.mjs +2 -0
- package/vendor/tui/dscode-email/gmail.mjs +194 -0
- package/vendor/tui/dscode-email/imap.mjs +136 -0
- package/vendor/tui/dscode-email/inbox.d.mts +25 -0
- package/vendor/tui/dscode-email/inbox.mjs +76 -0
- package/vendor/tui/dscode-email/smtp.mjs +69 -0
- package/vendor/tui/dscode-email/store.mjs +32 -0
- package/vendor/tui/dscode-email.mjs +69 -0
- package/vendor/tui/index.mjs +483 -60
package/bin/apply_patch
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
2
|
# Accept a standard unified diff on stdin. Works without a Git repository.
|
|
3
3
|
# git apply checks all hunks before writing; --check performs a dry run.
|
|
4
|
+
prefix=$(git rev-parse --show-prefix 2>/dev/null) || prefix=
|
|
5
|
+
if [ -n "$prefix" ]; then
|
|
6
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 1
|
|
7
|
+
exec git -C "$root" apply --directory="$prefix" --whitespace=nowarn "$@"
|
|
8
|
+
fi
|
|
4
9
|
exec git apply --whitespace=nowarn "$@"
|
package/cordis.patch.yml
CHANGED
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
# Adapters resolve references per request; the Models page writes only the
|
|
96
96
|
# managed document, which is never materialized into the process environment.
|
|
97
97
|
- id: credentials
|
|
98
|
-
name: '@
|
|
98
|
+
name: '@toddzheng024/dscode-bundle/credentials'
|
|
99
99
|
|
|
100
100
|
# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra
|
|
101
101
|
# models in the picker) until a `llm-pi-ai:` settings section supplies provider
|
|
@@ -672,21 +672,8 @@
|
|
|
672
672
|
pointerInputPolicy: targeted
|
|
673
673
|
cursorVisualization: visible
|
|
674
674
|
|
|
675
|
-
#
|
|
676
|
-
#
|
|
677
|
-
# uses the equally pinned npx fallback and needs npm access on first launch.
|
|
678
|
-
- insert:
|
|
679
|
-
- id: mcp-chrome
|
|
680
|
-
name: '@deepseek-ai/dsh-mcp-client'
|
|
681
|
-
config:
|
|
682
|
-
serverName: chrome
|
|
683
|
-
transport: stdio
|
|
684
|
-
command: !!js "process.env.DSH_TUI_CHROME_ENTRY ? process.execPath : 'npx'"
|
|
685
|
-
args: !!js "[...(process.env.DSH_TUI_CHROME_ENTRY ? [process.env.DSH_TUI_CHROME_ENTRY] : ['--yes', 'chrome-devtools-mcp@1.9.0']), '--isolated', '--no-usage-statistics', '--no-performance-crux']"
|
|
686
|
-
env:
|
|
687
|
-
CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: '1'
|
|
688
|
-
failOnStartupError: true
|
|
689
|
-
toolCallTimeoutMs: 60000
|
|
675
|
+
# Chrome MCP belongs to the DSCODE agent preset. A fresh, prompt-free TUI
|
|
676
|
+
# launch can render before composing an agent or connecting the browser server.
|
|
690
677
|
|
|
691
678
|
- id: permission
|
|
692
679
|
config:
|
|
@@ -712,51 +699,47 @@
|
|
|
712
699
|
approval: never
|
|
713
700
|
|
|
714
701
|
|
|
715
|
-
- id:
|
|
716
|
-
|
|
702
|
+
- id: credentials
|
|
703
|
+
name: "@toddzheng024/dscode-bundle/credentials"
|
|
717
704
|
- insert:
|
|
718
|
-
- id: dscode-deepseek
|
|
719
|
-
name: '@toddzheng024/dscode-bundle/deepseek'
|
|
720
|
-
- id: dscode-bootstrap
|
|
721
|
-
name: '@toddzheng024/dscode-bundle/bootstrap'
|
|
722
705
|
- id: dscode-auto-review
|
|
723
|
-
name:
|
|
706
|
+
name: "@toddzheng024/dscode-bundle/auto-review"
|
|
724
707
|
config:
|
|
725
708
|
timeoutMs: 30000
|
|
726
709
|
maxOutputTokens: 768
|
|
727
710
|
maxReviewsPerTurn: 20
|
|
728
711
|
- id: dscode-session-metrics
|
|
729
|
-
name:
|
|
730
|
-
- id: dscode-memory
|
|
731
|
-
name: '@toddzheng024/dscode-bundle/memory'
|
|
732
|
-
- id: dscode-session-bridge
|
|
733
|
-
name: '@toddzheng024/dscode-bundle/session-bridge'
|
|
712
|
+
name: "@toddzheng024/dscode-bundle/session-metrics"
|
|
734
713
|
- id: dscode-session-cards
|
|
735
|
-
name:
|
|
714
|
+
name: "@toddzheng024/dscode-bundle/session-cards"
|
|
715
|
+
- id: dscode-session-bridge
|
|
716
|
+
name: "@toddzheng024/dscode-bundle/session-bridge"
|
|
717
|
+
- id: dscode-memory
|
|
718
|
+
name: "@toddzheng024/dscode-bundle/memory"
|
|
736
719
|
- id: dscode-tui-tools
|
|
737
|
-
name:
|
|
720
|
+
name: "@toddzheng024/dscode-bundle/tui-tools"
|
|
721
|
+
- id: dscode-email-tools
|
|
722
|
+
name: "@toddzheng024/dscode-bundle/email-tools"
|
|
738
723
|
- id: dscode-hooks
|
|
739
|
-
name:
|
|
740
|
-
inject:
|
|
724
|
+
name: "@deepseek-ai/dsh-hooks-codex"
|
|
725
|
+
inject: &a1
|
|
726
|
+
- dscodePaths
|
|
741
727
|
config:
|
|
742
728
|
configPath: !!js ctx.dscodePaths.hooks
|
|
743
729
|
defaultTimeoutMs: 10000
|
|
744
730
|
stderrSummaryMaxChars: 500
|
|
745
731
|
- id: agent-presets
|
|
746
|
-
inject:
|
|
732
|
+
inject: *a1
|
|
747
733
|
config:
|
|
748
734
|
default: dscode
|
|
749
735
|
roots:
|
|
750
736
|
- path: !!js ctx.dscodePaths.presets
|
|
751
737
|
trust: system
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
CHROME_DEVTOOLS_MCP_NO_UPDATE_CHECKS: '1'
|
|
761
|
-
failOnStartupError: true
|
|
762
|
-
toolCallTimeoutMs: 60000
|
|
738
|
+
|
|
739
|
+
- id: llm-deepseek
|
|
740
|
+
disabled: true
|
|
741
|
+
- insert:
|
|
742
|
+
- id: dscode-deepseek
|
|
743
|
+
name: '@toddzheng024/dscode-bundle/deepseek'
|
|
744
|
+
- id: dscode-bootstrap
|
|
745
|
+
name: '@toddzheng024/dscode-bundle/bootstrap'
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.4.0",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Todd Zheng",
|
|
@@ -25,8 +25,13 @@
|
|
|
25
25
|
"THIRD_PARTY_NOTICES.md"
|
|
26
26
|
],
|
|
27
27
|
"exports": {
|
|
28
|
+
"./email-tools": "./plugins/email-tools/index.mjs",
|
|
29
|
+
"./imap": "./plugins/email/imap.mjs",
|
|
30
|
+
"./gmail": "./plugins/email/gmail.mjs",
|
|
31
|
+
"./email": "./plugins/email/inbox.mjs",
|
|
28
32
|
"./package.json": "./package.json",
|
|
29
33
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
34
|
+
"./credentials": "./plugins/credentials/index.mjs",
|
|
30
35
|
"./memory": "./plugins/memory/index.mjs",
|
|
31
36
|
"./session-bridge": "./plugins/session-bridge/index.mjs",
|
|
32
37
|
"./session-cards": "./plugins/session-cards/index.mjs",
|
|
@@ -289,6 +294,9 @@
|
|
|
289
294
|
"dsh-code": "1.0.6",
|
|
290
295
|
"chrome-devtools-mcp": "1.9.0",
|
|
291
296
|
"react": "18.3.1",
|
|
297
|
+
"imapflow": "2.0.2",
|
|
298
|
+
"mailparser": "3.9.26",
|
|
299
|
+
"nodemailer": "10.0.9",
|
|
292
300
|
"commander": "15.0.0",
|
|
293
301
|
"eventsource-parser": "3.1.1"
|
|
294
302
|
},
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local';
|
|
4
|
+
import { Context, Service } from '@deepseek-ai/cordis';
|
|
5
|
+
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
6
|
+
|
|
7
|
+
const DEEPSEEK = 'DEEPSEEK_API_KEY';
|
|
8
|
+
|
|
9
|
+
// Use the native locked, watched, owner-only store. Keep this independent of
|
|
10
|
+
// the installed profile so upgrades and different projects share credentials.
|
|
11
|
+
export default class DscodeCredentials extends LocalCredentialProvider {
|
|
12
|
+
shared;
|
|
13
|
+
sharedConfig;
|
|
14
|
+
constructor(ctx, config = {}) {
|
|
15
|
+
// Retain the old profile store, including other providers and OAuth records.
|
|
16
|
+
super(ctx, { ...config, path: undefined });
|
|
17
|
+
this.sharedConfig = { ...config, path: config.path ?? join(homedir(), '.dscode', 'credentials.yaml') };
|
|
18
|
+
}
|
|
19
|
+
async *[Service.init]() {
|
|
20
|
+
const sharedContext = new Context();
|
|
21
|
+
yield () => sharedContext.fiber.dispose();
|
|
22
|
+
sharedContext.provide('launchEnvironment', launchEnvironmentOf(this.ctx));
|
|
23
|
+
sharedContext.on('credentials/reference-updated', ref => this.notifyUpdated(ref));
|
|
24
|
+
await sharedContext.plugin(LocalCredentialProvider, this.sharedConfig);
|
|
25
|
+
this.shared = sharedContext.credentials;
|
|
26
|
+
yield* super[Service.init]();
|
|
27
|
+
}
|
|
28
|
+
async resolve(ref) {
|
|
29
|
+
if (ref === DEEPSEEK) {
|
|
30
|
+
const stored = await this.shared.resolve(ref);
|
|
31
|
+
if (stored?.source === 'env' || stored?.source === 'file') return stored;
|
|
32
|
+
}
|
|
33
|
+
return super.resolve(ref);
|
|
34
|
+
}
|
|
35
|
+
async describe(ref) {
|
|
36
|
+
if (ref === DEEPSEEK) {
|
|
37
|
+
const facts = await this.shared.describe(ref);
|
|
38
|
+
if (facts.source === 'env' || facts.source === 'file') return facts;
|
|
39
|
+
}
|
|
40
|
+
return super.describe(ref);
|
|
41
|
+
}
|
|
42
|
+
set(ref, value) {
|
|
43
|
+
return ref === DEEPSEEK ? this.shared.set(ref, value) : super.set(ref, value);
|
|
44
|
+
}
|
|
45
|
+
async unset(ref) {
|
|
46
|
+
// Explicit removal must not uncover a previously configured legacy key.
|
|
47
|
+
if (ref === DEEPSEEK) await this.shared.unset(ref);
|
|
48
|
+
await super.unset(ref);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createEmailContacts } from './contacts.mjs';
|
|
2
|
+
import { createGmailConnector } from './gmail.mjs';
|
|
3
|
+
import { createImapConnector } from './imap.mjs';
|
|
4
|
+
|
|
5
|
+
export async function runEmailClient(args, { connector = createGmailConnector(), imap = createImapConnector(), contacts = createEmailContacts(), output = console.log } = {}) {
|
|
6
|
+
const [command = 'status', ...rest] = args;
|
|
7
|
+
if (command === 'alias') {
|
|
8
|
+
const [action = 'list', name, address] = rest;
|
|
9
|
+
let result;
|
|
10
|
+
if (action === 'list' && rest.length <= 1) result = contacts.list();
|
|
11
|
+
else if (action === 'set' && rest.length === 3) result = await contacts.set(name, address);
|
|
12
|
+
else if (action === 'remove' && rest.length === 2) result = await contacts.remove(name);
|
|
13
|
+
else throw Error('Usage: dscode email alias list | set NAME ADDRESS | remove NAME');
|
|
14
|
+
if (result.busy === true) throw Error('Contacts are busy in another session. Retry shortly.');
|
|
15
|
+
output(JSON.stringify(result, null, 2)); return;
|
|
16
|
+
}
|
|
17
|
+
if (!['configure', 'connect', 'sync', 'status', 'imap-status', 'imap-sync'].includes(command) || (command === 'configure' ? rest.length !== 1 : rest.length !== 0)) throw Error('Usage: dscode email configure CLIENT_JSON | connect | sync | status | imap-status | imap-sync (IMAP setup: /email then i)');
|
|
18
|
+
if (command.startsWith('imap-')) connector = imap;
|
|
19
|
+
if (command === 'imap-status') { output(JSON.stringify(connector.status(), null, 2)); return; }
|
|
20
|
+
if (command === 'status') { output(JSON.stringify(connector.status(), null, 2)); return; }
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const abort = () => controller.abort();
|
|
23
|
+
process.once('SIGINT', abort); process.once('SIGTERM', abort);
|
|
24
|
+
try {
|
|
25
|
+
if (command === 'connect') output('Complete Gmail authorization in your browser. Ctrl+C cancels.');
|
|
26
|
+
const result = command === 'configure' ? await connector.configure(rest[0])
|
|
27
|
+
: command === 'connect' ? await connector.connect({ signal: controller.signal })
|
|
28
|
+
: await connector.sync({ signal: controller.signal, force: true });
|
|
29
|
+
if (result.busy) throw Error('Gmail is busy in another session. Retry shortly.');
|
|
30
|
+
output(command === 'configure' ? 'Google Desktop OAuth client saved privately. Open /email and press g to connect.' : JSON.stringify(result, null, 2));
|
|
31
|
+
} finally { process.off('SIGINT', abort); process.off('SIGTERM', abort); }
|
|
32
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { createEmailInbox } from './inbox.mjs';
|
|
3
|
+
import { emailStore } from './store.mjs';
|
|
4
|
+
|
|
5
|
+
export const emailAddress = value => typeof value === 'string' && value.length <= 254 && /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$/.test(value);
|
|
6
|
+
const aliasKey = value => {
|
|
7
|
+
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(value)) throw Error('Alias must be 1..64 letters, digits, underscores or hyphens.');
|
|
8
|
+
return value.toLowerCase();
|
|
9
|
+
};
|
|
10
|
+
export function createEmailContacts({ directory = createEmailInbox().directory } = {}) {
|
|
11
|
+
const store = emailStore(join(directory, 'contacts'));
|
|
12
|
+
const list = () => store.read('aliases.json') ?? {};
|
|
13
|
+
return {
|
|
14
|
+
list,
|
|
15
|
+
resolve(value) {
|
|
16
|
+
if (emailAddress(value)) return { to: value };
|
|
17
|
+
const alias = aliasKey(value), entries = list();
|
|
18
|
+
const to = Object.hasOwn(entries, alias) ? entries[alias] : null;
|
|
19
|
+
if (!emailAddress(to)) throw Error('Unknown email alias: ' + alias + '. Add it with dscode email alias set NAME ADDRESS.');
|
|
20
|
+
return { alias, to };
|
|
21
|
+
},
|
|
22
|
+
async set(name, address) {
|
|
23
|
+
const alias = aliasKey(name);
|
|
24
|
+
if (!emailAddress(address)) throw Error('Specify one plain email address.');
|
|
25
|
+
return store.locked(async () => {
|
|
26
|
+
const entries = list();
|
|
27
|
+
Object.defineProperty(entries, alias, { value: address, enumerable: true, configurable: true, writable: true });
|
|
28
|
+
store.write('aliases.json', entries); return { alias, to: address };
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
async remove(name) {
|
|
32
|
+
const alias = aliasKey(name);
|
|
33
|
+
return store.locked(async () => { const entries = list(); delete entries[alias]; store.write('aliases.json', entries); return { removed: alias }; });
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export const GMAIL_SCOPES = ['gmail.readonly', 'gmail.labels', 'gmail.settings.basic'].map(scope => 'https://www.googleapis.com/auth/' + scope);
|
|
7
|
+
export function readGoogleClient(path) {
|
|
8
|
+
let value;
|
|
9
|
+
try { value = JSON.parse(readFileSync(path, 'utf8')).installed; } catch { /* Report no content or secrets. */ }
|
|
10
|
+
if (!value || typeof value.client_id !== 'string' || !value.client_id.endsWith('.apps.googleusercontent.com')) throw Error('Set DSCODE_GMAIL_CLIENT_FILE to a Google Desktop OAuth client JSON file.');
|
|
11
|
+
return { client_id: value.client_id, ...(typeof value.client_secret === 'string' ? { client_secret: value.client_secret } : {}) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function tokenRequest(params, { fetchImpl = fetch, signal } = {}) {
|
|
15
|
+
const response = await fetchImpl('https://oauth2.googleapis.com/token', {
|
|
16
|
+
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
17
|
+
body: new URLSearchParams(params).toString(), redirect: 'error',
|
|
18
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000),
|
|
19
|
+
});
|
|
20
|
+
if (!response.ok) throw Error(response.status === 400 ? 'Google authorization expired or was rejected. Reconnect Gmail.' : 'Google token request failed. Retry later.');
|
|
21
|
+
const token = await response.json();
|
|
22
|
+
if (typeof token.access_token !== 'string' || !token.access_token || !Number.isFinite(token.expires_in) || token.expires_in <= 0) throw Error('Google returned an invalid access token.');
|
|
23
|
+
if (token.scope && !GMAIL_SCOPES.every(scope => token.scope.split(' ').includes(scope))) throw Error('Grant all three Gmail permissions, then reconnect.');
|
|
24
|
+
return { access_token: token.access_token, expiresAt: Date.now() + token.expires_in * 1000,
|
|
25
|
+
...(typeof token.refresh_token === 'string' ? { refresh_token: token.refresh_token } : {}) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const openGoogleBrowser = url => new Promise((resolve, reject) => {
|
|
29
|
+
execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [url], { timeout: 10000 }, error => error ? reject(Error('Could not open the browser for Google login.')) : resolve());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export async function authorizeGoogle(client, { fetchImpl = fetch, openBrowser = openGoogleBrowser, signal, timeoutMs = 180000 } = {}) {
|
|
33
|
+
const verifier = randomBytes(32).toString('base64url'), state = randomBytes(32).toString('base64url');
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const abort = () => controller.abort();
|
|
36
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
37
|
+
const timer = setTimeout(abort, timeoutMs);
|
|
38
|
+
let resolveCode, rejectCode;
|
|
39
|
+
const codeResult = new Promise((resolve, reject) => { resolveCode = resolve; rejectCode = reject; });
|
|
40
|
+
codeResult.catch(() => {});
|
|
41
|
+
const server = createServer((req, res) => {
|
|
42
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
43
|
+
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
44
|
+
if (req.method !== 'GET' || url.pathname !== '/oauth/callback' || url.searchParams.get('state') !== state) { res.writeHead(400).end('Invalid callback.'); return; }
|
|
45
|
+
if (url.searchParams.has('error')) { res.end('Google authorization was declined. Return to DSCODE.'); rejectCode(Error('Google authorization was declined.')); return; }
|
|
46
|
+
const code = url.searchParams.get('code');
|
|
47
|
+
if (!code) { res.writeHead(400).end('Missing code.'); return; }
|
|
48
|
+
res.end('Authorization received. Return to DSCODE to see connection status.');
|
|
49
|
+
resolveCode(code);
|
|
50
|
+
});
|
|
51
|
+
controller.signal.addEventListener('abort', () => rejectCode(Error('Google login cancelled or timed out.')), { once: true });
|
|
52
|
+
try {
|
|
53
|
+
if (signal?.aborted) throw Error('Google login cancelled.');
|
|
54
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
55
|
+
const redirect = 'http://127.0.0.1:' + server.address().port + '/oauth/callback';
|
|
56
|
+
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
57
|
+
url.search = new URLSearchParams({ client_id: client.client_id, redirect_uri: redirect,
|
|
58
|
+
response_type: 'code', scope: GMAIL_SCOPES.join(' '), access_type: 'offline', prompt: 'consent select_account',
|
|
59
|
+
state, code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256' }).toString();
|
|
60
|
+
await openBrowser(url.href);
|
|
61
|
+
const code = await codeResult;
|
|
62
|
+
const token = await tokenRequest({ ...client, code, redirect_uri: redirect, code_verifier: verifier, grant_type: 'authorization_code' }, { fetchImpl, signal: controller.signal });
|
|
63
|
+
if (!token.refresh_token) throw Error('Google did not grant offline access. Reconnect Gmail.');
|
|
64
|
+
return token;
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort);
|
|
67
|
+
server.close(); server.closeAllConnections();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { createEmailInbox, normalizeEmail } from './inbox.mjs';
|
|
4
|
+
import { gmailStore } from './gmail-store.mjs';
|
|
5
|
+
import { readGoogleClient, authorizeGoogle, tokenRequest } from './gmail-oauth.mjs';
|
|
6
|
+
|
|
7
|
+
const LABEL = 'ToAgent';
|
|
8
|
+
const FILTER = { subject: '[ToAgent]' };
|
|
9
|
+
const isId = value => typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value);
|
|
10
|
+
const historyId = value => { if (typeof value !== 'string' || !/^\d+$/.test(value)) throw Error('Invalid Gmail sync cursor.'); return value; };
|
|
11
|
+
const header = (part, name) => (part?.headers || []).find(h => h.name?.toLowerCase() === name.toLowerCase())?.value || '';
|
|
12
|
+
const decode = (data, charset = 'utf-8') => {
|
|
13
|
+
if (typeof data !== 'string' || data.length > 400000 || !/^[A-Za-z0-9_-]*={0,2}$/.test(data)) throw Error('Invalid or oversized plain-text body.');
|
|
14
|
+
try { return new TextDecoder(charset, { fatal: true }).decode(Buffer.from(data, 'base64url')); }
|
|
15
|
+
catch { throw Error('Invalid plain-text encoding.'); }
|
|
16
|
+
};
|
|
17
|
+
export function decodeHeader(value) {
|
|
18
|
+
return value.replace(/(\?=)\s+(=\?)/g, '$1$2').replace(/=\?([^?]+)\?([bq])\?([^?]*)\?=/gi, (_, charset, type, data) => {
|
|
19
|
+
const bytes = type.toLowerCase() === 'b' ? Buffer.from(data, 'base64') : Buffer.from(data.replace(/_/g, ' ').replace(/=([a-f0-9]{2})/gi, (_, n) => String.fromCharCode(parseInt(n, 16))), 'latin1');
|
|
20
|
+
try { return new TextDecoder(charset, { fatal: true }).decode(bytes); }
|
|
21
|
+
catch { throw Error('Invalid header encoding.'); }
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export function matchesGmailMessage(message, state) {
|
|
25
|
+
const date = Number(message.internalDate);
|
|
26
|
+
return Array.isArray(message.labelIds) && message.labelIds.includes(state.labelId)
|
|
27
|
+
&& !message.labelIds.some(id => ['SPAM', 'TRASH', 'DRAFT', 'SENT'].includes(id))
|
|
28
|
+
&& Number.isFinite(date) && date >= state.connectedAt
|
|
29
|
+
&& /^\[ToAgent\](?:\s|$)/.test(decodeHeader(header(message.payload, 'subject')));
|
|
30
|
+
}
|
|
31
|
+
export async function gmailEnvelope(message, state, getAttachment) {
|
|
32
|
+
if (!matchesGmailMessage(message, state)) return null;
|
|
33
|
+
const findPlain = (part, depth = 0) => {
|
|
34
|
+
if (!part || depth > 20 || part.filename || /^attachment\b/i.test(header(part, 'content-disposition'))) return null;
|
|
35
|
+
if (part.mimeType === 'text/plain') return part;
|
|
36
|
+
if (!part.mimeType?.startsWith('multipart/')) return null;
|
|
37
|
+
for (const child of part.parts || []) { const found = findPlain(child, depth + 1); if (found) return found; }
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
const part = findPlain(message.payload);
|
|
41
|
+
if (!part || part.body?.size > 262144) return null;
|
|
42
|
+
const body = part.body?.data ?? (part.body?.attachmentId ? (await getAttachment(part.body.attachmentId)).data : '');
|
|
43
|
+
const charset = header(part, 'content-type').match(/charset\s*=\s*"?([^;"\s]+)/i)?.[1] || 'utf-8';
|
|
44
|
+
return normalizeEmail({ format: 'dscode.email.v1', connector: 'gmail', account: state.account,
|
|
45
|
+
id: message.id, from: decodeHeader(header(message.payload, 'from')), subject: decodeHeader(header(message.payload, 'subject')),
|
|
46
|
+
body: decode(body, charset), receivedAt: new Date(Number(message.internalDate)).toISOString(),
|
|
47
|
+
updatedAt: new Date(Number(message.internalDate)).toISOString() });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createGmailConnector({ inbox = createEmailInbox(), directory = join(inbox.directory, 'gmail'), fetchImpl = fetch, authorize = authorizeGoogle, openBrowser, now = Date.now } = {}) {
|
|
51
|
+
const store = gmailStore(directory);
|
|
52
|
+
const clientPath = () => process.env.DSCODE_GMAIL_CLIENT_FILE || join(directory, 'client.json');
|
|
53
|
+
let working = false;
|
|
54
|
+
const exclusive = async action => {
|
|
55
|
+
if (working) return { busy: true };
|
|
56
|
+
working = true;
|
|
57
|
+
try { return await store.locked(action); } finally { working = false; }
|
|
58
|
+
};
|
|
59
|
+
async function api(token, path, { method = 'GET', body, signal } = {}) {
|
|
60
|
+
const response = await fetchImpl('https://gmail.googleapis.com/gmail/v1/users/me/' + path, {
|
|
61
|
+
method, headers: { Authorization: 'Bearer ' + token.access_token, ...(body ? { 'Content-Type': 'application/json' } : {}) },
|
|
62
|
+
...(body ? { body: JSON.stringify(body) } : {}), redirect: 'error',
|
|
63
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000),
|
|
64
|
+
});
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
const error = Error(response.status === 401 ? 'Gmail authorization expired. Press g to reconnect.' : response.status === 403 ? 'Gmail access denied. Check API enablement and granted permissions, then reconnect.' : 'Gmail request failed (' + response.status + '). Retry sync.');
|
|
67
|
+
error.status = response.status; throw error;
|
|
68
|
+
}
|
|
69
|
+
return response.status === 204 ? {} : response.json();
|
|
70
|
+
}
|
|
71
|
+
async function access(signal, force = false) {
|
|
72
|
+
const token = store.read('tokens.json');
|
|
73
|
+
if (!token?.refresh_token) throw Error('Gmail is not connected. Press g to connect.');
|
|
74
|
+
if (!force && token.expiresAt > now() + 60000) return token;
|
|
75
|
+
const next = { ...token, ...await tokenRequest({ ...readGoogleClient(clientPath()), grant_type: 'refresh_token', refresh_token: token.refresh_token }, { fetchImpl, signal }) };
|
|
76
|
+
store.write('tokens.json', next); return next;
|
|
77
|
+
}
|
|
78
|
+
async function provision(token, signal) {
|
|
79
|
+
const labels = await api(token, 'labels', { signal });
|
|
80
|
+
let label = labels.labels?.find(label => label.name === LABEL);
|
|
81
|
+
if (!label) label = await api(token, 'labels', { method: 'POST', body: { name: LABEL, labelListVisibility: 'labelShow', messageListVisibility: 'show' }, signal });
|
|
82
|
+
if (!isId(label.id)) throw Error('Invalid Gmail ToAgent label.');
|
|
83
|
+
const filters = await api(token, 'settings/filters', { signal });
|
|
84
|
+
const found = filters.filter?.some(filter => JSON.stringify(filter.criteria) === JSON.stringify(FILTER)
|
|
85
|
+
&& filter.action?.addLabelIds?.includes(label.id) && !filter.action?.removeLabelIds?.length && !filter.action?.forward);
|
|
86
|
+
if (!found) await api(token, 'settings/filters', { method: 'POST', body: { criteria: FILTER, action: { addLabelIds: [label.id] } }, signal });
|
|
87
|
+
return label.id;
|
|
88
|
+
}
|
|
89
|
+
async function collect(token, state, signal) {
|
|
90
|
+
const ids = new Set();
|
|
91
|
+
let cursor, pageToken;
|
|
92
|
+
try {
|
|
93
|
+
do {
|
|
94
|
+
const query = new URLSearchParams({ startHistoryId: state.historyId, labelId: state.labelId, maxResults: '100', ...(pageToken ? { pageToken } : {}) });
|
|
95
|
+
const page = await api(token, 'history?' + query, { signal });
|
|
96
|
+
for (const event of page.history || []) for (const change of [...(event.messagesAdded || []), ...(event.labelsAdded || [])]) {
|
|
97
|
+
if (isId(change.message?.id)) ids.add(change.message.id);
|
|
98
|
+
}
|
|
99
|
+
cursor = historyId(page.historyId); pageToken = page.nextPageToken;
|
|
100
|
+
} while (pageToken);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error.status !== 404) throw error;
|
|
103
|
+
// Expired history: capture a new boundary BEFORE listing so changes that
|
|
104
|
+
// race this recovery are replayed on the next incremental pass.
|
|
105
|
+
cursor = historyId((await api(token, 'profile', { signal })).historyId);
|
|
106
|
+
pageToken = undefined;
|
|
107
|
+
do {
|
|
108
|
+
const query = new URLSearchParams({ labelIds: state.labelId, q: 'after:' + (Math.floor(state.connectedAt / 1000) - 1), maxResults: '100', ...(pageToken ? { pageToken } : {}) });
|
|
109
|
+
const page = await api(token, 'messages?' + query, { signal });
|
|
110
|
+
for (const message of page.messages || []) if (isId(message.id)) ids.add(message.id);
|
|
111
|
+
pageToken = page.nextPageToken;
|
|
112
|
+
} while (pageToken);
|
|
113
|
+
}
|
|
114
|
+
let accepted = 0, skipped = 0;
|
|
115
|
+
for (const id of ids) {
|
|
116
|
+
let metadata;
|
|
117
|
+
try { metadata = await api(token, 'messages/' + id + '?format=metadata&metadataHeaders=Subject', { signal }); }
|
|
118
|
+
catch (error) { if (error.status === 404) { skipped++; continue; } throw error; }
|
|
119
|
+
let matches;
|
|
120
|
+
try { matches = matchesGmailMessage(metadata, state); } catch { matches = false; }
|
|
121
|
+
if (!matches) { skipped++; continue; }
|
|
122
|
+
let message;
|
|
123
|
+
try { message = await api(token, 'messages/' + id + '?format=full', { signal }); }
|
|
124
|
+
catch (error) { if (error.status === 404) { skipped++; continue; } throw error; }
|
|
125
|
+
let envelope;
|
|
126
|
+
try { envelope = await gmailEnvelope(message, state, attachmentId => {
|
|
127
|
+
if (!isId(attachmentId)) throw Error('Invalid body attachment.');
|
|
128
|
+
return api(token, 'messages/' + id + '/attachments/' + attachmentId, { signal });
|
|
129
|
+
}); } catch (error) {
|
|
130
|
+
// Network/body fetch failures must retain the cursor for retry.
|
|
131
|
+
if (error.status || signal?.aborted || error.name === 'TypeError' || error.name === 'TimeoutError') throw error;
|
|
132
|
+
skipped++; continue;
|
|
133
|
+
}
|
|
134
|
+
if (envelope) { inbox.receive(envelope); accepted++; } else skipped++;
|
|
135
|
+
}
|
|
136
|
+
return { ...state, historyId: cursor, lastSyncAt: now(), accepted, skipped, error: null };
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
directory,
|
|
140
|
+
configure(path) {
|
|
141
|
+
return exclusive(async () => {
|
|
142
|
+
if (process.env.DSCODE_GMAIL_CLIENT_FILE) throw Error('DSCODE_GMAIL_CLIENT_FILE is set. Unset it before importing a client.');
|
|
143
|
+
const client = readGoogleClient(path);
|
|
144
|
+
if (store.exists('tokens.json') && readGoogleClient(clientPath()).client_id !== client.client_id) throw Error('Gmail already uses a different OAuth client. Keep the connected client.');
|
|
145
|
+
store.write('client.json', { installed: client });
|
|
146
|
+
return { configured: true };
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
status() {
|
|
150
|
+
try {
|
|
151
|
+
const state = store.read('state.json');
|
|
152
|
+
return { configured: existsSync(clientPath()), connected: !!state?.account && store.exists('tokens.json'), account: state?.account,
|
|
153
|
+
lastSyncAt: state?.lastSyncAt, accepted: state?.accepted || 0, skipped: state?.skipped || 0, error: state?.error || null };
|
|
154
|
+
} catch { return { connected: false, error: 'Cannot read local Gmail state.' }; }
|
|
155
|
+
},
|
|
156
|
+
connect({ signal } = {}) {
|
|
157
|
+
return exclusive(async () => {
|
|
158
|
+
const client = readGoogleClient(clientPath());
|
|
159
|
+
const token = await authorize(client, { fetchImpl, openBrowser, signal });
|
|
160
|
+
const profile = await api(token, 'profile', { signal });
|
|
161
|
+
if (typeof profile.emailAddress !== 'string' || !profile.emailAddress.includes('@')) throw Error('Google returned an invalid account.');
|
|
162
|
+
const old = store.read('state.json');
|
|
163
|
+
if (old?.account && old.account !== profile.emailAddress) throw Error('One Gmail account is supported. Reconnect the original account.');
|
|
164
|
+
const labelId = await provision(token, signal);
|
|
165
|
+
const boundary = historyId((await api(token, 'profile', { signal })).historyId);
|
|
166
|
+
const state = { account: profile.emailAddress, labelId, connectedAt: old?.connectedAt ?? now(),
|
|
167
|
+
historyId: old?.historyId ?? boundary, lastSyncAt: old?.lastSyncAt ?? null, error: null };
|
|
168
|
+
signal?.throwIfAborted();
|
|
169
|
+
store.write('tokens.json', token);
|
|
170
|
+
store.write('state.json', state);
|
|
171
|
+
return { connected: true, account: state.account };
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
async sync({ signal, force = false } = {}) {
|
|
175
|
+
if (!store.exists('state.json')) return { connected: false };
|
|
176
|
+
return exclusive(async () => {
|
|
177
|
+
const state = store.read('state.json');
|
|
178
|
+
if (!state?.account) return { connected: false };
|
|
179
|
+
if (!force && now() - (state.lastAttemptAt || 0) < 30000) return { throttled: true };
|
|
180
|
+
state.lastAttemptAt = now(); store.write('state.json', state);
|
|
181
|
+
try {
|
|
182
|
+
let next;
|
|
183
|
+
try { next = await collect(await access(signal), state, signal); }
|
|
184
|
+
catch (error) { if (error.status !== 401) throw error; next = await collect(await access(signal, true), state, signal); }
|
|
185
|
+
signal?.throwIfAborted();
|
|
186
|
+
store.write('state.json', next); return { accepted: next.accepted, skipped: next.skipped };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const safe = error.status ? error.message : /^(Google|Gmail|Grant|Set DSCODE_|Cannot read|Invalid Gmail)/.test(error.message) ? error.message : 'Gmail sync interrupted. Retry sync.';
|
|
189
|
+
store.write('state.json', { ...state, error: safe }); throw Error(safe);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|