@toddzheng024/dscode-bundle 0.3.0 → 0.5.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/THIRD_PARTY_NOTICES.md +15 -0
- package/cordis.patch.yml +5 -3
- package/package.json +14 -1
- package/plugins/clipboard-image/clipboard-image.swift +56 -0
- package/plugins/clipboard-image/index.mjs +70 -0
- package/plugins/code-review/git.mjs +95 -0
- package/plugins/code-review/index.mjs +88 -0
- package/plugins/dscode/index.mjs +17 -3
- 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/session-metrics/index.mjs +6 -2
- package/plugins/session-metrics/rate.mjs +62 -0
- package/plugins/session-metrics/view.mjs +15 -9
- package/plugins/tui-tools/doctor-cli.mjs +22 -0
- package/plugins/tui-tools/doctor.mjs +141 -0
- package/plugins/tui-tools/index.mjs +14 -6
- package/plugins/ultra/policy.mjs +12 -1
- package/plugins/worktree-subagent/worktree.mjs +50 -0
- package/presets/dscode/agent.cordis.yml +7 -2
- package/vendor/deepseek/index.js +12 -2
- package/vendor/subagent/index.js +29 -9
- package/vendor/subagent-core/LICENSE +21 -0
- package/vendor/subagent-core/index.js +3232 -0
- package/vendor/subagent-core/invariant.js +76 -0
- package/vendor/subagent-core/typert.host.d.ts +3 -0
- package/vendor/subagent-core/typert.host.js +964 -0
- package/vendor/subagent-core/typert.remote-client.d.ts +27 -0
- package/vendor/subagent-core/typert.remote-client.js +160 -0
- package/vendor/subagent-core/types/assistant-output.d.ts +48 -0
- package/vendor/subagent-core/types/assistant-output.js +73 -0
- package/vendor/subagent-core/types/catalog.d.ts +77 -0
- package/vendor/subagent-core/types/catalog.js +103 -0
- package/vendor/subagent-core/types/child-agent.d.ts +150 -0
- package/vendor/subagent-core/types/child-agent.js +207 -0
- package/vendor/subagent-core/types/client.d.ts +8 -0
- package/vendor/subagent-core/types/client.js +7 -0
- package/vendor/subagent-core/types/continuation-activation.d.ts +251 -0
- package/vendor/subagent-core/types/continuation-activation.js +663 -0
- package/vendor/subagent-core/types/continuation-messages.d.ts +62 -0
- package/vendor/subagent-core/types/continuation-messages.js +102 -0
- package/vendor/subagent-core/types/continuation.d.ts +124 -0
- package/vendor/subagent-core/types/continuation.js +455 -0
- package/vendor/subagent-core/types/control-types.d.ts +146 -0
- package/vendor/subagent-core/types/control-types.js +9 -0
- package/vendor/subagent-core/types/control.d.ts +71 -0
- package/vendor/subagent-core/types/control.js +116 -0
- package/vendor/subagent-core/types/depth.d.ts +31 -0
- package/vendor/subagent-core/types/depth.js +39 -0
- package/vendor/subagent-core/types/descriptor.d.ts +144 -0
- package/vendor/subagent-core/types/descriptor.js +193 -0
- package/vendor/subagent-core/types/error.d.ts +11 -0
- package/vendor/subagent-core/types/error.js +14 -0
- package/vendor/subagent-core/types/inbox.d.ts +43 -0
- package/vendor/subagent-core/types/inbox.js +61 -0
- package/vendor/subagent-core/types/index.d.ts +316 -0
- package/vendor/subagent-core/types/index.js +511 -0
- package/vendor/subagent-core/types/internal.d.ts +58 -0
- package/vendor/subagent-core/types/internal.js +58 -0
- package/vendor/subagent-core/types/invariant.d.ts +13 -0
- package/vendor/subagent-core/types/invariant.js +91 -0
- package/vendor/subagent-core/types/lifecycle.d.ts +113 -0
- package/vendor/subagent-core/types/lifecycle.js +178 -0
- package/vendor/subagent-core/types/list-children.d.ts +65 -0
- package/vendor/subagent-core/types/list-children.js +381 -0
- package/vendor/subagent-core/types/out-of-process.d.ts +118 -0
- package/vendor/subagent-core/types/out-of-process.js +219 -0
- package/vendor/subagent-core/types/projection-types.d.ts +74 -0
- package/vendor/subagent-core/types/projection-types.js +7 -0
- package/vendor/subagent-core/types/projection.d.ts +95 -0
- package/vendor/subagent-core/types/projection.js +151 -0
- package/vendor/subagent-core/types/run-settlement.d.ts +17 -0
- package/vendor/subagent-core/types/run-settlement.js +70 -0
- package/vendor/subagent-core/types/types.d.ts +377 -0
- package/vendor/subagent-core/types/types.js +19 -0
- package/vendor/subagent-driver/LICENSE +21 -0
- package/vendor/subagent-driver/index.js +255 -0
- package/vendor/subagent-driver/types/index.d.ts +33 -0
- package/vendor/subagent-driver/types/structured.d.ts +42 -0
- package/vendor/subagent-fork/LICENSE +21 -0
- package/vendor/subagent-fork/index.js +61 -0
- package/vendor/subagent-fork/types/index.d.ts +20 -0
- package/vendor/subagent-spawn/LICENSE +21 -0
- package/vendor/subagent-spawn/index.js +45 -0
- package/vendor/subagent-spawn/types/index.d.ts +19 -0
- package/vendor/terminal/LICENSE +21 -0
- package/vendor/terminal/index.js +1013 -0
- package/vendor/terminal/types/config.d.ts +71 -0
- package/vendor/terminal/types/index.d.ts +36 -0
- package/vendor/terminal/types/sanitize.d.ts +47 -0
- package/vendor/terminal/types/session.d.ts +87 -0
- package/vendor/tui/dscode-clipboard-image/clipboard-image.swift +56 -0
- package/vendor/tui/dscode-clipboard-image/index.mjs +70 -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 +473 -82
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** Validated configuration for the local PTY backend. */
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
/** One supported interactive shell dialect. */
|
|
4
|
+
export type ShellDialect = 'bash' | 'pwsh';
|
|
5
|
+
/** Public plugin configuration. */
|
|
6
|
+
export interface Config {
|
|
7
|
+
/** Backend registry type (default: `shell`). */
|
|
8
|
+
backendType?: string;
|
|
9
|
+
/** Interactive shell dialect (default: `bash`); selects the argv/env/startup defaults. */
|
|
10
|
+
shellDialect?: ShellDialect;
|
|
11
|
+
/** Interactive shell executable (default per dialect: `/bin/bash`, or the resolved pwsh). */
|
|
12
|
+
shellPath?: string;
|
|
13
|
+
/** Shell arguments (default per dialect: bash `--noprofile --norc -i`, pwsh `-NoLogo -NoProfile`). */
|
|
14
|
+
shellArgs?: string[];
|
|
15
|
+
/** Terminal rows. */
|
|
16
|
+
rows?: number;
|
|
17
|
+
/** Terminal columns. */
|
|
18
|
+
cols?: number;
|
|
19
|
+
/** Maximum retained logical lines. */
|
|
20
|
+
scrollbackLines?: number;
|
|
21
|
+
/** Maximum retained UTF-8 bytes. */
|
|
22
|
+
scrollbackMaxBytes?: number;
|
|
23
|
+
/** Maximum bytes returned by one read or settled viewport. */
|
|
24
|
+
maxReadBytes?: number;
|
|
25
|
+
/** Readiness polling interval. */
|
|
26
|
+
pollIntervalMs?: number;
|
|
27
|
+
/** Delay before Linux exact syscall probes. */
|
|
28
|
+
exactProbeAfterMs?: number;
|
|
29
|
+
/** Silence duration that yields `inferred_idle`. */
|
|
30
|
+
idleSilenceMs?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Extra wait beyond `idleSilenceMs`, once a prompt marker was seen, for the shell to
|
|
33
|
+
* regain the foreground before `inferred_idle` settles; at least one `pollIntervalMs`.
|
|
34
|
+
*/
|
|
35
|
+
handoffGraceMs?: number;
|
|
36
|
+
/** Absolute bound for one send and the complete pwsh startup sequence. */
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
/** Grace before teardown escalates to `SIGKILL`. */
|
|
39
|
+
disposeGraceMs?: number;
|
|
40
|
+
}
|
|
41
|
+
/** Configuration after Schemastery defaults and dialect resolution. */
|
|
42
|
+
export type ResolvedConfig = Omit<Required<Config>, 'shellDialect' | 'shellPath' | 'shellArgs'> & {
|
|
43
|
+
shellDialect: ShellDialect;
|
|
44
|
+
shellPath: string;
|
|
45
|
+
shellArgs: string[];
|
|
46
|
+
};
|
|
47
|
+
/** Bash dialect default executable. */
|
|
48
|
+
export declare const DEFAULT_BASH_SHELL = "/bin/bash";
|
|
49
|
+
/** Bash dialect default arguments (interactive, profile-free). */
|
|
50
|
+
export declare const DEFAULT_BASH_ARGS: string[];
|
|
51
|
+
/** Pwsh dialect default arguments (interactive host, profile-free). */
|
|
52
|
+
export declare const DEFAULT_PWSH_ARGS: string[];
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the effective per-dialect shell specification. Defaulting is this
|
|
55
|
+
* explicit step: an unset or empty `shellPath`/`shellArgs` selects the
|
|
56
|
+
* dialect's defaults, while a non-empty explicit value always wins.
|
|
57
|
+
* (Schemastery materializes an absent optional array as `[]`, so emptiness —
|
|
58
|
+
* not just `undefined` — means "dialect default".)
|
|
59
|
+
* @param config - Schemastery-resolved plugin configuration.
|
|
60
|
+
* @returns the fully resolved configuration.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveConfig(config: Config): ResolvedConfig;
|
|
63
|
+
/** Schemastery config exposed by the plugin. */
|
|
64
|
+
export declare const Config: z<Config>;
|
|
65
|
+
/**
|
|
66
|
+
* Assert every effective numeric config field is a positive safe integer and bounds compose.
|
|
67
|
+
* @param config - Schemastery-resolved plugin configuration.
|
|
68
|
+
* @returns Narrows the input to the fully resolved configuration.
|
|
69
|
+
*/
|
|
70
|
+
export declare function validateConfig(config: Config): asserts config is ResolvedConfig;
|
|
71
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent shell PTY backend over the subprocess terminal primitive, shared
|
|
3
|
+
* sandbox policy, bounded output, and provider-owned session cleanup.
|
|
4
|
+
* @module @deepseek-ai/dsh-terminal-bash
|
|
5
|
+
*/
|
|
6
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
7
|
+
import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh-terminal';
|
|
8
|
+
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess';
|
|
9
|
+
import { type Config, type ResolvedConfig } from './config.ts';
|
|
10
|
+
import { LocalPtySession } from './session.ts';
|
|
11
|
+
export { Config } from './config.ts';
|
|
12
|
+
export type { Config as TerminalLocalConfig } from './config.ts';
|
|
13
|
+
/** Cordis plugin name. */
|
|
14
|
+
export declare const name = "terminal-bash";
|
|
15
|
+
/** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */
|
|
16
|
+
export declare const inject: string[];
|
|
17
|
+
/**
|
|
18
|
+
* The pwsh prompt function that emits the shared OSC `133;D;` + BEL marker
|
|
19
|
+
* before every prompt, mirroring bash's PROMPT_COMMAND. `[char]27`/`[char]7`
|
|
20
|
+
* build the control bytes at runtime because raw ESC characters in submitted
|
|
21
|
+
* input are unreliable under PSReadLine.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PWSH_PROMPT_SETUP: string;
|
|
24
|
+
/** Local shell backend registered under the configured type. */
|
|
25
|
+
export declare class BashTerminalBackend implements TerminalBackend {
|
|
26
|
+
private readonly ctx;
|
|
27
|
+
private readonly config;
|
|
28
|
+
private readonly spawnTerminal;
|
|
29
|
+
private readonly createSession;
|
|
30
|
+
readonly type: string;
|
|
31
|
+
constructor(ctx: Context, config: ResolvedConfig, spawnTerminal?: (spec: SubprocessTerminalSpawnSpec) => Promise<SubprocessTerminalHandle>, createSession?: (terminal: SubprocessTerminalHandle, config: ResolvedConfig) => LocalPtySession);
|
|
32
|
+
spawn(spec: TerminalBackendSpawnSpec): Promise<LocalPtySession>;
|
|
33
|
+
}
|
|
34
|
+
/** Register the local PTY backend. */
|
|
35
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
36
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Streaming terminal-control sanitizer for the line-oriented first release. */
|
|
2
|
+
/** OSC marker emitted by the controlled bash before each prompt. */
|
|
3
|
+
export declare const PROMPT_MARKER_PREFIX = "133;D;";
|
|
4
|
+
/** Exact printable prompt emitted after the private marker. */
|
|
5
|
+
export declare const CONTROLLED_PROMPT = "dsh> ";
|
|
6
|
+
/** One sanitized chunk plus whether it contained the owned prompt marker. */
|
|
7
|
+
export interface SanitizedChunk {
|
|
8
|
+
text: string;
|
|
9
|
+
prompt: boolean;
|
|
10
|
+
/** Printable text after the latest owned marker in this chunk. */
|
|
11
|
+
promptTail?: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Remove CSI/OSC/short escape sequences while preserving split-sequence carry.
|
|
15
|
+
* Full terminal emulation is deliberately deferred; ordinary line output and
|
|
16
|
+
* the private prompt marker are the supported contract.
|
|
17
|
+
*/
|
|
18
|
+
export declare class TerminalSanitizer {
|
|
19
|
+
private readonly maxPendingBytes;
|
|
20
|
+
private pending;
|
|
21
|
+
private discardMode;
|
|
22
|
+
private discardOscEscape;
|
|
23
|
+
private trailingCarriageReturn;
|
|
24
|
+
private trackingPromptTail;
|
|
25
|
+
constructor(maxPendingBytes: number);
|
|
26
|
+
/**
|
|
27
|
+
* Consume one decoded `node-pty` data chunk.
|
|
28
|
+
* @param chunk - decoded terminal data.
|
|
29
|
+
* @returns Printable text and whether the private prompt marker completed.
|
|
30
|
+
*/
|
|
31
|
+
push(chunk: string): SanitizedChunk;
|
|
32
|
+
/**
|
|
33
|
+
* Flush a trailing printable fragment when the PTY exits.
|
|
34
|
+
* @returns Remaining printable text; incomplete escapes are discarded.
|
|
35
|
+
*/
|
|
36
|
+
flush(): string;
|
|
37
|
+
private normalizeText;
|
|
38
|
+
private enforcePendingBound;
|
|
39
|
+
private discardPrefix;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Normalize CRLF and standalone carriage returns for line-oriented rendering.
|
|
43
|
+
* @param text - sanitized terminal text.
|
|
44
|
+
* @returns Line-normalized text with BEL removed.
|
|
45
|
+
*/
|
|
46
|
+
export declare function normalizeTerminalText(text: string): string;
|
|
47
|
+
//# sourceMappingURL=sanitize.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Persistent PTY session with bounded output, readiness, and terminal-protocol replies. */
|
|
2
|
+
import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess';
|
|
3
|
+
import type { TerminalBackendSession, TerminalReadRequest, TerminalReadResult, TerminalSendOperation, TerminalSendRequest, TerminalSessionStatus, TerminalSignal, TerminalSignalResult } from '@deepseek-ai/dsh-terminal';
|
|
4
|
+
import type { ResolvedConfig } from './config.ts';
|
|
5
|
+
/** Backend session wrapping one provider-owned terminal process. */
|
|
6
|
+
export declare class LocalPtySession implements TerminalBackendSession {
|
|
7
|
+
private readonly terminal;
|
|
8
|
+
private readonly config;
|
|
9
|
+
motd: string;
|
|
10
|
+
readonly pid: number;
|
|
11
|
+
private readonly decoder;
|
|
12
|
+
/** Protocol state only; the sanitizer and bounded buffers own returned text. */
|
|
13
|
+
private readonly emulator;
|
|
14
|
+
private readonly emulatorData;
|
|
15
|
+
private readonly sanitizer;
|
|
16
|
+
private readonly scrollback;
|
|
17
|
+
private readonly outputEnded;
|
|
18
|
+
private readonly completion;
|
|
19
|
+
private statusValue;
|
|
20
|
+
private active;
|
|
21
|
+
private activeTimer;
|
|
22
|
+
private activeDeadlineTimer;
|
|
23
|
+
private activeAbort;
|
|
24
|
+
private interrupting;
|
|
25
|
+
private activeWrite;
|
|
26
|
+
private pollingReady;
|
|
27
|
+
private polling;
|
|
28
|
+
private promptSeen;
|
|
29
|
+
private promptTextSeen;
|
|
30
|
+
private promptTail;
|
|
31
|
+
private shellPgid;
|
|
32
|
+
private initializing;
|
|
33
|
+
private lastOutputAt;
|
|
34
|
+
private closing;
|
|
35
|
+
private closePromise;
|
|
36
|
+
private transportFailure;
|
|
37
|
+
private emulatorWrites;
|
|
38
|
+
private emulatorWriteDone;
|
|
39
|
+
private emulatorBuffer;
|
|
40
|
+
private emulatorWriting;
|
|
41
|
+
private responseWrites;
|
|
42
|
+
private pendingResponseWrites;
|
|
43
|
+
private emulatorClosed;
|
|
44
|
+
constructor(terminal: SubprocessTerminalHandle, config: ResolvedConfig);
|
|
45
|
+
/**
|
|
46
|
+
* Capture startup output through the same readiness contract as later sends.
|
|
47
|
+
* @param signal - optional cancellation while the shell reaches its first prompt.
|
|
48
|
+
* @returns Resolves after startup readiness; rejects on exit or readiness timeout.
|
|
49
|
+
*/
|
|
50
|
+
initialize(signal?: AbortSignal): Promise<void>;
|
|
51
|
+
startSend(request: TerminalSendRequest): TerminalSendOperation;
|
|
52
|
+
private beginSend;
|
|
53
|
+
private resetReadinessEvidence;
|
|
54
|
+
read(request: TerminalReadRequest): TerminalReadResult;
|
|
55
|
+
signal(signal: TerminalSignal): Promise<TerminalSignalResult>;
|
|
56
|
+
status(): TerminalSessionStatus;
|
|
57
|
+
close(reason: string): Promise<void>;
|
|
58
|
+
private readonly onTerminalData;
|
|
59
|
+
private readonly onTerminalEnd;
|
|
60
|
+
private readonly onTerminalError;
|
|
61
|
+
private onData;
|
|
62
|
+
private onExit;
|
|
63
|
+
private onTransportFailure;
|
|
64
|
+
private appendOutput;
|
|
65
|
+
private schedulePoll;
|
|
66
|
+
private pollReadiness;
|
|
67
|
+
/** Wait until generated replies reach the provider before another send can publish. */
|
|
68
|
+
private drainTerminalProtocol;
|
|
69
|
+
/** Sample foreground state only after protocol replies are quiet for the entire inspection. */
|
|
70
|
+
private inspectForegroundAfterProtocol;
|
|
71
|
+
private protocolStateChanged;
|
|
72
|
+
private protocolWorkPending;
|
|
73
|
+
private queueEmulatorData;
|
|
74
|
+
private pumpEmulator;
|
|
75
|
+
private finishResponseWrite;
|
|
76
|
+
private releaseSettledActive;
|
|
77
|
+
private closeEmulator;
|
|
78
|
+
private settleActive;
|
|
79
|
+
private stopPolling;
|
|
80
|
+
private stopReadinessPolling;
|
|
81
|
+
private clearActive;
|
|
82
|
+
private failActive;
|
|
83
|
+
private interrupt;
|
|
84
|
+
private interruptOnce;
|
|
85
|
+
private closeOnce;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import Darwin
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
func fail(_ message: String, _ status: Int32) -> Never {
|
|
6
|
+
FileHandle.standardError.write(Data((message + "\n").utf8))
|
|
7
|
+
exit(status)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
func pngData(from pasteboard: NSPasteboard) -> Data? {
|
|
11
|
+
if let direct = pasteboard.data(forType: .png) { return direct }
|
|
12
|
+
if let image = NSImage(pasteboard: pasteboard),
|
|
13
|
+
let tiff = image.tiffRepresentation,
|
|
14
|
+
let bitmap = NSBitmapImageRep(data: tiff) {
|
|
15
|
+
return bitmap.representation(using: .png, properties: [:])
|
|
16
|
+
}
|
|
17
|
+
return nil
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if CommandLine.arguments.count == 2 && CommandLine.arguments[1] == "--self-test" {
|
|
21
|
+
guard let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1, pixelsHigh: 1,
|
|
22
|
+
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true,
|
|
23
|
+
isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0,
|
|
24
|
+
bitsPerPixel: 0) else {
|
|
25
|
+
fail("could not create test image", 3)
|
|
26
|
+
}
|
|
27
|
+
bitmap.setColor(.red, atX: 0, y: 0)
|
|
28
|
+
guard let source = bitmap.representation(using: .png, properties: [:]) else {
|
|
29
|
+
fail("could not encode test image", 3)
|
|
30
|
+
}
|
|
31
|
+
let board = NSPasteboard(name: NSPasteboard.Name("dscode-image-self-test-\(UUID().uuidString)"))
|
|
32
|
+
board.clearContents()
|
|
33
|
+
guard board.setData(source, forType: .png), pngData(from: board) == source else {
|
|
34
|
+
fail("pasteboard image round trip failed", 3)
|
|
35
|
+
}
|
|
36
|
+
guard let tiff = bitmap.representation(using: .tiff, properties: [:]) else {
|
|
37
|
+
fail("could not create test TIFF", 3)
|
|
38
|
+
}
|
|
39
|
+
board.clearContents()
|
|
40
|
+
guard board.setData(tiff, forType: .tiff),
|
|
41
|
+
let converted = pngData(from: board),
|
|
42
|
+
converted.starts(with: [137, 80, 78, 71, 13, 10, 26, 10]) else {
|
|
43
|
+
fail("pasteboard TIFF conversion failed", 3)
|
|
44
|
+
}
|
|
45
|
+
exit(0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
guard CommandLine.arguments.count == 2 else { fail("missing output path", 3) }
|
|
49
|
+
guard let data = pngData(from: NSPasteboard.general) else { fail("clipboard has no image", 2) }
|
|
50
|
+
guard data.count <= 32 * 1024 * 1024 else { fail("clipboard image is too large", 4) }
|
|
51
|
+
|
|
52
|
+
do {
|
|
53
|
+
try data.write(to: URL(fileURLWithPath: CommandLine.arguments[1]), options: .atomic)
|
|
54
|
+
} catch {
|
|
55
|
+
fail("could not save clipboard image", 3)
|
|
56
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { access, chmod, mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { rmSync } from 'node:fs';
|
|
5
|
+
import { constants } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
|
|
11
|
+
const execFile = promisify(execFileCallback);
|
|
12
|
+
const source = fileURLToPath(new URL('./clipboard-image.swift', import.meta.url));
|
|
13
|
+
const cache = join(tmpdir(), `dscode-clipboard-helper-${process.getuid?.() ?? 'user'}`);
|
|
14
|
+
const clipboardDirs = new Set();
|
|
15
|
+
let compiler;
|
|
16
|
+
let cleanupRegistered = false;
|
|
17
|
+
|
|
18
|
+
async function helperBinary() {
|
|
19
|
+
if (process.platform !== 'darwin') throw Error('Clipboard image paste requires macOS');
|
|
20
|
+
if (compiler) return compiler;
|
|
21
|
+
compiler = (async () => {
|
|
22
|
+
const hash = createHash('sha256').update(await readFile(source)).update(process.arch).digest('hex').slice(0, 16);
|
|
23
|
+
const binary = join(cache, `clipboard-image-${hash}`);
|
|
24
|
+
try { await access(binary, constants.X_OK); return binary; } catch {}
|
|
25
|
+
await mkdir(cache, { recursive: true, mode: 0o700 });
|
|
26
|
+
const moduleCache = join(cache, 'swift-modules');
|
|
27
|
+
await mkdir(moduleCache, { recursive: true, mode: 0o700 });
|
|
28
|
+
const temporary = `${binary}.${process.pid}.${randomUUID()}.tmp`;
|
|
29
|
+
try {
|
|
30
|
+
await execFile('swiftc', [source, '-o', temporary], {
|
|
31
|
+
timeout: 120_000,
|
|
32
|
+
maxBuffer: 64 * 1024,
|
|
33
|
+
env: { ...process.env, CLANG_MODULE_CACHE_PATH: moduleCache, SWIFT_MODULE_CACHE_PATH: moduleCache },
|
|
34
|
+
});
|
|
35
|
+
await chmod(temporary, 0o700);
|
|
36
|
+
await rename(temporary, binary);
|
|
37
|
+
} catch {
|
|
38
|
+
await rm(temporary, { force: true });
|
|
39
|
+
throw Error('Clipboard image helper unavailable; install Xcode Command Line Tools');
|
|
40
|
+
}
|
|
41
|
+
return binary;
|
|
42
|
+
})();
|
|
43
|
+
try { return await compiler; } catch (error) { compiler = undefined; throw error; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function readClipboardImage() {
|
|
47
|
+
const binary = await helperBinary();
|
|
48
|
+
const directory = await mkdtemp(join(tmpdir(), 'dscode-clipboard-image-'));
|
|
49
|
+
const path = join(directory, 'image.png');
|
|
50
|
+
try {
|
|
51
|
+
await execFile(binary, [path], { timeout: 10_000, maxBuffer: 1024 });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
await rm(directory, { recursive: true, force: true });
|
|
54
|
+
if (error?.code === 2) throw Error('Clipboard has no image');
|
|
55
|
+
if (error?.code === 4) throw Error('Clipboard image is too large');
|
|
56
|
+
throw Error('Could not read clipboard image');
|
|
57
|
+
}
|
|
58
|
+
clipboardDirs.add(directory);
|
|
59
|
+
if (!cleanupRegistered) {
|
|
60
|
+
cleanupRegistered = true;
|
|
61
|
+
process.once('exit', () => { for (const dir of clipboardDirs) rmSync(dir, { recursive: true, force: true }); });
|
|
62
|
+
}
|
|
63
|
+
return path;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Exercise the compiled helper against a private named pasteboard. */
|
|
67
|
+
export async function verifyClipboardImageHelper() {
|
|
68
|
+
const binary = await helperBinary();
|
|
69
|
+
await execFile(binary, ['--self-test'], { timeout: 10_000, maxBuffer: 1024 });
|
|
70
|
+
}
|
|
@@ -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
|
+
}
|