@wenathlan/devthink 1.1.1
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/CHANGELOG.md +44 -0
- package/LICENSE +674 -0
- package/README.md +170 -0
- package/build.ts +41 -0
- package/check-secrets.ts +52 -0
- package/compatibility.ts +37 -0
- package/config.ts +240 -0
- package/devthink.ts +360 -0
- package/identity.ts +136 -0
- package/ids.ts +22 -0
- package/memory.ts +95 -0
- package/modes.ts +43 -0
- package/package.json +67 -0
- package/plugin.ts +68 -0
- package/providers.ts +179 -0
- package/server.ts +227 -0
- package/session.ts +155 -0
- package/storage.ts +51 -0
- package/stream.ts +130 -0
- package/sync.ts +27 -0
- package/ui.ts +122 -0
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# DevThink
|
|
2
|
+
|
|
3
|
+
**DevThink** is a provider-neutral AI development workbench with a terminal CLI, an embedded local gateway and a static web workbench. The public source lives in the [DevThink repository](https://github.com/wenathlan/devthink); **DevThink** remains the product name.
|
|
4
|
+
|
|
5
|
+
> **Security boundary.** DevThink uses official provider APIs and credentials supplied explicitly by the user. It does not capture browser cookies, store browser sessions, solve CAPTCHAs, automate browser authentication or bypass anti-bot controls.
|
|
6
|
+
|
|
7
|
+
| Surface | Capability |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| CLI | Streaming chat, provider and model discovery, 20 modes, local sessions, memory, pairing and credential-free snapshot export. |
|
|
10
|
+
| Local gateway | Loopback HTTP and SSE service for health, provider models, workspaces, sessions, tabs and normalized chat streaming. |
|
|
11
|
+
| Provider layer | OpenAI-compatible, Anthropic Messages and Google Generate Content protocols with user-configured endpoints. |
|
|
12
|
+
| Local state | Atomic JSON compatibility records plus SQLite mirrors under `~/.config/devthink/`. |
|
|
13
|
+
| Workbench | Static React application under `web/`, with addressable workspace/session/tab/section routes and local one-time pairing. |
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
DevThink uses Bun for development and self-contained binary builds. The CLI keeps non-secret preferences in `devthink.json` and user-provided official credentials in a separate `auth.json` file.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install --global @wenathlan/devthink
|
|
21
|
+
devthink init
|
|
22
|
+
devthink --help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The portable static workbench is also published as an npm package. It is a browser artifact rather than a server, so it must be served with any static-file host after installation.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install @wenathlan/devthink.web
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bun install
|
|
33
|
+
bun run devthink.ts init
|
|
34
|
+
bun run devthink.ts --help
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
devthink auth login zai --token "$ZAI_API_KEY" --kind api-key
|
|
39
|
+
devthink config activeModel glm-5
|
|
40
|
+
devthink chat --prompt "Summarize this repository in three points."
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
On Linux, the default local paths are:
|
|
44
|
+
|
|
45
|
+
| File | Purpose |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `~/.config/devthink/devthink.json` | Non-secret preferences and provider endpoint configuration. |
|
|
48
|
+
| `~/.config/devthink/auth.json` | Explicit official API keys, bearer values or OAuth records; file mode is `0600`. |
|
|
49
|
+
| `~/.config/devthink/devthink.sqlite` | Local workspaces, sessions, tabs and messages. |
|
|
50
|
+
|
|
51
|
+
The examples below are deliberately inert and must be replaced locally by an official credential supplied by the user.
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"version": 1,
|
|
56
|
+
"providers": {
|
|
57
|
+
"zai": {
|
|
58
|
+
"kind": "api-key",
|
|
59
|
+
"value": "example_api_key_not_a_secret",
|
|
60
|
+
"updatedAt": "2026-08-19T00:00:00.000Z"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Provider endpoints
|
|
67
|
+
|
|
68
|
+
DevThink does not use a separate proxy module. A provider-specific endpoint belongs in `providers.<id>.baseUrl` within the user-owned `devthink.json` file.
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"activeProvider": "zai",
|
|
73
|
+
"activeModel": "glm-5",
|
|
74
|
+
"gateway": { "mode": "embedded", "stream": true },
|
|
75
|
+
"providers": {
|
|
76
|
+
"zai": {
|
|
77
|
+
"baseUrl": "https://api.z.ai/api/paas/v4",
|
|
78
|
+
"transport": "openai-compatible"
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"web": { "enabled": true }
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Provider family | Protocol |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| OpenAI, Z.AI, Qwen, OpenRouter, DeepSeek, Groq, Mistral, xAI, Ollama and Xiaomi MiMo | OpenAI-compatible streaming. |
|
|
88
|
+
| Anthropic | Messages streaming. |
|
|
89
|
+
| Google Gemini | Generate Content streaming. |
|
|
90
|
+
|
|
91
|
+
## CLI commands
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
devthink init
|
|
95
|
+
devthink chat --provider <id> --model <id> --prompt <text>
|
|
96
|
+
devthink providers
|
|
97
|
+
devthink models --provider <id>
|
|
98
|
+
devthink modes
|
|
99
|
+
devthink config [key] [value]
|
|
100
|
+
devthink auth login <provider> --token <credential> [--kind api-key|bearer|oauth]
|
|
101
|
+
devthink auth status
|
|
102
|
+
devthink auth clear <provider>
|
|
103
|
+
devthink auth migrate
|
|
104
|
+
devthink identity
|
|
105
|
+
devthink pair create [--web-url <url>] [--gateway <url>]
|
|
106
|
+
devthink pair revoke
|
|
107
|
+
devthink sync status
|
|
108
|
+
devthink sync export
|
|
109
|
+
devthink gateway status
|
|
110
|
+
devthink sessions list
|
|
111
|
+
devthink sessions export <id> --format markdown
|
|
112
|
+
devthink serve
|
|
113
|
+
devthink interactive
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`devthink serve` listens only on the local loopback interface. The web workbench must pair through a short-lived, one-time code before a browser origin can access local records. Provider credentials remain local and are never sent to GitHub Pages.
|
|
117
|
+
|
|
118
|
+
## Workbench and GitHub Pages
|
|
119
|
+
|
|
120
|
+
The workbench lives directly under [`web/`](./web), with no `web/src` or `web/client/src` nesting. Its route preserves the same stable identifiers used by the CLI and local gateway.
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
/w/:workspaceId/s/:sessionId/t/:tabId/:sectionId
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
cd web
|
|
128
|
+
pnpm install --frozen-lockfile
|
|
129
|
+
pnpm check
|
|
130
|
+
pnpm build
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The GitHub Pages build is static. It never embeds a provider credential or remote database. To interact with the CLI, users run `devthink serve` locally and pair the browser explicitly.
|
|
134
|
+
|
|
135
|
+
## TypeScript documentation
|
|
136
|
+
|
|
137
|
+
The public `docs/` directory contains compact `category.ts.md` collections. Legacy non-TypeScript executable documentation was rewritten as typed, JSDoc-described TypeScript contracts. The root code integrates the safe parts of those contracts: bounded retry behavior, error redaction, exact allowed-origin matching, secure remote-endpoint validation and Pages base-path normalization.
|
|
138
|
+
|
|
139
|
+
| Collection | Focus |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| `auth.ts.md` | Official credential shapes and inert examples. |
|
|
142
|
+
| `providers.ts.md` | Protocol adapters, retry and error redaction. |
|
|
143
|
+
| `gateway.ts.md` | Local gateway request and origin contract. |
|
|
144
|
+
| `storage.ts.md` | Credential-free local persistence and sync boundary. |
|
|
145
|
+
| `web.ts.md` | Static base-path and addressable route contract. |
|
|
146
|
+
|
|
147
|
+
## Validation and release
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
bun run check
|
|
151
|
+
bun run build:binary
|
|
152
|
+
|
|
153
|
+
cd web
|
|
154
|
+
pnpm check
|
|
155
|
+
pnpm build
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The release workflow builds Bun binaries for Linux x64, macOS arm64 and Windows x64. The Pages workflow validates and publishes the static workbench with a repository-scoped base path and SPA fallback.
|
|
159
|
+
|
|
160
|
+
## References
|
|
161
|
+
|
|
162
|
+
[1] [Bun documentation](https://bun.sh/docs)
|
|
163
|
+
|
|
164
|
+
[2] [Anthropic streaming documentation](https://platform.claude.com/docs/en/build-with-claude/streaming)
|
|
165
|
+
|
|
166
|
+
[3] [Google Gemini Generate Content](https://ai.google.dev/gemini-api/docs/generate-content)
|
|
167
|
+
|
|
168
|
+
[4] [Z.AI API documentation](https://docs.z.ai/api-reference/introduction)
|
|
169
|
+
|
|
170
|
+
[5] [Xiaomi MiMo quick start](https://mimo.mi.com/docs/quick-start/first-api-call)
|
package/build.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { arch, platform } from "node:process";
|
|
3
|
+
|
|
4
|
+
type BunRuntime = {
|
|
5
|
+
build: (options: Record<string, unknown>) => Promise<{ success: boolean; logs: unknown[] }>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function bunRuntime(): BunRuntime {
|
|
9
|
+
const runtime = (globalThis as { Bun?: BunRuntime }).Bun;
|
|
10
|
+
if (!runtime) throw new Error("Bun is required to compile DevThink binaries.");
|
|
11
|
+
return runtime;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function flag(name: string): string | undefined {
|
|
15
|
+
const index = process.argv.indexOf(name);
|
|
16
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function packageVersion(): string {
|
|
20
|
+
const packageData = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) as { version?: string };
|
|
21
|
+
return packageData.version || "0.0.0";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function localCompileTarget(): string {
|
|
25
|
+
const os = platform === "darwin" ? "darwin" : platform === "win32" ? "windows" : "linux";
|
|
26
|
+
const cpu = arch === "arm64" ? "arm64" : "x64";
|
|
27
|
+
return `bun-${os}-${cpu}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function buildBinary(): Promise<void> {
|
|
31
|
+
const runtime = bunRuntime();
|
|
32
|
+
const target = flag("--target") || process.env.BUN_TARGET || localCompileTarget();
|
|
33
|
+
const outfile = flag("--outfile") || process.env.DEVTHINK_OUTFILE || `release/devthink-${target}`;
|
|
34
|
+
const result = await runtime.build({ entrypoints: ["./devthink.ts"], target, define: { "process.env.DEVTHINK_VERSION": JSON.stringify(packageVersion()) }, compile: { target, outfile }, minify: true, sourcemap: "none" });
|
|
35
|
+
if (!result.success) throw new Error(`Bun compilation failed: ${JSON.stringify(result.logs)}`);
|
|
36
|
+
console.log(`Built ${outfile}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const directEntry = process.argv[1]?.endsWith("build.ts") || process.argv[1]?.endsWith("build");
|
|
40
|
+
const bunEntry = (import.meta as ImportMeta & { main?: boolean }).main === true;
|
|
41
|
+
if (directEntry || bunEntry) buildBinary().catch((error: unknown) => { console.error(error instanceof Error ? error.message : "Build failed."); process.exitCode = 1; });
|
package/check-secrets.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const patterns = [
|
|
5
|
+
/sk-[A-Za-z0-9_-]{20,}/g,
|
|
6
|
+
/AIza[A-Za-z0-9_-]{20,}/g,
|
|
7
|
+
/xox[baprs]-[A-Za-z0-9-]{16,}/g,
|
|
8
|
+
/gh[pousr]_[A-Za-z0-9_]{20,}/g,
|
|
9
|
+
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g,
|
|
10
|
+
/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/g,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function productFiles(root: string): string[] {
|
|
14
|
+
const files: string[] = [];
|
|
15
|
+
const visit = (path: string) => {
|
|
16
|
+
if (!existsSync(path)) return;
|
|
17
|
+
const stat = statSync(path);
|
|
18
|
+
if (stat.isFile()) return files.push(path);
|
|
19
|
+
for (const entry of readdirSync(path)) {
|
|
20
|
+
if (entry === "node_modules" || entry === ".git" || entry === "docs" || entry === "dist" || entry === "release") continue;
|
|
21
|
+
visit(join(path, entry));
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
visit(join(root, "devthink.ts"));
|
|
25
|
+
for (const name of ["config.ts", "providers.ts", "stream.ts", "session.ts", "memory.ts", "modes.ts", "plugin.ts", "server.ts", "ui.ts", "build.ts", "check-secrets.ts", "package.json", "tsconfig.json", "README.md", "CHANGELOG.md", "SECURITY.md"]) visit(join(root, name));
|
|
26
|
+
visit(join(root, ".github"));
|
|
27
|
+
visit(join(root, "tests"));
|
|
28
|
+
return files.filter((path) => existsSync(path));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function scanSecrets(root = process.cwd()): string[] {
|
|
32
|
+
const findings: string[] = [];
|
|
33
|
+
for (const file of productFiles(root)) {
|
|
34
|
+
const text = readFileSync(file, "utf8");
|
|
35
|
+
for (const pattern of patterns) {
|
|
36
|
+
if (pattern.test(text)) findings.push(`${file}: ${pattern}`);
|
|
37
|
+
pattern.lastIndex = 0;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return findings;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const directEntry = process.argv[1]?.endsWith("check-secrets.ts") || process.argv[1]?.endsWith("check-secrets");
|
|
44
|
+
const bunEntry = (import.meta as ImportMeta & { main?: boolean }).main === true;
|
|
45
|
+
if (directEntry || bunEntry) {
|
|
46
|
+
const findings = scanSecrets();
|
|
47
|
+
if (findings.length) {
|
|
48
|
+
console.error("Potential embedded credentials detected:");
|
|
49
|
+
for (const finding of findings) console.error(finding);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
} else console.log("Secret scan passed.");
|
|
52
|
+
}
|
package/compatibility.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared TypeScript compatibility primitives derived from the converted docs logic.
|
|
3
|
+
* These helpers intentionally exclude browser automation, CAPTCHA handling and credential capture.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Identifies the public wire formats normalized by DevThink. */
|
|
7
|
+
export type ProviderProtocol = "openai" | "anthropic" | "google";
|
|
8
|
+
|
|
9
|
+
/** Returns bounded exponential retry timing for a transient request failure. */
|
|
10
|
+
export function retryDelay(attempt: number, baseMs = 250, maximumMs = 4_000): number {
|
|
11
|
+
return Math.min(maximumMs, baseMs * 2 ** Math.max(0, attempt));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Removes a bearer value from an error message before it reaches a terminal or browser surface. */
|
|
15
|
+
export function redactProviderError(message: string): string {
|
|
16
|
+
return message.replace(/bearer\s+[^\s]+/gi, "bearer [redacted]").slice(0, 500);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Accepts a browser origin only when it exactly matches an explicit local configuration entry. */
|
|
20
|
+
export function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[] | undefined): boolean {
|
|
21
|
+
return Boolean(origin && allowedOrigins?.includes(origin));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Verifies that a user-operated synchronization endpoint uses HTTPS. */
|
|
25
|
+
export function isSecureRemoteEndpoint(value: string | undefined): boolean {
|
|
26
|
+
try {
|
|
27
|
+
return Boolean(value && new URL(value).protocol === "https:");
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Normalizes a static-site base path for repository-scoped GitHub Pages deployments. */
|
|
34
|
+
export function normalizeBasePath(value: string | undefined, repository: string): string {
|
|
35
|
+
const candidate = value?.trim() || `/${repository}/`;
|
|
36
|
+
return `/${candidate.replace(/^\/+|\/+$/g, "")}/`;
|
|
37
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type DevThinkConfig = {
|
|
6
|
+
activeProvider?: string;
|
|
7
|
+
activeModel?: string;
|
|
8
|
+
provider?: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
mode?: string;
|
|
11
|
+
baseUrl?: string;
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
anthropicApiKey?: string;
|
|
14
|
+
googleApiKey?: string;
|
|
15
|
+
zaiApiKey?: string;
|
|
16
|
+
temperature?: number;
|
|
17
|
+
maxTokens?: number;
|
|
18
|
+
projectMemory?: boolean;
|
|
19
|
+
externalMemory?: boolean;
|
|
20
|
+
fallbackProviders?: string[];
|
|
21
|
+
providers?: Record<string, {
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
apiKey?: string;
|
|
24
|
+
model?: string;
|
|
25
|
+
transport?: "official" | "openai-compatible" | "local-gateway";
|
|
26
|
+
auth?: { kind: "api-key" | "bearer" | "oauth"; value?: string; expiresAt?: number };
|
|
27
|
+
}>;
|
|
28
|
+
gateway?: { mode?: "embedded"; stream?: boolean; host?: string };
|
|
29
|
+
web?: { enabled?: boolean; pagesUrl?: string; gatewayUrl?: string; allowedOrigins?: string[]; remoteSync?: { enabled?: boolean; endpoint?: string } };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type AuthKind = "api-key" | "bearer" | "oauth";
|
|
33
|
+
export type AuthCredential = { kind: AuthKind; value?: string; accessToken?: string; refreshToken?: string; expiresAt?: number; resourceUrl?: string; updatedAt: string };
|
|
34
|
+
export type DevThinkAuth = { version: 1; providers: Record<string, AuthCredential> };
|
|
35
|
+
|
|
36
|
+
export type DevThinkPaths = {
|
|
37
|
+
home: string;
|
|
38
|
+
config: string;
|
|
39
|
+
auth: string;
|
|
40
|
+
identity: string;
|
|
41
|
+
pairings: string;
|
|
42
|
+
legacyConfig: string;
|
|
43
|
+
sessions: string;
|
|
44
|
+
workspaces: string;
|
|
45
|
+
database: string;
|
|
46
|
+
memory: string;
|
|
47
|
+
logs: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function resolveHome(): string {
|
|
51
|
+
const override = process.env.DEVTHINK_HOME?.trim();
|
|
52
|
+
if (override) return override;
|
|
53
|
+
if (platform() === "win32") return join(process.env.APPDATA || homedir(), "devthink");
|
|
54
|
+
if (platform() === "darwin") return join(homedir(), "Library", "Application Support", "devthink");
|
|
55
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "devthink");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolvePaths(root = resolveHome()): DevThinkPaths {
|
|
59
|
+
return { home: root, config: join(root, "devthink.json"), auth: join(root, "auth.json"), identity: join(root, "identity.json"), pairings: join(root, "pairings.json"), legacyConfig: join(root, "config.json"), sessions: join(root, "sessions"), workspaces: join(root, "workspaces"), database: join(root, "devthink.sqlite"), memory: join(root, "memory"), logs: join(root, "logs") };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ensurePaths(paths = resolvePaths()): DevThinkPaths {
|
|
63
|
+
for (const path of [paths.home, paths.sessions, paths.workspaces, paths.memory, paths.logs]) mkdirSync(path, { recursive: true });
|
|
64
|
+
return paths;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
68
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function writeJson(path: string, value: unknown, mode?: number): void {
|
|
72
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
73
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, mode ? { encoding: "utf8", mode } : "utf8");
|
|
74
|
+
renameSync(temporary, path);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readConfig(paths = resolvePaths()): DevThinkConfig {
|
|
78
|
+
try {
|
|
79
|
+
const candidate = existsSync(paths.config) ? paths.config : paths.legacyConfig;
|
|
80
|
+
if (!existsSync(candidate)) return {};
|
|
81
|
+
const parsed: unknown = JSON.parse(readFileSync(candidate, "utf8"));
|
|
82
|
+
if (!isRecord(parsed)) return {};
|
|
83
|
+
const config = parsed as DevThinkConfig;
|
|
84
|
+
return { ...config, activeProvider: config.activeProvider || config.provider, activeModel: config.activeModel || config.model };
|
|
85
|
+
} catch {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function saveConfig(config: DevThinkConfig, paths = resolvePaths()): void {
|
|
91
|
+
ensurePaths(paths);
|
|
92
|
+
writeJson(paths.config, { ...config, activeProvider: config.activeProvider || config.provider, activeModel: config.activeModel || config.model, gateway: { mode: "embedded" as const, stream: true, ...config.gateway } });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const forbiddenAuthFields = new Set(["cookie", "cookies", "fingerprint", "useragent", "sessiontoken", "captcha", "browserprofile"]);
|
|
96
|
+
|
|
97
|
+
function validCredential(value: unknown): AuthCredential | undefined {
|
|
98
|
+
if (!isRecord(value) || Object.keys(value).some((key) => forbiddenAuthFields.has(key.toLowerCase()))) return undefined;
|
|
99
|
+
const kind = String(value.kind) as AuthKind;
|
|
100
|
+
if (!["api-key", "bearer", "oauth"].includes(kind)) return undefined;
|
|
101
|
+
const token = typeof value.value === "string" ? value.value : undefined;
|
|
102
|
+
const accessToken = typeof value.accessToken === "string" ? value.accessToken : undefined;
|
|
103
|
+
const refreshToken = typeof value.refreshToken === "string" ? value.refreshToken : undefined;
|
|
104
|
+
if ((kind === "oauth" && !accessToken) || (kind !== "oauth" && !token)) return undefined;
|
|
105
|
+
return { kind, ...(token ? { value: token } : {}), ...(accessToken ? { accessToken } : {}), ...(refreshToken ? { refreshToken } : {}), ...(typeof value.expiresAt === "number" ? { expiresAt: value.expiresAt } : {}), ...(typeof value.resourceUrl === "string" ? { resourceUrl: value.resourceUrl } : {}), updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : new Date().toISOString() };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readAuth(paths = resolvePaths()): DevThinkAuth {
|
|
109
|
+
try {
|
|
110
|
+
if (!existsSync(paths.auth)) return { version: 1, providers: {} };
|
|
111
|
+
const parsed: unknown = JSON.parse(readFileSync(paths.auth, "utf8"));
|
|
112
|
+
if (!isRecord(parsed) || !isRecord(parsed.providers)) return { version: 1, providers: {} };
|
|
113
|
+
const providers: Record<string, AuthCredential> = {};
|
|
114
|
+
for (const [provider, raw] of Object.entries(parsed.providers)) {
|
|
115
|
+
const credential = validCredential(raw);
|
|
116
|
+
if (credential) providers[provider.toLowerCase()] = credential;
|
|
117
|
+
}
|
|
118
|
+
return { version: 1, providers };
|
|
119
|
+
} catch {
|
|
120
|
+
return { version: 1, providers: {} };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function saveAuth(auth: DevThinkAuth, paths = resolvePaths()): void {
|
|
125
|
+
ensurePaths(paths);
|
|
126
|
+
const providers: Record<string, AuthCredential> = {};
|
|
127
|
+
for (const [provider, raw] of Object.entries(auth.providers)) {
|
|
128
|
+
const credential = validCredential(raw);
|
|
129
|
+
if (credential) providers[provider.toLowerCase()] = credential;
|
|
130
|
+
}
|
|
131
|
+
writeJson(paths.auth, { version: 1, providers }, 0o600);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function setAuthCredential(provider: string, credential: Omit<AuthCredential, "updatedAt">, paths = resolvePaths()): DevThinkAuth {
|
|
135
|
+
const next = { ...credential, updatedAt: new Date().toISOString() };
|
|
136
|
+
if (!validCredential(next)) throw new Error("Credential must use an official api-key, bearer, or OAuth shape without browser-session fields.");
|
|
137
|
+
const current = readAuth(paths);
|
|
138
|
+
const auth = { version: 1 as const, providers: { ...current.providers, [provider.toLowerCase()]: next } };
|
|
139
|
+
saveAuth(auth, paths);
|
|
140
|
+
return auth;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function clearAuthCredential(provider: string, paths = resolvePaths()): DevThinkAuth {
|
|
144
|
+
const current = readAuth(paths);
|
|
145
|
+
const providers = { ...current.providers };
|
|
146
|
+
delete providers[provider.toLowerCase()];
|
|
147
|
+
const auth = { version: 1 as const, providers };
|
|
148
|
+
saveAuth(auth, paths);
|
|
149
|
+
return auth;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function migrateLegacyCredentials(config: DevThinkConfig, paths = resolvePaths()): { config: DevThinkConfig; migrated: string[] } {
|
|
153
|
+
const current = readAuth(paths);
|
|
154
|
+
const providers = { ...current.providers };
|
|
155
|
+
const nextConfig: DevThinkConfig = { ...config, providers: { ...config.providers } };
|
|
156
|
+
const migrated: string[] = [];
|
|
157
|
+
for (const [provider, settings] of Object.entries(config.providers || {})) {
|
|
158
|
+
const legacy = settings.auth?.value || settings.apiKey;
|
|
159
|
+
if (!legacy || providers[provider]) continue;
|
|
160
|
+
const kind = settings.auth?.kind || "api-key";
|
|
161
|
+
providers[provider] = kind === "oauth" ? { kind, accessToken: legacy, expiresAt: settings.auth?.expiresAt, updatedAt: new Date().toISOString() } : { kind, value: legacy, expiresAt: settings.auth?.expiresAt, updatedAt: new Date().toISOString() };
|
|
162
|
+
const { auth: _auth, apiKey: _apiKey, ...rest } = settings;
|
|
163
|
+
nextConfig.providers![provider] = rest;
|
|
164
|
+
migrated.push(provider);
|
|
165
|
+
}
|
|
166
|
+
for (const [provider, property] of Object.entries({ openai: "apiKey", anthropic: "anthropicApiKey", google: "googleApiKey", zai: "zaiApiKey" })) {
|
|
167
|
+
const value = nextConfig[property as keyof DevThinkConfig];
|
|
168
|
+
if (typeof value === "string" && value && !providers[provider]) {
|
|
169
|
+
providers[provider] = { kind: "api-key", value, updatedAt: new Date().toISOString() };
|
|
170
|
+
delete (nextConfig as Record<string, unknown>)[property];
|
|
171
|
+
migrated.push(provider);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
saveAuth({ version: 1, providers }, paths);
|
|
175
|
+
saveConfig(nextConfig, paths);
|
|
176
|
+
return { config: nextConfig, migrated };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function getConfigValue(config: DevThinkConfig, key: string): unknown {
|
|
180
|
+
return (config as Record<string, unknown>)[key];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function setConfigValue(config: DevThinkConfig, key: string, value: unknown): DevThinkConfig {
|
|
184
|
+
return { ...config, [key]: value };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function resolveCredential(provider: string, config: DevThinkConfig, paths = resolvePaths()): string | undefined {
|
|
188
|
+
const auth = readAuth(paths).providers[provider.toLowerCase()];
|
|
189
|
+
if (auth) {
|
|
190
|
+
if (auth.kind === "oauth" && auth.expiresAt && auth.expiresAt <= Date.now()) throw new Error(`Official OAuth credential for ${provider} has expired. Reauthenticate with the provider-owned flow.`);
|
|
191
|
+
return auth.kind === "oauth" ? auth.accessToken : auth.value;
|
|
192
|
+
}
|
|
193
|
+
const envNames: Record<string, string[]> = { openai: ["OPENAI_API_KEY"], anthropic: ["ANTHROPIC_API_KEY"], google: ["GOOGLE_API_KEY", "GEMINI_API_KEY"], zai: ["ZAI_API_KEY"], qwen: ["QWEN_API_KEY"], openrouter: ["OPENROUTER_API_KEY"], deepseek: ["DEEPSEEK_API_KEY"], groq: ["GROQ_API_KEY"], mistral: ["MISTRAL_API_KEY"], xai: ["XAI_API_KEY"], ollama: ["OLLAMA_API_KEY"], mimo: ["MIMO_API_KEY"] };
|
|
194
|
+
for (const name of envNames[provider.toLowerCase()] || []) {
|
|
195
|
+
const value = process.env[name]?.trim();
|
|
196
|
+
if (value) return value;
|
|
197
|
+
}
|
|
198
|
+
const property = `${provider}ApiKey` as keyof DevThinkConfig;
|
|
199
|
+
const nested = config.providers?.[provider];
|
|
200
|
+
return nested?.auth?.value || nested?.apiKey || config[property] || (provider === (config.activeProvider || config.provider) ? config.apiKey : undefined) || process.env.DEVTHINK_API_KEY?.trim();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function redactValue(value: unknown): unknown {
|
|
204
|
+
if (typeof value !== "string") return value;
|
|
205
|
+
if (value.length < 8) return "[redacted]";
|
|
206
|
+
return `${value.slice(0, 3)}...${value.slice(-3)}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function redactAuth(auth: DevThinkAuth): Record<string, unknown> {
|
|
210
|
+
return { version: auth.version, providers: Object.fromEntries(Object.entries(auth.providers).map(([provider, credential]) => [provider, { kind: credential.kind, ...(credential.value ? { value: redactValue(credential.value) } : {}), ...(credential.accessToken ? { accessToken: redactValue(credential.accessToken) } : {}), ...(credential.refreshToken ? { refreshToken: redactValue(credential.refreshToken) } : {}), ...(credential.expiresAt ? { expiresAt: credential.expiresAt } : {}), ...(credential.resourceUrl ? { resourceUrl: credential.resourceUrl } : {}), updatedAt: credential.updatedAt }])) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function redactConfig(config: DevThinkConfig): Record<string, unknown> {
|
|
214
|
+
const sensitive = new Set(["apiKey", "anthropicApiKey", "googleApiKey", "zaiApiKey"]);
|
|
215
|
+
return Object.fromEntries(Object.entries(config).map(([key, value]) => {
|
|
216
|
+
if (key === "providers" && value && typeof value === "object") {
|
|
217
|
+
const providers = Object.fromEntries(Object.entries(value as Record<string, Record<string, unknown>>).map(([id, settings]) => [id, Object.fromEntries(Object.entries(settings).map(([setting, item]) => {
|
|
218
|
+
if (setting === "apiKey") return [setting, redactValue(item)];
|
|
219
|
+
if (setting === "auth" && item && typeof item === "object") {
|
|
220
|
+
const auth = item as Record<string, unknown>;
|
|
221
|
+
return [setting, { ...auth, value: redactValue(auth.value) }];
|
|
222
|
+
}
|
|
223
|
+
return [setting, item];
|
|
224
|
+
}))]));
|
|
225
|
+
return [key, providers];
|
|
226
|
+
}
|
|
227
|
+
return [key, sensitive.has(key) || key.toLowerCase().endsWith("apikey") ? redactValue(value) : value];
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function parseConfigValue(raw: string): unknown {
|
|
232
|
+
if (raw === "true") return true;
|
|
233
|
+
if (raw === "false") return false;
|
|
234
|
+
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
|
|
235
|
+
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
|
|
236
|
+
if (raw.startsWith("[") || raw.startsWith("{")) {
|
|
237
|
+
try { return JSON.parse(raw); } catch { return raw; }
|
|
238
|
+
}
|
|
239
|
+
return raw;
|
|
240
|
+
}
|