planche 0.8.3 → 0.8.5
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/app/assets/1E6FYbv2.js +1 -0
- package/app/assets/{postgresql-erqhE-5Q.js → 5E2RYjEs.js} +1 -1
- package/app/assets/B-Mrqvrb.css +2 -0
- package/app/assets/B3LyCSVJ.js +1 -0
- package/app/assets/{DownloadToCSVDialog-Bc0PDZHm.js → B3uI9Ppb.js} +2 -2
- package/app/assets/B5OocgEJ.js +1 -0
- package/app/assets/B8xQ2o-X.js +1 -0
- package/app/assets/BBSxV90W.js +1 -0
- package/app/assets/BGOnv2_O.js +1 -0
- package/app/assets/BIjF9ulh.js +1 -0
- package/app/assets/BM5G_YA7.js +1 -0
- package/app/assets/{ja-C7vEFXh8.js → BXX-BtBa.js} +1 -1
- package/app/assets/{tunneling-B7lL9I14.js → BdPEUnLq.js} +1 -1
- package/app/assets/Bpg8LzYe.js +2 -0
- package/app/assets/BunrDTHn.js +1 -0
- package/app/assets/{ko-1oXH_x0o.js → BziT-o8V.js} +1 -1
- package/app/assets/{dbms-BwnatOMe.js → C1RY7wUM.js} +13 -13
- package/app/assets/C3bMAsUp.js +1 -0
- package/app/assets/C4il2fft.js +1 -0
- package/app/assets/CChaTOPr.js +1 -0
- package/app/assets/CKOS5Y-e.js +1 -0
- package/app/assets/{storage-worker-DEIYmjYK.js → CM41Cs80.js} +3 -3
- package/app/assets/COIa4uoZ.js +1 -0
- package/app/assets/{de-EBRzfz-s.js → CWY0kZPK.js} +1 -1
- package/app/assets/CZQ1r4zz.js +41 -0
- package/app/assets/{error-hints-BsG7qe62.js → CfsL8_WC.js} +3 -3
- package/app/assets/CkgLFvM5.js +1 -0
- package/app/assets/ClyAuDY9.js +2 -0
- package/app/assets/CmJ3siBN.js +1 -0
- package/app/assets/Cw_LYT6k.js +1 -0
- package/app/assets/Cx_chT_j.js +1 -0
- package/app/assets/D-RWduy5.js +1 -0
- package/app/assets/{index-Tw24-4of.js → D7Ze5md9.js} +176 -176
- package/app/assets/{zh-B-LYJHDx.js → D8nrOvmh.js} +1 -1
- package/app/assets/DAFA-E1-.js +1 -0
- package/app/assets/{dist-Roudme38.js → DD58tsa9.js} +2 -2
- package/app/assets/DFNc4ghA.js +1 -0
- package/app/assets/DGD5v9rw.js +1 -0
- package/app/assets/{mariadb-BT1adDMU.js → DOeLCbYG.js} +1 -1
- package/app/assets/DTxGpkJp.js +1 -0
- package/app/assets/DZH-tLW-.js +1 -0
- package/app/assets/D_2UqK4k.js +1 -0
- package/app/assets/DbRMSZEW.js +1 -0
- package/app/assets/Dd9Cr3e8.js +1 -0
- package/app/assets/DeB5sJfy.js +1 -0
- package/app/assets/Dh3YJ3G1.js +1 -0
- package/app/assets/DiaA_DOW.js +1 -0
- package/app/assets/DlKZCiMg.js +2 -0
- package/app/assets/{sqlite3-worker1-CVKz6JuG.js → DlWwFXlb.js} +1 -1
- package/app/assets/DmejZTwI.js +1 -0
- package/app/assets/{SchemaDiffDialog-DjuZiSux.js → DnjY8W6t.js} +1 -1
- package/app/assets/DxuXBECK.js +1 -0
- package/app/assets/FnB_uI57.js +1 -0
- package/app/assets/IzH8qyLi.js +1 -0
- package/app/assets/NY_tAtcY.js +1 -0
- package/app/assets/QBd1UkiI.js +1 -0
- package/app/assets/{mysql-BEg8tI_S.js → R93pYE1w.js} +1 -1
- package/app/assets/RIOwEE7M.js +1 -0
- package/app/assets/S4Oofhu9.js +1 -0
- package/app/assets/XzPc-eVf.js +1 -0
- package/app/assets/cc05Z2oj.js +1 -0
- package/app/assets/dxLVUFtU.js +1 -0
- package/app/assets/eFSiW0Lh.js +1 -0
- package/app/assets/{transactsql-BVCsopZt.js → eRZ39OKr.js} +1 -1
- package/app/assets/fCzQKDqY.js +1 -0
- package/app/assets/{SchemaToHTMLDialog-NzNj1jXs.js → og5dTmtH.js} +1 -1
- package/app/assets/{RowFormDialog-pQYmxMrm.js → qbvehL2h.js} +1 -1
- package/app/assets/rcI4Fmt0.js +1 -0
- package/app/assets/{fr-DERxGVO3.js → u80owQsI.js} +1 -1
- package/app/index.html +22 -22
- package/dist/chunk-2Z2E3JGN.js +3733 -0
- package/dist/embed.js +21 -0
- package/dist/index.js +210 -3920
- package/package.json +4 -1
- package/app/assets/AboutDialog-DuqxA9a9.js +0 -1
- package/app/assets/AiSettingsDialog-CgGNd3Oc.js +0 -1
- package/app/assets/BigInteger-BlV0QR66.js +0 -1
- package/app/assets/ConnectDialog-H3vlQRP_.js +0 -1
- package/app/assets/CopyDatabaseDialog-CVoYRBig.js +0 -1
- package/app/assets/CopyTableDialog-DC1kxjD2.js +0 -1
- package/app/assets/CreateDatabaseDialog-BYwKAc5-.js +0 -1
- package/app/assets/CreateSchemaDialog-BhPRvUT-.js +0 -1
- package/app/assets/DataDiffDialog-CXMnUq1B.js +0 -1
- package/app/assets/EditSchemaDialog-B4h1xWEz.js +0 -2
- package/app/assets/FindReplaceDialog-BkDPXuox.js +0 -1
- package/app/assets/FlushDialog-BlPJ6BTk.js +0 -1
- package/app/assets/GeneralSettingsDialog-ByNvtugk.js +0 -1
- package/app/assets/GroupByDialog-B593U6zZ.js +0 -1
- package/app/assets/LicenseDialog-3TWjXiJM.js +0 -1
- package/app/assets/LinkColumnDialog-D3ygO1ut.js +0 -1
- package/app/assets/LocalHistoryDialog-DPgqYYkG.js +0 -1
- package/app/assets/McpSetupDialog-DOS0fPHQ.js +0 -1
- package/app/assets/ProcessListDialog-DNqFCxy7.js +0 -1
- package/app/assets/QuerySettingsDialog-BjD8gLmZ.js +0 -1
- package/app/assets/QuickCommandPalette-06i8D2JM.js +0 -1
- package/app/assets/SafetySettingsDialog-a0Y03qEt.js +0 -1
- package/app/assets/StatusDialog-DVKcDDpl.js +0 -1
- package/app/assets/SyncDialog-CTyRxpKa.js +0 -1
- package/app/assets/TermsDialog-oo1J6DXC.js +0 -1
- package/app/assets/UserAddDialog-CtZpkft3.js +0 -1
- package/app/assets/UserManagerDialog-Dk1mzBsu.js +0 -1
- package/app/assets/VariablesDialog-Blu74K9P.js +0 -1
- package/app/assets/app-storage-C_1RLCyI.js +0 -1
- package/app/assets/button-B2EsmMJs.js +0 -1
- package/app/assets/chevron-up-CBnVjNE1.js +0 -1
- package/app/assets/connection-store-B85kbJpB.js +0 -1
- package/app/assets/database-BSXLJRzV.js +0 -1
- package/app/assets/dialog-FBpgH0QJ.js +0 -1
- package/app/assets/dist-BYhFWvmC.js +0 -41
- package/app/assets/editor-registry-ukUyoekH.js +0 -1
- package/app/assets/engine-DjP-Sw-S.js +0 -1
- package/app/assets/i18n-C0aLwQ_k.js +0 -2
- package/app/assets/index-dwv2QL4R.css +0 -2
- package/app/assets/pro-upsell-DhbKIX46.js +0 -1
- package/app/assets/pro-usage-BxwRZwtM.js +0 -1
- package/app/assets/query-execution-BeDEQ-3M.js +0 -2
- package/app/assets/query-store-BqBiylGA.js +0 -1
- package/app/assets/schema-nodes-MgIiKfpG.js +0 -1
- package/app/assets/select-DEqoX1sU.js +0 -1
- package/app/assets/settings-controls-BepR6gzL.js +0 -1
- package/app/assets/settings-store-DzQ3BP5e.js +0 -1
- package/app/assets/ui-store-5IssrgmG.js +0 -1
- package/app/assets/user-store-DOy2VMsT.js +0 -1
- /package/app/assets/{sqlite3-BVKGSWc-.wasm → BVKGSWc-.wasm} +0 -0
- /package/app/assets/{editor.worker-BboMRUer.js → BboMRUer.js} +0 -0
- /package/app/assets/{hmr-singleton-Bit0Kfog.js → Bit0Kfog.js} +0 -0
- /package/app/assets/{table-definition-Bs5VSn0X.js → Bs5VSn0X.js} +0 -0
- /package/app/assets/{file-name-CnYBZPf9.js → CnYBZPf9.js} +0 -0
- /package/app/assets/{sqlite3-opfs-async-proxy-D_xnb1D8.js → D_xnb1D8.js} +0 -0
- /package/app/assets/{sql-NEE52Syq.js → NEE52Syq.js} +0 -0
- /package/app/assets/{rolldown-runtime-hePW80VL.js → hePW80VL.js} +0 -0
- /package/app/assets/{contract-neLPq0_M.js → neLPq0_M.js} +0 -0
- /package/app/assets/{codicon-ngg6Pgfi.ttf → ngg6Pgfi.ttf} +0 -0
|
@@ -0,0 +1,3733 @@
|
|
|
1
|
+
// ../shared/contract.ts
|
|
2
|
+
var PROTOCOL_VERSION = 2;
|
|
3
|
+
var PRO_FEATURES = [
|
|
4
|
+
"ssh_tunnel",
|
|
5
|
+
"compare_schemas",
|
|
6
|
+
"compare_data",
|
|
7
|
+
"copy_table",
|
|
8
|
+
"federated",
|
|
9
|
+
"scratchpad",
|
|
10
|
+
"column_link",
|
|
11
|
+
"sync",
|
|
12
|
+
"ai_explain"
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// src/ai-cli.ts
|
|
16
|
+
import { execFile as execFileCb2, spawn } from "child_process";
|
|
17
|
+
import { mkdtemp, readFile as readFile2, rm, writeFile as writeFile2 } from "fs/promises";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
import { join as join3 } from "path";
|
|
20
|
+
import { promisify as promisify2 } from "util";
|
|
21
|
+
|
|
22
|
+
// src/ai-config.ts
|
|
23
|
+
import { chmod, mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
|
|
24
|
+
import { homedir as homedir2 } from "os";
|
|
25
|
+
import { join as join2 } from "path";
|
|
26
|
+
|
|
27
|
+
// ../shared/ai.ts
|
|
28
|
+
var AI_DEFAULT_MODEL = {
|
|
29
|
+
// Anthropic's most capable general model. Explaining a failure is the paid
|
|
30
|
+
// half of the product, so the default is not chosen to be cheap.
|
|
31
|
+
anthropic: "claude-opus-5",
|
|
32
|
+
// Same API, same models; only where the credential comes from differs.
|
|
33
|
+
"anthropic-profile": "claude-opus-5",
|
|
34
|
+
openai: "gpt-4o",
|
|
35
|
+
gemini: "gemini-2.5-flash",
|
|
36
|
+
// Not a model but the command to run: the CLI decides the model itself.
|
|
37
|
+
cli: "claude"
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// src/ai-anthropic.ts
|
|
41
|
+
import { execFile as execFileCb } from "child_process";
|
|
42
|
+
import { access } from "fs/promises";
|
|
43
|
+
import { homedir } from "os";
|
|
44
|
+
import { join } from "path";
|
|
45
|
+
import { promisify } from "util";
|
|
46
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
47
|
+
function toMessages(turns) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const turn of turns) {
|
|
50
|
+
if (turn.role === "user") {
|
|
51
|
+
out.push({ role: "user", content: turn.content });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (turn.role === "assistant") {
|
|
55
|
+
const blocks = [];
|
|
56
|
+
if (turn.content.trim()) blocks.push({ type: "text", text: turn.content });
|
|
57
|
+
for (const call of turn.toolCalls ?? []) {
|
|
58
|
+
blocks.push({ type: "tool_use", id: call.id, name: call.name, input: call.input });
|
|
59
|
+
}
|
|
60
|
+
if (blocks.length > 0) out.push({ role: "assistant", content: blocks });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const result = {
|
|
64
|
+
type: "tool_result",
|
|
65
|
+
tool_use_id: turn.toolCallId,
|
|
66
|
+
content: turn.content,
|
|
67
|
+
...turn.isError ? { is_error: true } : {}
|
|
68
|
+
};
|
|
69
|
+
const last = out[out.length - 1];
|
|
70
|
+
if (last && last.role === "user" && Array.isArray(last.content)) {
|
|
71
|
+
last.content.push(result);
|
|
72
|
+
} else {
|
|
73
|
+
out.push({ role: "user", content: [result] });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
var execFile = promisify(execFileCb);
|
|
79
|
+
async function anthropicProfileAvailable() {
|
|
80
|
+
try {
|
|
81
|
+
await execFile("ant", ["auth", "status"], { timeout: 1e4 });
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
try {
|
|
85
|
+
await access(join(homedir(), ".config", "anthropic"));
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function runAnthropic(config, req, signal) {
|
|
93
|
+
const client2 = new Anthropic({
|
|
94
|
+
...config.key ? { apiKey: config.key } : {},
|
|
95
|
+
...config.baseUrl ? { baseURL: config.baseUrl } : {}
|
|
96
|
+
});
|
|
97
|
+
const tools2 = (req.tools ?? []).map((tool) => ({
|
|
98
|
+
name: tool.name,
|
|
99
|
+
description: tool.description,
|
|
100
|
+
input_schema: tool.inputSchema
|
|
101
|
+
}));
|
|
102
|
+
try {
|
|
103
|
+
const response = await client2.messages.create({
|
|
104
|
+
model: config.model,
|
|
105
|
+
max_tokens: MAX_TOKENS,
|
|
106
|
+
system: req.system,
|
|
107
|
+
messages: toMessages(turnsOf(req)),
|
|
108
|
+
...tools2.length > 0 ? { tools: tools2 } : {}
|
|
109
|
+
}, { signal });
|
|
110
|
+
const text = response.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
111
|
+
const toolCalls = response.content.filter((block) => block.type === "tool_use").map((block) => ({ id: block.id, name: block.name, input: block.input ?? {} }));
|
|
112
|
+
if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
|
|
113
|
+
return {
|
|
114
|
+
text,
|
|
115
|
+
toolCalls,
|
|
116
|
+
stop: response.stop_reason === "tool_use" ? "tool_use" : response.stop_reason === "max_tokens" ? "length" : "end"
|
|
117
|
+
};
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err instanceof AiError) throw err;
|
|
120
|
+
const noCredential = 'This machine has no Anthropic credential the SDK could use. Run "ant auth login", or set a key instead.';
|
|
121
|
+
if (err instanceof Anthropic.AuthenticationError) {
|
|
122
|
+
throw new AiError(401, config.key ? "The AI key was rejected." : noCredential);
|
|
123
|
+
}
|
|
124
|
+
if (!config.key && err instanceof Error && /resolve authentication/i.test(err.message)) {
|
|
125
|
+
throw new AiError(401, noCredential);
|
|
126
|
+
}
|
|
127
|
+
if (err instanceof Anthropic.RateLimitError) throw new AiError(429, "The provider is rate limiting this key.");
|
|
128
|
+
if (err instanceof Anthropic.BadRequestError) throw new AiError(400, err.message);
|
|
129
|
+
if (err instanceof Anthropic.APIError) throw new AiError(502, `The provider answered ${err.status}: ${err.message}`);
|
|
130
|
+
throw new AiError(502, err instanceof Error ? err.message : String(err));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/ai-config.ts
|
|
135
|
+
var PLANCHE_DIR = join2(homedir2(), ".planche");
|
|
136
|
+
var AI_FILE = join2(PLANCHE_DIR, "ai.json");
|
|
137
|
+
function envConfig() {
|
|
138
|
+
const key = process.env.PLANCHE_AI_KEY?.trim() || process.env.ANTHROPIC_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim() || process.env.GEMINI_API_KEY?.trim();
|
|
139
|
+
if (!key) return null;
|
|
140
|
+
const named = process.env.PLANCHE_AI_PROVIDER?.trim();
|
|
141
|
+
const provider = named === "openai" || named === "anthropic" || named === "gemini" || named === "cli" || named === "anthropic-profile" ? named : process.env.GEMINI_API_KEY?.trim() ? "gemini" : process.env.OPENAI_API_KEY?.trim() && !process.env.ANTHROPIC_API_KEY?.trim() ? "openai" : "anthropic";
|
|
142
|
+
return {
|
|
143
|
+
provider,
|
|
144
|
+
model: process.env.PLANCHE_AI_MODEL?.trim() || AI_DEFAULT_MODEL[provider],
|
|
145
|
+
baseUrl: process.env.PLANCHE_AI_BASE_URL?.trim() || void 0,
|
|
146
|
+
key
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
var isKeyless = (provider) => provider === "cli" || provider === "anthropic-profile";
|
|
150
|
+
var session = null;
|
|
151
|
+
var cached;
|
|
152
|
+
async function fileConfig() {
|
|
153
|
+
if (cached !== void 0) return cached;
|
|
154
|
+
try {
|
|
155
|
+
const raw = JSON.parse(await readFile(AI_FILE, "utf8"));
|
|
156
|
+
const provider = raw.provider === "openai" || raw.provider === "gemini" || raw.provider === "cli" || raw.provider === "anthropic-profile" ? raw.provider : "anthropic";
|
|
157
|
+
const key = typeof raw.key === "string" ? raw.key : "";
|
|
158
|
+
cached = key || isKeyless(provider) ? {
|
|
159
|
+
provider,
|
|
160
|
+
model: typeof raw.model === "string" && raw.model ? raw.model : AI_DEFAULT_MODEL[provider],
|
|
161
|
+
baseUrl: typeof raw.baseUrl === "string" && raw.baseUrl ? raw.baseUrl : void 0,
|
|
162
|
+
key
|
|
163
|
+
} : null;
|
|
164
|
+
} catch {
|
|
165
|
+
cached = null;
|
|
166
|
+
}
|
|
167
|
+
return cached;
|
|
168
|
+
}
|
|
169
|
+
async function aiConfig() {
|
|
170
|
+
return envConfig() ?? session ?? await fileConfig();
|
|
171
|
+
}
|
|
172
|
+
async function aiConfigView() {
|
|
173
|
+
const env = envConfig();
|
|
174
|
+
const file = env ? null : await fileConfig();
|
|
175
|
+
const config = env ?? session ?? file;
|
|
176
|
+
const storage = env ? "environment" : session ? "session" : file ? "file" : "none";
|
|
177
|
+
return {
|
|
178
|
+
configured: config !== null,
|
|
179
|
+
provider: config?.provider ?? "anthropic",
|
|
180
|
+
model: config?.model ?? AI_DEFAULT_MODEL.anthropic,
|
|
181
|
+
baseUrl: config?.baseUrl,
|
|
182
|
+
fromEnvironment: env !== null,
|
|
183
|
+
storage,
|
|
184
|
+
path: AI_FILE,
|
|
185
|
+
clis: await installedClis(),
|
|
186
|
+
anthropicProfile: await anthropicProfileAvailable()
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function defaultModelFor(provider) {
|
|
190
|
+
return AI_DEFAULT_MODEL[provider];
|
|
191
|
+
}
|
|
192
|
+
async function saveAiConfig(config, remember) {
|
|
193
|
+
if (!remember && !isKeyless(config.provider)) {
|
|
194
|
+
session = config;
|
|
195
|
+
await forgetStoredConfig();
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
session = null;
|
|
199
|
+
await mkdir(PLANCHE_DIR, { recursive: true });
|
|
200
|
+
const temp = `${AI_FILE}.${process.pid}.tmp`;
|
|
201
|
+
await writeFile(temp, JSON.stringify(config, null, 2), { mode: 384 });
|
|
202
|
+
await chmod(temp, 384);
|
|
203
|
+
await rename(temp, AI_FILE);
|
|
204
|
+
cached = config;
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
async function forgetStoredConfig() {
|
|
208
|
+
cached = null;
|
|
209
|
+
try {
|
|
210
|
+
await unlink(AI_FILE);
|
|
211
|
+
} catch {
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function forgetAiConfig() {
|
|
215
|
+
session = null;
|
|
216
|
+
await forgetStoredConfig();
|
|
217
|
+
}
|
|
218
|
+
var aiConfigPath = AI_FILE;
|
|
219
|
+
|
|
220
|
+
// src/ai-gemini.ts
|
|
221
|
+
var DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
222
|
+
function toContents(turns) {
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const turn of turns) {
|
|
225
|
+
if (turn.role === "user") {
|
|
226
|
+
out.push({ role: "user", parts: [{ text: turn.content }] });
|
|
227
|
+
} else if (turn.role === "assistant") {
|
|
228
|
+
const parts = [];
|
|
229
|
+
if (turn.content.trim()) parts.push({ text: turn.content });
|
|
230
|
+
for (const call of turn.toolCalls ?? []) parts.push({ functionCall: { name: call.name, args: call.input } });
|
|
231
|
+
if (parts.length > 0) out.push({ role: "model", parts });
|
|
232
|
+
} else {
|
|
233
|
+
const part = {
|
|
234
|
+
functionResponse: { name: turn.toolCallId, response: { result: turn.content, ...turn.isError ? { error: true } : {} } }
|
|
235
|
+
};
|
|
236
|
+
const last = out[out.length - 1];
|
|
237
|
+
if (last && last.role === "user" && last.parts.every((p) => p.functionResponse)) last.parts.push(part);
|
|
238
|
+
else out.push({ role: "user", parts: [part] });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
async function runGemini(config, req, signal) {
|
|
244
|
+
const base = (config.baseUrl || DEFAULT_BASE).replace(/\/+$/, "");
|
|
245
|
+
const url = `${base}/models/${encodeURIComponent(config.model)}:generateContent?key=${encodeURIComponent(config.key)}`;
|
|
246
|
+
const tools2 = req.tools ?? [];
|
|
247
|
+
let response;
|
|
248
|
+
try {
|
|
249
|
+
response = await fetch(url, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
signal,
|
|
252
|
+
headers: { "content-type": "application/json" },
|
|
253
|
+
body: JSON.stringify({
|
|
254
|
+
systemInstruction: { parts: [{ text: req.system }] },
|
|
255
|
+
contents: toContents(turnsOf(req)),
|
|
256
|
+
...tools2.length > 0 ? {
|
|
257
|
+
tools: [
|
|
258
|
+
{
|
|
259
|
+
functionDeclarations: tools2.map((tool) => ({
|
|
260
|
+
name: tool.name,
|
|
261
|
+
description: tool.description,
|
|
262
|
+
parameters: tool.inputSchema
|
|
263
|
+
}))
|
|
264
|
+
}
|
|
265
|
+
]
|
|
266
|
+
} : {},
|
|
267
|
+
generationConfig: {
|
|
268
|
+
// A JSON mime type and function calling cannot both be asked for.
|
|
269
|
+
...tools2.length > 0 ? {} : { responseMimeType: "application/json" },
|
|
270
|
+
maxOutputTokens: MAX_TOKENS
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
});
|
|
274
|
+
} catch (err) {
|
|
275
|
+
throw new AiError(502, err instanceof Error ? err.message : String(err));
|
|
276
|
+
}
|
|
277
|
+
const data = await response.json().catch(() => null);
|
|
278
|
+
if (!response.ok) {
|
|
279
|
+
const message = data?.error?.message ?? `${response.status} ${response.statusText}`;
|
|
280
|
+
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
281
|
+
throw new AiError(401, `The AI key was rejected: ${message}`);
|
|
282
|
+
}
|
|
283
|
+
if (response.status === 429) throw new AiError(429, "The provider is rate limiting this key.");
|
|
284
|
+
throw new AiError(502, `The provider answered ${response.status}: ${message}`);
|
|
285
|
+
}
|
|
286
|
+
const candidate = data?.candidates?.[0];
|
|
287
|
+
const parts = candidate?.content?.parts ?? [];
|
|
288
|
+
const text = parts.map((p) => p.text ?? "").join("");
|
|
289
|
+
const toolCalls = parts.filter((p) => typeof p.functionCall?.name === "string").map((p) => ({ id: p.functionCall.name, name: p.functionCall.name, input: p.functionCall.args ?? {} }));
|
|
290
|
+
if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
|
|
291
|
+
return {
|
|
292
|
+
text,
|
|
293
|
+
toolCalls,
|
|
294
|
+
stop: toolCalls.length > 0 ? "tool_use" : candidate?.finishReason === "MAX_TOKENS" ? "length" : "end"
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// src/ai-openai.ts
|
|
299
|
+
import OpenAI from "openai";
|
|
300
|
+
function toMessages2(system, turns) {
|
|
301
|
+
const out = [{ role: "system", content: system }];
|
|
302
|
+
for (const turn of turns) {
|
|
303
|
+
if (turn.role === "user") {
|
|
304
|
+
out.push({ role: "user", content: turn.content });
|
|
305
|
+
} else if (turn.role === "assistant") {
|
|
306
|
+
const calls = turn.toolCalls ?? [];
|
|
307
|
+
out.push({
|
|
308
|
+
role: "assistant",
|
|
309
|
+
content: turn.content || null,
|
|
310
|
+
...calls.length > 0 ? {
|
|
311
|
+
tool_calls: calls.map((call) => ({
|
|
312
|
+
id: call.id,
|
|
313
|
+
type: "function",
|
|
314
|
+
function: { name: call.name, arguments: JSON.stringify(call.input) }
|
|
315
|
+
}))
|
|
316
|
+
} : {}
|
|
317
|
+
});
|
|
318
|
+
} else {
|
|
319
|
+
out.push({ role: "tool", tool_call_id: turn.toolCallId, content: turn.content });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
function parseArguments(raw) {
|
|
325
|
+
try {
|
|
326
|
+
const parsed = JSON.parse(raw || "{}");
|
|
327
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
328
|
+
} catch {
|
|
329
|
+
return {};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async function runOpenAI(config, req, signal) {
|
|
333
|
+
const client2 = new OpenAI({ apiKey: config.key, ...config.baseUrl ? { baseURL: config.baseUrl } : {} });
|
|
334
|
+
const tools2 = (req.tools ?? []).map((tool) => ({
|
|
335
|
+
type: "function",
|
|
336
|
+
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema }
|
|
337
|
+
}));
|
|
338
|
+
try {
|
|
339
|
+
const response = await client2.chat.completions.create({
|
|
340
|
+
model: config.model,
|
|
341
|
+
max_completion_tokens: MAX_TOKENS,
|
|
342
|
+
// JSON mode and tools are mutually exclusive here: forcing a JSON object
|
|
343
|
+
// leaves the model no way to answer with a call. The one-shot prompts
|
|
344
|
+
// ask for JSON, a tool run does not.
|
|
345
|
+
...tools2.length > 0 ? { tools: tools2 } : { response_format: { type: "json_object" } },
|
|
346
|
+
messages: toMessages2(req.system, turnsOf(req))
|
|
347
|
+
}, { signal });
|
|
348
|
+
const choice = response.choices[0];
|
|
349
|
+
const text = choice?.message?.content ?? "";
|
|
350
|
+
const toolCalls = (choice?.message?.tool_calls ?? []).filter((call) => call.type === "function").map((call) => ({ id: call.id, name: call.function.name, input: parseArguments(call.function.arguments) }));
|
|
351
|
+
if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
|
|
352
|
+
return {
|
|
353
|
+
text,
|
|
354
|
+
toolCalls,
|
|
355
|
+
stop: choice?.finish_reason === "tool_calls" ? "tool_use" : choice?.finish_reason === "length" ? "length" : "end"
|
|
356
|
+
};
|
|
357
|
+
} catch (err) {
|
|
358
|
+
if (err instanceof AiError) throw err;
|
|
359
|
+
if (err instanceof OpenAI.APIError) {
|
|
360
|
+
const status = err.status === 401 || err.status === 429 || err.status === 400 ? err.status : 502;
|
|
361
|
+
throw new AiError(status, err.status === 401 ? "The AI key was rejected." : err.message);
|
|
362
|
+
}
|
|
363
|
+
throw new AiError(502, err instanceof Error ? err.message : String(err));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/ai-run.ts
|
|
368
|
+
var AiError = class extends Error {
|
|
369
|
+
constructor(status, message) {
|
|
370
|
+
super(message);
|
|
371
|
+
this.status = status;
|
|
372
|
+
this.name = "AiError";
|
|
373
|
+
}
|
|
374
|
+
status;
|
|
375
|
+
};
|
|
376
|
+
var MAX_TOKENS = 8e3;
|
|
377
|
+
function turnsOf(req) {
|
|
378
|
+
if (req.messages && req.messages.length > 0) return req.messages;
|
|
379
|
+
return [{ role: "user", content: req.user ?? "" }];
|
|
380
|
+
}
|
|
381
|
+
async function runPrompt(req, signal) {
|
|
382
|
+
const config = await aiConfig();
|
|
383
|
+
if (!config) throw new AiError(501, "No AI key is configured on this agent.");
|
|
384
|
+
const model = req.model?.trim() || config.model;
|
|
385
|
+
const run2 = config.provider === "cli" ? runCli : config.provider === "openai" ? runOpenAI : config.provider === "gemini" ? runGemini : runAnthropic;
|
|
386
|
+
const result = await run2({ ...config, model }, req, signal);
|
|
387
|
+
return {
|
|
388
|
+
success: true,
|
|
389
|
+
text: result.text,
|
|
390
|
+
model,
|
|
391
|
+
...result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {},
|
|
392
|
+
stop: result.stop
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// src/ai-cli.ts
|
|
397
|
+
var execFile2 = promisify2(execFileCb2);
|
|
398
|
+
var CLI_COMMANDS = ["claude", "codex"];
|
|
399
|
+
var TIMEOUT_MS = 18e4;
|
|
400
|
+
var mcp = null;
|
|
401
|
+
function configureCliBridge(url, token) {
|
|
402
|
+
mcp = { url, token };
|
|
403
|
+
}
|
|
404
|
+
function mcpUrl() {
|
|
405
|
+
return mcp?.url ?? null;
|
|
406
|
+
}
|
|
407
|
+
function isCliCommand(name) {
|
|
408
|
+
return CLI_COMMANDS.includes(name);
|
|
409
|
+
}
|
|
410
|
+
async function installedClis() {
|
|
411
|
+
const found = [];
|
|
412
|
+
for (const name of CLI_COMMANDS) {
|
|
413
|
+
try {
|
|
414
|
+
await execFile2(name, ["--version"], { timeout: 1e4 });
|
|
415
|
+
found.push(name);
|
|
416
|
+
} catch {
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return found;
|
|
420
|
+
}
|
|
421
|
+
var STOP_GRACE_MS = 3e3;
|
|
422
|
+
function run(command, args, prompt, signal) {
|
|
423
|
+
return new Promise((resolve3, reject) => {
|
|
424
|
+
if (signal?.aborted) {
|
|
425
|
+
reject(new AiError(499, "Stopped."));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
429
|
+
let out = "";
|
|
430
|
+
let err = "";
|
|
431
|
+
let stopped = false;
|
|
432
|
+
const timer2 = setTimeout(() => {
|
|
433
|
+
child.kill("SIGKILL");
|
|
434
|
+
reject(new AiError(504, `${command} did not answer within ${TIMEOUT_MS / 1e3}s.`));
|
|
435
|
+
}, TIMEOUT_MS);
|
|
436
|
+
const onAbort = () => {
|
|
437
|
+
stopped = true;
|
|
438
|
+
child.kill("SIGTERM");
|
|
439
|
+
setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS).unref();
|
|
440
|
+
};
|
|
441
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
442
|
+
const done = () => {
|
|
443
|
+
clearTimeout(timer2);
|
|
444
|
+
signal?.removeEventListener("abort", onAbort);
|
|
445
|
+
};
|
|
446
|
+
child.stdout.on("data", (d) => out += d);
|
|
447
|
+
child.stderr.on("data", (d) => err += d);
|
|
448
|
+
child.on("error", (e) => {
|
|
449
|
+
done();
|
|
450
|
+
reject(new AiError(501, `${command} could not be started: ${e.message}`));
|
|
451
|
+
});
|
|
452
|
+
child.on("close", (code) => {
|
|
453
|
+
done();
|
|
454
|
+
if (stopped) reject(new AiError(499, "Stopped."));
|
|
455
|
+
else if (code === 0) resolve3(out);
|
|
456
|
+
else reject(new AiError(502, `${command} exited ${code}: ${err.trim().slice(0, 400) || "no output"}`));
|
|
457
|
+
});
|
|
458
|
+
child.stdin.end(prompt);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
function unwrap(text) {
|
|
462
|
+
const fenced = text.trim().match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
|
|
463
|
+
return (fenced ? fenced[1] : text).trim();
|
|
464
|
+
}
|
|
465
|
+
async function runCli(config, req, signal) {
|
|
466
|
+
const command = config.model.trim();
|
|
467
|
+
const wantsTools = (req.tools?.length ?? 0) > 0;
|
|
468
|
+
if (wantsTools && command !== "claude") {
|
|
469
|
+
throw new AiError(400, `${command} cannot be given Planche's tools. Use the claude CLI for the AI panel, or a provider with an API key.`);
|
|
470
|
+
}
|
|
471
|
+
if (wantsTools && !mcp) {
|
|
472
|
+
throw new AiError(501, "This agent cannot offer its tools to a CLI. Restart it, or use a provider with an API key.");
|
|
473
|
+
}
|
|
474
|
+
if (!isCliCommand(command)) {
|
|
475
|
+
throw new AiError(400, `Unknown CLI "${command}". Use one of: ${CLI_COMMANDS.join(", ")}.`);
|
|
476
|
+
}
|
|
477
|
+
const turns = turnsOf(req);
|
|
478
|
+
const prompt = [req.system, "", turns.map((t) => "content" in t ? t.content : "").join("\n\n")].join("\n");
|
|
479
|
+
let text;
|
|
480
|
+
if (command === "claude") {
|
|
481
|
+
if (!wantsTools) {
|
|
482
|
+
text = await run("claude", ["-p"], prompt, signal);
|
|
483
|
+
} else {
|
|
484
|
+
const dir = await mkdtemp(join3(tmpdir(), "planche-mcp-"));
|
|
485
|
+
const file = join3(dir, "mcp.json");
|
|
486
|
+
try {
|
|
487
|
+
await writeFile2(
|
|
488
|
+
file,
|
|
489
|
+
JSON.stringify({
|
|
490
|
+
mcpServers: {
|
|
491
|
+
planche: { type: "http", url: mcp.url, headers: { Authorization: `Bearer ${mcp.token}` } }
|
|
492
|
+
}
|
|
493
|
+
}),
|
|
494
|
+
{ mode: 384 }
|
|
495
|
+
);
|
|
496
|
+
text = await run("claude", ["-p", "--strict-mcp-config", "--mcp-config", file], prompt, signal);
|
|
497
|
+
} finally {
|
|
498
|
+
await rm(dir, { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
const dir = await mkdtemp(join3(tmpdir(), "planche-cli-"));
|
|
503
|
+
const file = join3(dir, "answer.txt");
|
|
504
|
+
try {
|
|
505
|
+
await run("codex", ["exec", "--skip-git-repo-check", "-o", file, "-"], prompt, signal);
|
|
506
|
+
text = await readFile2(file, "utf8");
|
|
507
|
+
} finally {
|
|
508
|
+
await rm(dir, { recursive: true, force: true });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const answer = unwrap(text);
|
|
512
|
+
if (!answer) throw new AiError(502, `${command} returned nothing.`);
|
|
513
|
+
return { text: answer, toolCalls: [], stop: "end" };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/mcp-token.ts
|
|
517
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile3, rm as rm2, writeFile as writeFile3 } from "fs/promises";
|
|
518
|
+
import { randomBytes } from "crypto";
|
|
519
|
+
import { homedir as homedir3 } from "os";
|
|
520
|
+
import { join as join4 } from "path";
|
|
521
|
+
var DIR = join4(homedir3(), ".planche");
|
|
522
|
+
var FILE = join4(DIR, "mcp-token");
|
|
523
|
+
var mcpTokenPath = FILE;
|
|
524
|
+
var cached2 = null;
|
|
525
|
+
async function readMcpToken() {
|
|
526
|
+
if (cached2) return cached2;
|
|
527
|
+
try {
|
|
528
|
+
const text = (await readFile3(FILE, "utf8")).trim();
|
|
529
|
+
cached2 = text || null;
|
|
530
|
+
} catch {
|
|
531
|
+
cached2 = null;
|
|
532
|
+
}
|
|
533
|
+
return cached2;
|
|
534
|
+
}
|
|
535
|
+
async function ensureMcpToken() {
|
|
536
|
+
const existing = await readMcpToken();
|
|
537
|
+
if (existing) return existing;
|
|
538
|
+
const token = randomBytes(32).toString("base64url");
|
|
539
|
+
await mkdir2(DIR, { recursive: true, mode: 448 });
|
|
540
|
+
await writeFile3(FILE, `${token}
|
|
541
|
+
`, { mode: 384 });
|
|
542
|
+
await chmod2(FILE, 384);
|
|
543
|
+
cached2 = token;
|
|
544
|
+
return token;
|
|
545
|
+
}
|
|
546
|
+
async function forgetMcpToken() {
|
|
547
|
+
cached2 = null;
|
|
548
|
+
await rm2(FILE, { force: true });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/usage.ts
|
|
552
|
+
import { mkdir as mkdir3, readFile as readFile4, rename as rename2, writeFile as writeFile4 } from "fs/promises";
|
|
553
|
+
import { homedir as homedir4 } from "os";
|
|
554
|
+
import { join as join5 } from "path";
|
|
555
|
+
var PLANCHE_DIR2 = join5(homedir4(), ".planche");
|
|
556
|
+
var USAGE_FILE = join5(PLANCHE_DIR2, "usage.json");
|
|
557
|
+
var WRITE_DEBOUNCE_MS = 2e3;
|
|
558
|
+
var KNOWN = new Set(PRO_FEATURES);
|
|
559
|
+
var state = null;
|
|
560
|
+
var loading = null;
|
|
561
|
+
var timer = null;
|
|
562
|
+
var writes = Promise.resolve();
|
|
563
|
+
function isProFeature(value) {
|
|
564
|
+
return typeof value === "string" && KNOWN.has(value);
|
|
565
|
+
}
|
|
566
|
+
var isoNow = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
567
|
+
function empty() {
|
|
568
|
+
return { version: 1, since: isoNow(), features: {} };
|
|
569
|
+
}
|
|
570
|
+
function parse(raw) {
|
|
571
|
+
const data = JSON.parse(raw);
|
|
572
|
+
if (!data || typeof data !== "object") return empty();
|
|
573
|
+
const features = {};
|
|
574
|
+
for (const [key, value] of Object.entries(data.features ?? {})) {
|
|
575
|
+
if (!isProFeature(key) || !value || typeof value !== "object") continue;
|
|
576
|
+
const entry = value;
|
|
577
|
+
const count = Number(entry.count);
|
|
578
|
+
if (!Number.isFinite(count) || count < 0) continue;
|
|
579
|
+
features[key] = {
|
|
580
|
+
count: Math.floor(count),
|
|
581
|
+
first: typeof entry.first === "string" ? entry.first : isoNow(),
|
|
582
|
+
last: typeof entry.last === "string" ? entry.last : isoNow()
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return { version: 1, since: typeof data.since === "string" ? data.since : isoNow(), features };
|
|
586
|
+
}
|
|
587
|
+
async function load() {
|
|
588
|
+
if (state) return state;
|
|
589
|
+
loading ??= (async () => {
|
|
590
|
+
try {
|
|
591
|
+
state = parse(await readFile4(USAGE_FILE, "utf8"));
|
|
592
|
+
} catch {
|
|
593
|
+
state = empty();
|
|
594
|
+
}
|
|
595
|
+
return state;
|
|
596
|
+
})();
|
|
597
|
+
return loading;
|
|
598
|
+
}
|
|
599
|
+
async function persist() {
|
|
600
|
+
if (!state) return;
|
|
601
|
+
const body = `${JSON.stringify(state, null, 2)}
|
|
602
|
+
`;
|
|
603
|
+
await mkdir3(PLANCHE_DIR2, { recursive: true, mode: 448 });
|
|
604
|
+
const tmp = `${USAGE_FILE}.${process.pid}.tmp`;
|
|
605
|
+
await writeFile4(tmp, body, { mode: 384 });
|
|
606
|
+
await rename2(tmp, USAGE_FILE);
|
|
607
|
+
}
|
|
608
|
+
function schedule() {
|
|
609
|
+
if (timer) return;
|
|
610
|
+
timer = setTimeout(() => {
|
|
611
|
+
timer = null;
|
|
612
|
+
void flushUsage();
|
|
613
|
+
}, WRITE_DEBOUNCE_MS);
|
|
614
|
+
timer.unref?.();
|
|
615
|
+
}
|
|
616
|
+
function recordUsage(feature) {
|
|
617
|
+
void (async () => {
|
|
618
|
+
try {
|
|
619
|
+
const file = await load();
|
|
620
|
+
const now = isoNow();
|
|
621
|
+
const entry = file.features[feature];
|
|
622
|
+
if (entry) {
|
|
623
|
+
entry.count += 1;
|
|
624
|
+
entry.last = now;
|
|
625
|
+
} else {
|
|
626
|
+
file.features[feature] = { count: 1, first: now, last: now };
|
|
627
|
+
}
|
|
628
|
+
schedule();
|
|
629
|
+
} catch {
|
|
630
|
+
}
|
|
631
|
+
})();
|
|
632
|
+
}
|
|
633
|
+
function flushUsage() {
|
|
634
|
+
if (timer) {
|
|
635
|
+
clearTimeout(timer);
|
|
636
|
+
timer = null;
|
|
637
|
+
}
|
|
638
|
+
writes = writes.then(persist).catch(() => {
|
|
639
|
+
});
|
|
640
|
+
return writes;
|
|
641
|
+
}
|
|
642
|
+
async function usageSnapshot() {
|
|
643
|
+
const file = await load().catch(empty);
|
|
644
|
+
return { success: true, since: file.since, features: { ...file.features } };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// src/gateway.ts
|
|
648
|
+
import { readFileSync } from "fs";
|
|
649
|
+
import { resolve as resolve2 } from "path";
|
|
650
|
+
|
|
651
|
+
// src/drivers/mysql.ts
|
|
652
|
+
import mysql from "mysql2/promise";
|
|
653
|
+
var MYSQL_TYPES = {
|
|
654
|
+
0: "decimal",
|
|
655
|
+
1: "tinyint",
|
|
656
|
+
2: "smallint",
|
|
657
|
+
3: "int",
|
|
658
|
+
4: "float",
|
|
659
|
+
5: "double",
|
|
660
|
+
6: "null",
|
|
661
|
+
7: "timestamp",
|
|
662
|
+
8: "bigint",
|
|
663
|
+
9: "mediumint",
|
|
664
|
+
10: "date",
|
|
665
|
+
11: "time",
|
|
666
|
+
12: "datetime",
|
|
667
|
+
13: "year",
|
|
668
|
+
14: "newdate",
|
|
669
|
+
15: "varchar",
|
|
670
|
+
16: "bit",
|
|
671
|
+
245: "json",
|
|
672
|
+
246: "newdecimal",
|
|
673
|
+
247: "enum",
|
|
674
|
+
248: "set",
|
|
675
|
+
249: "tinyblob",
|
|
676
|
+
250: "mediumblob",
|
|
677
|
+
251: "longblob",
|
|
678
|
+
252: "blob",
|
|
679
|
+
253: "varstring",
|
|
680
|
+
254: "string",
|
|
681
|
+
255: "geometry"
|
|
682
|
+
};
|
|
683
|
+
var BINARY_CHARSET = 63;
|
|
684
|
+
var BLOB_TO_TEXT = { tinyblob: "tinytext", blob: "text", mediumblob: "mediumtext", longblob: "longtext" };
|
|
685
|
+
function getTypeName(typeId, charset) {
|
|
686
|
+
const name = MYSQL_TYPES[typeId] ?? "unknown";
|
|
687
|
+
if (charset !== void 0 && charset !== BINARY_CHARSET) return BLOB_TO_TEXT[name] ?? name;
|
|
688
|
+
return name;
|
|
689
|
+
}
|
|
690
|
+
function toConnectionOptions(config) {
|
|
691
|
+
return {
|
|
692
|
+
host: config.host,
|
|
693
|
+
port: config.port,
|
|
694
|
+
user: config.user,
|
|
695
|
+
password: config.password,
|
|
696
|
+
database: config.database || void 0,
|
|
697
|
+
dateStrings: true,
|
|
698
|
+
charset: config.charset || "utf8mb4",
|
|
699
|
+
ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
var USE_STATEMENT = /^\s*USE\s/i;
|
|
703
|
+
var MySqlDriver = class {
|
|
704
|
+
constructor(connection, config) {
|
|
705
|
+
this.connection = connection;
|
|
706
|
+
this.config = config;
|
|
707
|
+
this.label = `${config.user}@${config.host}:${config.port}`;
|
|
708
|
+
this.currentDatabase = config.database ?? null;
|
|
709
|
+
}
|
|
710
|
+
connection;
|
|
711
|
+
config;
|
|
712
|
+
type = "mysql";
|
|
713
|
+
label;
|
|
714
|
+
/**
|
|
715
|
+
* Default schema the session is currently in, or null when unknown (after
|
|
716
|
+
* the client ran its own `USE`). Tracked so the schema is switched with a
|
|
717
|
+
* plain `USE` only when it actually changes: `changeUser()` would reset the
|
|
718
|
+
* whole session on every call, silently rolling back open transactions and
|
|
719
|
+
* dropping temporary tables and user variables.
|
|
720
|
+
*/
|
|
721
|
+
currentDatabase;
|
|
722
|
+
async query(sql2, database) {
|
|
723
|
+
if (database && database !== this.currentDatabase) {
|
|
724
|
+
await this.connection.query(`USE ${mysql.escapeId(database)}`);
|
|
725
|
+
this.currentDatabase = database;
|
|
726
|
+
}
|
|
727
|
+
const [results, fields] = await this.connection.query(sql2);
|
|
728
|
+
if (USE_STATEMENT.test(sql2)) this.currentDatabase = null;
|
|
729
|
+
if (Array.isArray(results) && fields) {
|
|
730
|
+
const fieldPackets = fields;
|
|
731
|
+
const mappedFields = fieldPackets.map((field) => ({
|
|
732
|
+
name: field.name,
|
|
733
|
+
orgName: field.orgName ?? field.name,
|
|
734
|
+
type: getTypeName(field.type ?? 253, field.characterSet),
|
|
735
|
+
table: field.table ?? "",
|
|
736
|
+
orgTable: field.orgTable ?? "",
|
|
737
|
+
db: field.db ?? "",
|
|
738
|
+
maxLength: field.length ?? 0
|
|
739
|
+
}));
|
|
740
|
+
const records = results.map(
|
|
741
|
+
(row) => fieldPackets.map((field) => row[field.name])
|
|
742
|
+
);
|
|
743
|
+
return { fields: mappedFields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
744
|
+
}
|
|
745
|
+
const header = results;
|
|
746
|
+
return {
|
|
747
|
+
fields: [],
|
|
748
|
+
records: [],
|
|
749
|
+
affectedRows: header.affectedRows ?? 0,
|
|
750
|
+
insertId: header.insertId ?? 0,
|
|
751
|
+
isResultQuery: false
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
async setReadOnly() {
|
|
755
|
+
await this.connection.query("SET SESSION TRANSACTION READ ONLY");
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* A connection running a statement cannot take another one, so the KILL has
|
|
759
|
+
* to come down a second connection. `KILL QUERY` ends the statement and
|
|
760
|
+
* leaves the session — and its open transaction — alone.
|
|
761
|
+
*/
|
|
762
|
+
async cancel() {
|
|
763
|
+
const threadId = this.connection.threadId;
|
|
764
|
+
if (!threadId) return;
|
|
765
|
+
const killer = await mysql.createConnection(toConnectionOptions(this.config));
|
|
766
|
+
try {
|
|
767
|
+
await killer.query(`KILL QUERY ${Number(threadId)}`);
|
|
768
|
+
} finally {
|
|
769
|
+
await killer.end().catch(() => void 0);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
async close() {
|
|
773
|
+
await this.connection.end();
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
var mysqlDriverFactory = {
|
|
777
|
+
async open(config) {
|
|
778
|
+
const connection = await mysql.createConnection(toConnectionOptions(config));
|
|
779
|
+
connection.on("error", (err) => console.error("[mysql] Connection error:", err.message));
|
|
780
|
+
return new MySqlDriver(connection, config);
|
|
781
|
+
},
|
|
782
|
+
async test(config) {
|
|
783
|
+
const connection = await mysql.createConnection({
|
|
784
|
+
...toConnectionOptions(config),
|
|
785
|
+
database: void 0
|
|
786
|
+
});
|
|
787
|
+
await connection.end();
|
|
788
|
+
},
|
|
789
|
+
parseConfig(body) {
|
|
790
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
791
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
792
|
+
if (!host || !user) {
|
|
793
|
+
throw new Error("host and user are required");
|
|
794
|
+
}
|
|
795
|
+
return {
|
|
796
|
+
driver: "mysql",
|
|
797
|
+
host,
|
|
798
|
+
port: Number(body.port) || 3306,
|
|
799
|
+
user,
|
|
800
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
801
|
+
database: typeof body.database === "string" && body.database ? body.database : void 0,
|
|
802
|
+
charset: typeof body.charset === "string" && body.charset ? body.charset : void 0,
|
|
803
|
+
ssl: body.ssl === true,
|
|
804
|
+
sslVerify: body.sslVerify === true
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
// src/drivers/postgres.ts
|
|
810
|
+
import pg from "pg";
|
|
811
|
+
var TEXT_OIDS = [
|
|
812
|
+
20,
|
|
813
|
+
// int8
|
|
814
|
+
1700,
|
|
815
|
+
// numeric
|
|
816
|
+
1082,
|
|
817
|
+
// date
|
|
818
|
+
1114,
|
|
819
|
+
// timestamp
|
|
820
|
+
1184,
|
|
821
|
+
// timestamptz
|
|
822
|
+
1083,
|
|
823
|
+
// time
|
|
824
|
+
1266,
|
|
825
|
+
// timetz
|
|
826
|
+
1186
|
|
827
|
+
// interval
|
|
828
|
+
];
|
|
829
|
+
var types = new pg.TypeOverrides();
|
|
830
|
+
for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
|
|
831
|
+
var BUILTIN_TYPES = {
|
|
832
|
+
16: "bool",
|
|
833
|
+
17: "bytea",
|
|
834
|
+
18: "char",
|
|
835
|
+
19: "name",
|
|
836
|
+
20: "int8",
|
|
837
|
+
21: "int2",
|
|
838
|
+
23: "int4",
|
|
839
|
+
25: "text",
|
|
840
|
+
26: "oid",
|
|
841
|
+
114: "json",
|
|
842
|
+
142: "xml",
|
|
843
|
+
700: "float4",
|
|
844
|
+
701: "float8",
|
|
845
|
+
1042: "bpchar",
|
|
846
|
+
1043: "varchar",
|
|
847
|
+
1082: "date",
|
|
848
|
+
1083: "time",
|
|
849
|
+
1114: "timestamp",
|
|
850
|
+
1184: "timestamptz",
|
|
851
|
+
1186: "interval",
|
|
852
|
+
1266: "timetz",
|
|
853
|
+
1700: "numeric",
|
|
854
|
+
2950: "uuid",
|
|
855
|
+
3802: "jsonb",
|
|
856
|
+
1560: "bit",
|
|
857
|
+
1562: "varbit",
|
|
858
|
+
600: "point",
|
|
859
|
+
869: "inet",
|
|
860
|
+
650: "cidr",
|
|
861
|
+
829: "macaddr",
|
|
862
|
+
1007: "_int4",
|
|
863
|
+
1009: "_text"
|
|
864
|
+
};
|
|
865
|
+
function toClientConfig(config) {
|
|
866
|
+
return {
|
|
867
|
+
host: config.host,
|
|
868
|
+
port: config.port,
|
|
869
|
+
user: config.user,
|
|
870
|
+
password: config.password,
|
|
871
|
+
database: config.database,
|
|
872
|
+
ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0,
|
|
873
|
+
types
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
|
|
877
|
+
var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
878
|
+
var PostgresDriver = class {
|
|
879
|
+
constructor(client2, config) {
|
|
880
|
+
this.client = client2;
|
|
881
|
+
this.config = config;
|
|
882
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
883
|
+
}
|
|
884
|
+
client;
|
|
885
|
+
config;
|
|
886
|
+
type = "postgres";
|
|
887
|
+
label;
|
|
888
|
+
/**
|
|
889
|
+
* Schema currently first in `search_path`, or null when unknown (after the
|
|
890
|
+
* client set it itself). The app's "database" for a Postgres connection is
|
|
891
|
+
* a schema of the bound database, switched with `SET search_path`.
|
|
892
|
+
*/
|
|
893
|
+
currentSchema = null;
|
|
894
|
+
typeNames = { ...BUILTIN_TYPES };
|
|
895
|
+
/**
|
|
896
|
+
* Result fields name their source only by OID (`tableID` / `columnID`);
|
|
897
|
+
* the frontend needs schema/table/column names to decide whether a grid is
|
|
898
|
+
* editable and to build UPDATEs, so they are resolved here and cached.
|
|
899
|
+
*/
|
|
900
|
+
relations = /* @__PURE__ */ new Map();
|
|
901
|
+
async loadTypeNames() {
|
|
902
|
+
try {
|
|
903
|
+
const res = await this.client.query(
|
|
904
|
+
"SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
|
|
905
|
+
);
|
|
906
|
+
for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
|
|
907
|
+
} catch {
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
async resolveRelations(tableIds) {
|
|
911
|
+
const missing = [...new Set(tableIds)].filter((id) => id > 0 && !this.relations.has(id));
|
|
912
|
+
if (missing.length === 0) return;
|
|
913
|
+
try {
|
|
914
|
+
const res = await this.client.query(
|
|
915
|
+
`SELECT c.oid, n.nspname, c.relname, a.attnum, a.attname
|
|
916
|
+
FROM pg_class c
|
|
917
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
918
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
919
|
+
WHERE c.oid = ANY($1::oid[])`,
|
|
920
|
+
[missing]
|
|
921
|
+
);
|
|
922
|
+
for (const row of res.rows) {
|
|
923
|
+
const oid = Number(row.oid);
|
|
924
|
+
let rel = this.relations.get(oid);
|
|
925
|
+
if (!rel) {
|
|
926
|
+
rel = { schema: row.nspname, table: row.relname, columns: /* @__PURE__ */ new Map() };
|
|
927
|
+
this.relations.set(oid, rel);
|
|
928
|
+
}
|
|
929
|
+
rel.columns.set(Number(row.attnum), row.attname);
|
|
930
|
+
}
|
|
931
|
+
} catch {
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
async query(sql2, database) {
|
|
935
|
+
if (database && database !== this.currentSchema) {
|
|
936
|
+
await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
|
|
937
|
+
this.currentSchema = database;
|
|
938
|
+
}
|
|
939
|
+
const result = await this.client.query({ text: sql2, rowMode: "array" });
|
|
940
|
+
if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
|
|
941
|
+
const results = Array.isArray(result) ? result : [result];
|
|
942
|
+
const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
|
|
943
|
+
if (last.fields.length > 0) {
|
|
944
|
+
await this.resolveRelations(last.fields.map((f) => f.tableID));
|
|
945
|
+
const fields = last.fields.map((field) => {
|
|
946
|
+
const rel = this.relations.get(field.tableID);
|
|
947
|
+
return {
|
|
948
|
+
name: field.name,
|
|
949
|
+
orgName: rel?.columns.get(field.columnID) ?? field.name,
|
|
950
|
+
type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
|
|
951
|
+
table: rel?.table ?? "",
|
|
952
|
+
orgTable: rel?.table ?? "",
|
|
953
|
+
db: rel?.schema ?? "",
|
|
954
|
+
maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
|
|
955
|
+
};
|
|
956
|
+
});
|
|
957
|
+
return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
958
|
+
}
|
|
959
|
+
return {
|
|
960
|
+
fields: [],
|
|
961
|
+
records: [],
|
|
962
|
+
affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
|
|
963
|
+
insertId: 0,
|
|
964
|
+
isResultQuery: false
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
async setReadOnly() {
|
|
968
|
+
await this.client.query("SET default_transaction_read_only = on");
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* `pg_cancel_backend` from a second connection: the busy one cannot carry the
|
|
972
|
+
* request. It cancels the running statement only, leaving the session open.
|
|
973
|
+
*/
|
|
974
|
+
async cancel() {
|
|
975
|
+
const pid = this.client.processID;
|
|
976
|
+
if (!pid) return;
|
|
977
|
+
const canceller = new pg.Client(toClientConfig(this.config));
|
|
978
|
+
try {
|
|
979
|
+
await canceller.connect();
|
|
980
|
+
await canceller.query("SELECT pg_cancel_backend($1)", [pid]);
|
|
981
|
+
} finally {
|
|
982
|
+
await canceller.end().catch(() => void 0);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
async close() {
|
|
986
|
+
await this.client.end();
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
var postgresDriverFactory = {
|
|
990
|
+
async open(config) {
|
|
991
|
+
const client2 = new pg.Client(toClientConfig(config));
|
|
992
|
+
await client2.connect();
|
|
993
|
+
client2.on("error", (err) => console.error("[postgres] Connection error:", err.message));
|
|
994
|
+
const driver = new PostgresDriver(client2, config);
|
|
995
|
+
await driver.loadTypeNames();
|
|
996
|
+
return driver;
|
|
997
|
+
},
|
|
998
|
+
async test(config) {
|
|
999
|
+
const client2 = new pg.Client(toClientConfig(config));
|
|
1000
|
+
await client2.connect();
|
|
1001
|
+
await client2.end();
|
|
1002
|
+
},
|
|
1003
|
+
parseConfig(body) {
|
|
1004
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1005
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
1006
|
+
if (!host || !user) {
|
|
1007
|
+
throw new Error("host and user are required");
|
|
1008
|
+
}
|
|
1009
|
+
return {
|
|
1010
|
+
driver: "postgres",
|
|
1011
|
+
host,
|
|
1012
|
+
port: Number(body.port) || 5432,
|
|
1013
|
+
user,
|
|
1014
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
1015
|
+
database: typeof body.database === "string" && body.database ? body.database : "postgres",
|
|
1016
|
+
ssl: body.ssl === true,
|
|
1017
|
+
sslVerify: body.sslVerify === true
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
// src/drivers/sqlite.ts
|
|
1023
|
+
import { createRequire } from "module";
|
|
1024
|
+
import { existsSync, realpathSync } from "fs";
|
|
1025
|
+
import { homedir as homedir5 } from "os";
|
|
1026
|
+
import { basename, dirname, join as join6, relative, resolve, sep } from "path";
|
|
1027
|
+
function normalizeDeclaredType(declared) {
|
|
1028
|
+
if (!declared) return null;
|
|
1029
|
+
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
1030
|
+
return base || null;
|
|
1031
|
+
}
|
|
1032
|
+
function inferTypeFromValue(value) {
|
|
1033
|
+
if (value === null || value === void 0) return "null";
|
|
1034
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
1035
|
+
if (typeof value === "bigint") return "bigint";
|
|
1036
|
+
if (Buffer.isBuffer(value)) return "blob";
|
|
1037
|
+
return "text";
|
|
1038
|
+
}
|
|
1039
|
+
var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome(process.env.PLANCHE_SQLITE_DIR)) : null;
|
|
1040
|
+
var DEFAULT_ROOT = homedir5();
|
|
1041
|
+
var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
|
|
1042
|
+
var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
|
|
1043
|
+
var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
|
|
1044
|
+
function expandHome(path) {
|
|
1045
|
+
return path === "~" || path.startsWith("~/") ? resolve(homedir5(), path.slice(2)) : path;
|
|
1046
|
+
}
|
|
1047
|
+
function realish(file) {
|
|
1048
|
+
let probe = file;
|
|
1049
|
+
const rest = [];
|
|
1050
|
+
for (; ; ) {
|
|
1051
|
+
try {
|
|
1052
|
+
return rest.length ? join6(realpathSync.native(probe), ...rest) : realpathSync.native(probe);
|
|
1053
|
+
} catch {
|
|
1054
|
+
const parent = dirname(probe);
|
|
1055
|
+
if (parent === probe) return file;
|
|
1056
|
+
rest.unshift(basename(probe));
|
|
1057
|
+
probe = parent;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
function isInside(path, root) {
|
|
1062
|
+
return path === root || path.startsWith(root + sep);
|
|
1063
|
+
}
|
|
1064
|
+
function resolveFile(file, forCreate = false) {
|
|
1065
|
+
if (file === ":memory:") return file;
|
|
1066
|
+
if (file.startsWith("file:")) throw new Error("SQLite URI filenames are not supported by the agent; give a plain path");
|
|
1067
|
+
const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome(file)));
|
|
1068
|
+
if (SQLITE_DIR) {
|
|
1069
|
+
if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
|
|
1070
|
+
} else {
|
|
1071
|
+
const root = realish(DEFAULT_ROOT);
|
|
1072
|
+
if (!isInside(resolved, root)) throw new Error("SQLite files outside your home directory need PLANCHE_SQLITE_DIR on the agent");
|
|
1073
|
+
const parts = relative(root, resolved).split(sep);
|
|
1074
|
+
if (parts.some((part) => part.startsWith("."))) throw new Error("SQLite files in hidden directories need PLANCHE_SQLITE_DIR on the agent");
|
|
1075
|
+
if (DENIED_UNDER_HOME.some((d) => d.toLowerCase() === parts[0].toLowerCase())) throw new Error(`SQLite files under ~/${parts[0]} need PLANCHE_SQLITE_DIR on the agent`);
|
|
1076
|
+
}
|
|
1077
|
+
if (forCreate && !DB_EXTENSIONS.test(resolved)) {
|
|
1078
|
+
throw new Error("A new SQLite file must end with .db, .db3, .s3db, .sl3, .sqlite or .sqlite3");
|
|
1079
|
+
}
|
|
1080
|
+
return resolved;
|
|
1081
|
+
}
|
|
1082
|
+
var ctor = null;
|
|
1083
|
+
function sqliteCtor() {
|
|
1084
|
+
ctor ??= createRequire(import.meta.url)("better-sqlite3");
|
|
1085
|
+
return ctor;
|
|
1086
|
+
}
|
|
1087
|
+
function openDatabase(config) {
|
|
1088
|
+
const file = resolveFile(config.file, config.createIfMissing === true);
|
|
1089
|
+
const isMemory = file === ":memory:";
|
|
1090
|
+
if (!isMemory && !config.createIfMissing && !existsSync(file)) {
|
|
1091
|
+
throw new Error(`SQLite file not found: ${config.file}`);
|
|
1092
|
+
}
|
|
1093
|
+
const db = new (sqliteCtor())(file, {
|
|
1094
|
+
readonly: config.readonly ?? false,
|
|
1095
|
+
fileMustExist: !isMemory && !config.createIfMissing,
|
|
1096
|
+
// A host with another ABI (the Electron shell) supplies its own build of
|
|
1097
|
+
// the binding instead of rebuilding the one installed for Node.
|
|
1098
|
+
nativeBinding: process.env.PLANCHE_SQLITE_BINDING || void 0
|
|
1099
|
+
});
|
|
1100
|
+
db.pragma("trusted_schema = OFF");
|
|
1101
|
+
if (config.readonly) db.pragma("query_only = ON");
|
|
1102
|
+
return db;
|
|
1103
|
+
}
|
|
1104
|
+
function stripLiteralsAndComments(sql2) {
|
|
1105
|
+
let out = "";
|
|
1106
|
+
let i = 0;
|
|
1107
|
+
while (i < sql2.length) {
|
|
1108
|
+
const c = sql2[i];
|
|
1109
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
1110
|
+
let j = i + 1;
|
|
1111
|
+
while (j < sql2.length) {
|
|
1112
|
+
if (sql2[j] === c) {
|
|
1113
|
+
if (sql2[j + 1] === c) j += 2;
|
|
1114
|
+
else {
|
|
1115
|
+
j++;
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
1118
|
+
} else j++;
|
|
1119
|
+
}
|
|
1120
|
+
out += c + c;
|
|
1121
|
+
i = j;
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
if (c === "[") {
|
|
1125
|
+
const j = sql2.indexOf("]", i);
|
|
1126
|
+
i = j < 0 ? sql2.length : j + 1;
|
|
1127
|
+
out += "[]";
|
|
1128
|
+
continue;
|
|
1129
|
+
}
|
|
1130
|
+
if (c === "-" && sql2[i + 1] === "-") {
|
|
1131
|
+
const j = sql2.indexOf("\n", i);
|
|
1132
|
+
i = j < 0 ? sql2.length : j;
|
|
1133
|
+
out += " ";
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
if (c === "/" && sql2[i + 1] === "*") {
|
|
1137
|
+
const j = sql2.indexOf("*/", i + 2);
|
|
1138
|
+
i = j < 0 ? sql2.length : j + 2;
|
|
1139
|
+
out += " ";
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
out += c;
|
|
1143
|
+
i++;
|
|
1144
|
+
}
|
|
1145
|
+
return out;
|
|
1146
|
+
}
|
|
1147
|
+
function assertNoFileStatements(sql2) {
|
|
1148
|
+
const text = stripLiteralsAndComments(sql2);
|
|
1149
|
+
if (!ALLOW_ATTACH && /\bATTACH\b/i.test(text)) {
|
|
1150
|
+
throw new Error("ATTACH DATABASE is disabled on the agent (set PLANCHE_SQLITE_ATTACH=1 to allow it)");
|
|
1151
|
+
}
|
|
1152
|
+
if (/\bVACUUM\b[^;]*\bINTO\b/i.test(text)) {
|
|
1153
|
+
throw new Error("VACUUM INTO is not available through the agent");
|
|
1154
|
+
}
|
|
1155
|
+
if (/\bPRAGMA\s+temp_store_directory\b/i.test(text)) {
|
|
1156
|
+
throw new Error("PRAGMA temp_store_directory is not available through the agent");
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
var SqliteDriver = class {
|
|
1160
|
+
constructor(db, config) {
|
|
1161
|
+
this.db = db;
|
|
1162
|
+
this.label = config.file;
|
|
1163
|
+
}
|
|
1164
|
+
db;
|
|
1165
|
+
type = "sqlite";
|
|
1166
|
+
label;
|
|
1167
|
+
async query(sql2) {
|
|
1168
|
+
assertNoFileStatements(sql2);
|
|
1169
|
+
let statement;
|
|
1170
|
+
try {
|
|
1171
|
+
statement = this.db.prepare(sql2);
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
|
|
1174
|
+
this.db.exec(sql2);
|
|
1175
|
+
return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
|
|
1176
|
+
}
|
|
1177
|
+
throw err;
|
|
1178
|
+
}
|
|
1179
|
+
if (!statement.reader) {
|
|
1180
|
+
const info = statement.run();
|
|
1181
|
+
return {
|
|
1182
|
+
fields: [],
|
|
1183
|
+
records: [],
|
|
1184
|
+
affectedRows: info.changes,
|
|
1185
|
+
insertId: Number(info.lastInsertRowid),
|
|
1186
|
+
isResultQuery: false
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
const columns = statement.columns();
|
|
1190
|
+
const records = statement.raw(true).all();
|
|
1191
|
+
const fields = columns.map((col, index) => {
|
|
1192
|
+
const declared = normalizeDeclaredType(col.type);
|
|
1193
|
+
const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
|
|
1194
|
+
return {
|
|
1195
|
+
name: col.name,
|
|
1196
|
+
orgName: col.column ?? col.name,
|
|
1197
|
+
type,
|
|
1198
|
+
table: col.table ?? "",
|
|
1199
|
+
orgTable: col.table ?? "",
|
|
1200
|
+
db: col.database ?? "",
|
|
1201
|
+
maxLength: 0
|
|
1202
|
+
};
|
|
1203
|
+
});
|
|
1204
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1205
|
+
}
|
|
1206
|
+
async setReadOnly() {
|
|
1207
|
+
this.db.pragma("query_only = 1");
|
|
1208
|
+
}
|
|
1209
|
+
async close() {
|
|
1210
|
+
if (this.db.inTransaction) {
|
|
1211
|
+
try {
|
|
1212
|
+
this.db.exec("ROLLBACK");
|
|
1213
|
+
} catch {
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
this.db.close();
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
function firstNonNull(records, index) {
|
|
1220
|
+
for (const row of records) {
|
|
1221
|
+
const value = row[index];
|
|
1222
|
+
if (value !== null && value !== void 0) return value;
|
|
1223
|
+
}
|
|
1224
|
+
return null;
|
|
1225
|
+
}
|
|
1226
|
+
var sqliteDriverFactory = {
|
|
1227
|
+
async open(config) {
|
|
1228
|
+
return new SqliteDriver(openDatabase(config), config);
|
|
1229
|
+
},
|
|
1230
|
+
async test(config) {
|
|
1231
|
+
const db = openDatabase(config);
|
|
1232
|
+
try {
|
|
1233
|
+
db.prepare("SELECT 1").get();
|
|
1234
|
+
} finally {
|
|
1235
|
+
db.close();
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
parseConfig(body) {
|
|
1239
|
+
const file = typeof body.file === "string" ? body.file.trim() : "";
|
|
1240
|
+
if (!file) {
|
|
1241
|
+
throw new Error("file is required");
|
|
1242
|
+
}
|
|
1243
|
+
return {
|
|
1244
|
+
driver: "sqlite",
|
|
1245
|
+
file,
|
|
1246
|
+
createIfMissing: body.createIfMissing === true,
|
|
1247
|
+
readonly: body.readonly === true
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
// src/drivers/mssql.ts
|
|
1253
|
+
import sql from "mssql";
|
|
1254
|
+
function toPoolConfig(config) {
|
|
1255
|
+
return {
|
|
1256
|
+
server: config.host,
|
|
1257
|
+
port: config.port,
|
|
1258
|
+
user: config.user,
|
|
1259
|
+
password: config.password,
|
|
1260
|
+
database: config.database,
|
|
1261
|
+
// One physical connection: manual transactions and USE need session affinity.
|
|
1262
|
+
pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
|
|
1263
|
+
options: {
|
|
1264
|
+
encrypt: config.ssl === true,
|
|
1265
|
+
trustServerCertificate: config.sslVerify !== true,
|
|
1266
|
+
// Keep date/time values as sent by the server rather than shifting to UTC.
|
|
1267
|
+
useUTC: false,
|
|
1268
|
+
enableArithAbort: true
|
|
1269
|
+
},
|
|
1270
|
+
requestTimeout: 0
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
var pad = (n, width = 2) => String(n).padStart(width, "0");
|
|
1274
|
+
function formatDate(d) {
|
|
1275
|
+
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
1276
|
+
const ms = d.getMilliseconds();
|
|
1277
|
+
return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? "." + pad(ms, 3) : ""}`;
|
|
1278
|
+
}
|
|
1279
|
+
function normalizeValue(value) {
|
|
1280
|
+
if (value instanceof Date) return formatDate(value);
|
|
1281
|
+
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
1282
|
+
return value;
|
|
1283
|
+
}
|
|
1284
|
+
var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
|
|
1285
|
+
var MssqlDriver = class {
|
|
1286
|
+
constructor(pool, config) {
|
|
1287
|
+
this.pool = pool;
|
|
1288
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
1289
|
+
}
|
|
1290
|
+
pool;
|
|
1291
|
+
type = "mssql";
|
|
1292
|
+
label;
|
|
1293
|
+
/** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
|
|
1294
|
+
sources = /* @__PURE__ */ new Map();
|
|
1295
|
+
/**
|
|
1296
|
+
* The `mssql` driver does not report which table a result column comes
|
|
1297
|
+
* from; ask the server to describe the statement instead so the grid can
|
|
1298
|
+
* decide whether it is editable and address rows by primary key.
|
|
1299
|
+
*/
|
|
1300
|
+
async describe(text) {
|
|
1301
|
+
const cached3 = this.sources.get(text);
|
|
1302
|
+
if (cached3 !== void 0) return cached3;
|
|
1303
|
+
let result = null;
|
|
1304
|
+
try {
|
|
1305
|
+
const req = this.pool.request();
|
|
1306
|
+
req.input("sql", sql.NVarChar(sql.MAX), text);
|
|
1307
|
+
const res = await req.query(
|
|
1308
|
+
"SELECT name, source_schema, source_table, source_column, system_type_name FROM sys.dm_exec_describe_first_result_set(@sql, NULL, 1) WHERE is_hidden = 0 ORDER BY column_ordinal"
|
|
1309
|
+
);
|
|
1310
|
+
result = res.recordset.map((r) => ({
|
|
1311
|
+
schema: r.source_schema ?? "",
|
|
1312
|
+
table: r.source_table ?? "",
|
|
1313
|
+
column: r.source_column ?? "",
|
|
1314
|
+
type: (r.system_type_name ?? "").toLowerCase()
|
|
1315
|
+
}));
|
|
1316
|
+
} catch {
|
|
1317
|
+
result = null;
|
|
1318
|
+
}
|
|
1319
|
+
if (this.sources.size > 200) this.sources.clear();
|
|
1320
|
+
this.sources.set(text, result);
|
|
1321
|
+
return result;
|
|
1322
|
+
}
|
|
1323
|
+
/** The request in flight, so a cancel can interrupt it. */
|
|
1324
|
+
running = null;
|
|
1325
|
+
/** `tedious` sends an attention packet; the statement ends and the pool stays usable. */
|
|
1326
|
+
async cancel() {
|
|
1327
|
+
this.running?.cancel();
|
|
1328
|
+
}
|
|
1329
|
+
async query(text) {
|
|
1330
|
+
const req = this.pool.request();
|
|
1331
|
+
req.arrayRowMode = true;
|
|
1332
|
+
this.running = req;
|
|
1333
|
+
let res;
|
|
1334
|
+
try {
|
|
1335
|
+
res = await req.query(text);
|
|
1336
|
+
} finally {
|
|
1337
|
+
if (this.running === req) this.running = null;
|
|
1338
|
+
}
|
|
1339
|
+
const sets = res.recordsets;
|
|
1340
|
+
const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
|
|
1341
|
+
const columns = last?.columns;
|
|
1342
|
+
if (last && columns) {
|
|
1343
|
+
const meta = Object.values(columns).sort((a, b) => a.index - b.index);
|
|
1344
|
+
const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
|
|
1345
|
+
const fields = meta.map((col, i) => {
|
|
1346
|
+
const src = sources && sources.length === meta.length ? sources[i] : void 0;
|
|
1347
|
+
const typeName = col.type?.declaration ?? "";
|
|
1348
|
+
return {
|
|
1349
|
+
name: col.name,
|
|
1350
|
+
orgName: src?.column || col.name,
|
|
1351
|
+
type: src?.type || typeName.toLowerCase(),
|
|
1352
|
+
table: src?.table ?? "",
|
|
1353
|
+
orgTable: src?.table ?? "",
|
|
1354
|
+
db: src?.schema ?? "",
|
|
1355
|
+
maxLength: typeof col.length === "number" ? col.length : 0
|
|
1356
|
+
};
|
|
1357
|
+
});
|
|
1358
|
+
const records = last.map((row) => row.map(normalizeValue));
|
|
1359
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1360
|
+
}
|
|
1361
|
+
return {
|
|
1362
|
+
fields: [],
|
|
1363
|
+
records: [],
|
|
1364
|
+
affectedRows: res.rowsAffected.reduce((sum, n) => sum + n, 0),
|
|
1365
|
+
insertId: 0,
|
|
1366
|
+
isResultQuery: false
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
async close() {
|
|
1370
|
+
await this.pool.close();
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
var mssqlDriverFactory = {
|
|
1374
|
+
async open(config) {
|
|
1375
|
+
const pool = new sql.ConnectionPool(toPoolConfig(config));
|
|
1376
|
+
await pool.connect();
|
|
1377
|
+
return new MssqlDriver(pool, config);
|
|
1378
|
+
},
|
|
1379
|
+
async test(config) {
|
|
1380
|
+
const pool = new sql.ConnectionPool(toPoolConfig(config));
|
|
1381
|
+
await pool.connect();
|
|
1382
|
+
await pool.close();
|
|
1383
|
+
},
|
|
1384
|
+
parseConfig(body) {
|
|
1385
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1386
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
1387
|
+
if (!host || !user) {
|
|
1388
|
+
throw new Error("host and user are required");
|
|
1389
|
+
}
|
|
1390
|
+
return {
|
|
1391
|
+
driver: "mssql",
|
|
1392
|
+
host,
|
|
1393
|
+
port: Number(body.port) || 1433,
|
|
1394
|
+
user,
|
|
1395
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
1396
|
+
database: typeof body.database === "string" && body.database ? body.database : "master",
|
|
1397
|
+
ssl: body.ssl === true,
|
|
1398
|
+
sslVerify: body.sslVerify === true
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
// src/drivers/oracle.ts
|
|
1404
|
+
import oracledb from "oracledb";
|
|
1405
|
+
oracledb.autoCommit = true;
|
|
1406
|
+
oracledb.fetchAsString = [oracledb.CLOB];
|
|
1407
|
+
var fetchTypeHandler = (meta) => meta.dbType === oracledb.DB_TYPE_NUMBER ? { type: oracledb.STRING } : void 0;
|
|
1408
|
+
var pad2 = (n, width = 2) => String(n).padStart(width, "0");
|
|
1409
|
+
function formatDate2(d) {
|
|
1410
|
+
const ms = d.getMilliseconds();
|
|
1411
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${ms ? "." + pad2(ms, 3) : ""}`;
|
|
1412
|
+
}
|
|
1413
|
+
function normalizeValue2(value) {
|
|
1414
|
+
if (value instanceof Date) return formatDate2(value);
|
|
1415
|
+
if (Buffer.isBuffer(value)) return value.toString("hex");
|
|
1416
|
+
return value;
|
|
1417
|
+
}
|
|
1418
|
+
var SESSION_FORMATS = [
|
|
1419
|
+
"ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'",
|
|
1420
|
+
"ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3'",
|
|
1421
|
+
"ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3 TZH:TZM'"
|
|
1422
|
+
];
|
|
1423
|
+
var quoteIdent2 = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
1424
|
+
var PLSQL = /^\s*(BEGIN|DECLARE|CREATE\s+(OR\s+REPLACE\s+)?((NON)?EDITIONABLE\s+)?(PROCEDURE|FUNCTION|TRIGGER|PACKAGE|TYPE)\b)/i;
|
|
1425
|
+
function normalizeStatement(sql2) {
|
|
1426
|
+
const trimmed = sql2.trim().replace(/\n\/\s*$/, "");
|
|
1427
|
+
if (PLSQL.test(trimmed)) return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
|
|
1428
|
+
return trimmed.replace(/;\s*$/, "");
|
|
1429
|
+
}
|
|
1430
|
+
var SET_SCHEMA = /^\s*ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\b/i;
|
|
1431
|
+
var START_TX = /^\s*SET\s+TRANSACTION\b/i;
|
|
1432
|
+
var END_TX = /^\s*(COMMIT|ROLLBACK)\b/i;
|
|
1433
|
+
function singleSourceTable(sql2) {
|
|
1434
|
+
const m = /\bFROM\s+("?[\w$#]+"?)(?:\s*\.\s*("?[\w$#]+"?))?([\s\S]*)$/i.exec(sql2);
|
|
1435
|
+
if (!m) return null;
|
|
1436
|
+
const rest = m[3].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|FETCH|OFFSET|UNION|MINUS|INTERSECT|FOR\s+UPDATE)\b[\s\S]*$/i, "");
|
|
1437
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
1438
|
+
const unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replaceAll('""', '"') : v.toUpperCase();
|
|
1439
|
+
return m[2] ? { schema: unquote(m[1]), table: unquote(m[2]) } : { table: unquote(m[1]) };
|
|
1440
|
+
}
|
|
1441
|
+
var OracleDriver = class {
|
|
1442
|
+
constructor(conn, config) {
|
|
1443
|
+
this.conn = conn;
|
|
1444
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
|
|
1445
|
+
this.currentSchema = config.user.toUpperCase();
|
|
1446
|
+
}
|
|
1447
|
+
conn;
|
|
1448
|
+
type = "oracle";
|
|
1449
|
+
label;
|
|
1450
|
+
currentSchema;
|
|
1451
|
+
manualTx = false;
|
|
1452
|
+
/** Column names per `OWNER.TABLE`, for result-source resolution. */
|
|
1453
|
+
columns = /* @__PURE__ */ new Map();
|
|
1454
|
+
async init() {
|
|
1455
|
+
for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
|
|
1456
|
+
}
|
|
1457
|
+
async tableColumns(schema, table) {
|
|
1458
|
+
const key = `${schema}.${table}`;
|
|
1459
|
+
const cached3 = this.columns.get(key);
|
|
1460
|
+
if (cached3 !== void 0) return cached3;
|
|
1461
|
+
let out = null;
|
|
1462
|
+
try {
|
|
1463
|
+
const res = await this.conn.execute(
|
|
1464
|
+
"SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
|
|
1465
|
+
{ o: schema, t: table },
|
|
1466
|
+
{ outFormat: oracledb.OUT_FORMAT_ARRAY }
|
|
1467
|
+
);
|
|
1468
|
+
out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
|
|
1469
|
+
} catch {
|
|
1470
|
+
out = null;
|
|
1471
|
+
}
|
|
1472
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
1473
|
+
this.columns.set(key, out);
|
|
1474
|
+
return out;
|
|
1475
|
+
}
|
|
1476
|
+
async query(sql2, database) {
|
|
1477
|
+
if (database && database !== this.currentSchema) {
|
|
1478
|
+
await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
|
|
1479
|
+
this.currentSchema = database;
|
|
1480
|
+
}
|
|
1481
|
+
const text = normalizeStatement(sql2);
|
|
1482
|
+
if (START_TX.test(text)) this.manualTx = true;
|
|
1483
|
+
const res = await this.conn.execute(text, [], {
|
|
1484
|
+
outFormat: oracledb.OUT_FORMAT_ARRAY,
|
|
1485
|
+
fetchTypeHandler,
|
|
1486
|
+
autoCommit: !this.manualTx
|
|
1487
|
+
});
|
|
1488
|
+
if (SET_SCHEMA.test(text)) this.currentSchema = null;
|
|
1489
|
+
if (END_TX.test(text)) this.manualTx = false;
|
|
1490
|
+
if (res.metaData && res.metaData.length > 0) {
|
|
1491
|
+
const source = singleSourceTable(text);
|
|
1492
|
+
const schema = source?.schema ?? this.currentSchema ?? "";
|
|
1493
|
+
const known = source && schema ? await this.tableColumns(schema, source.table) : null;
|
|
1494
|
+
const fields = res.metaData.map((col) => {
|
|
1495
|
+
const fromTable = known?.has(col.name) ?? false;
|
|
1496
|
+
return {
|
|
1497
|
+
name: col.name,
|
|
1498
|
+
orgName: col.name,
|
|
1499
|
+
type: (col.dbTypeName ?? "").toLowerCase(),
|
|
1500
|
+
table: fromTable ? source.table : "",
|
|
1501
|
+
orgTable: fromTable ? source.table : "",
|
|
1502
|
+
db: fromTable ? schema : "",
|
|
1503
|
+
maxLength: col.byteSize ?? 0
|
|
1504
|
+
};
|
|
1505
|
+
});
|
|
1506
|
+
const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
|
|
1507
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1508
|
+
}
|
|
1509
|
+
return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* `break()` interrupts the call in progress; the session then needs
|
|
1513
|
+
* `reset()` before it can be used again.
|
|
1514
|
+
*/
|
|
1515
|
+
async cancel() {
|
|
1516
|
+
await this.conn.break();
|
|
1517
|
+
await this.conn.reset?.().catch(() => void 0);
|
|
1518
|
+
}
|
|
1519
|
+
async close() {
|
|
1520
|
+
await this.conn.close();
|
|
1521
|
+
}
|
|
1522
|
+
};
|
|
1523
|
+
function connectionAttributes(config) {
|
|
1524
|
+
return {
|
|
1525
|
+
user: config.user,
|
|
1526
|
+
password: config.password,
|
|
1527
|
+
connectString: `${config.host}:${config.port}/${config.serviceName}`
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
var oracleDriverFactory = {
|
|
1531
|
+
async open(config) {
|
|
1532
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
1533
|
+
const driver = new OracleDriver(conn, config);
|
|
1534
|
+
await driver.init();
|
|
1535
|
+
return driver;
|
|
1536
|
+
},
|
|
1537
|
+
async test(config) {
|
|
1538
|
+
const conn = await oracledb.getConnection(connectionAttributes(config));
|
|
1539
|
+
await conn.close();
|
|
1540
|
+
},
|
|
1541
|
+
parseConfig(body) {
|
|
1542
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
1543
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
1544
|
+
if (!host || !user) {
|
|
1545
|
+
throw new Error("host and user are required");
|
|
1546
|
+
}
|
|
1547
|
+
if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
|
|
1548
|
+
const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
|
|
1549
|
+
if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
|
|
1550
|
+
return {
|
|
1551
|
+
driver: "oracle",
|
|
1552
|
+
host,
|
|
1553
|
+
port: Number(body.port) || 1521,
|
|
1554
|
+
user,
|
|
1555
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
1556
|
+
serviceName
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
// src/drivers/d1.ts
|
|
1562
|
+
var API = "https://api.cloudflare.com/client/v4";
|
|
1563
|
+
var LOCAL_API = "/cdn-cgi/local/explorer/api";
|
|
1564
|
+
var SELECT_STAR = /^\s*SELECT\s+(?:DISTINCT\s+)?\*/i;
|
|
1565
|
+
var localRawUrl = (origin, databaseId) => `${origin}${LOCAL_API}/d1/database/${encodeURIComponent(databaseId)}/raw`;
|
|
1566
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", "0.0.0.0"]);
|
|
1567
|
+
function parseDevUrl(raw) {
|
|
1568
|
+
let url;
|
|
1569
|
+
try {
|
|
1570
|
+
url = new URL(raw);
|
|
1571
|
+
} catch {
|
|
1572
|
+
throw new Error(`Not a valid URL: ${raw}`);
|
|
1573
|
+
}
|
|
1574
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("devUrl must be an http(s) URL");
|
|
1575
|
+
if (!LOOPBACK.has(url.hostname.toLowerCase())) {
|
|
1576
|
+
throw new Error("devUrl must point at a wrangler dev server on this machine (localhost).");
|
|
1577
|
+
}
|
|
1578
|
+
return url.origin;
|
|
1579
|
+
}
|
|
1580
|
+
var D1Error = class extends Error {
|
|
1581
|
+
constructor(message, status) {
|
|
1582
|
+
super(message);
|
|
1583
|
+
this.status = status;
|
|
1584
|
+
}
|
|
1585
|
+
status;
|
|
1586
|
+
};
|
|
1587
|
+
var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
|
|
1588
|
+
var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
|
|
1589
|
+
var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
|
|
1590
|
+
function singleSourceTable2(sql2) {
|
|
1591
|
+
const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
|
|
1592
|
+
if (!m) return null;
|
|
1593
|
+
const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
|
|
1594
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
1595
|
+
const raw = m[1];
|
|
1596
|
+
const first = raw[0];
|
|
1597
|
+
if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
|
|
1598
|
+
if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
|
|
1599
|
+
if (first === "[") return raw.slice(1, -1);
|
|
1600
|
+
return raw;
|
|
1601
|
+
}
|
|
1602
|
+
function normalizeDeclaredType2(declared) {
|
|
1603
|
+
if (typeof declared !== "string") return null;
|
|
1604
|
+
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
1605
|
+
return base || null;
|
|
1606
|
+
}
|
|
1607
|
+
function inferTypeFromValue2(value) {
|
|
1608
|
+
if (value === null || value === void 0) return "null";
|
|
1609
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
1610
|
+
if (Array.isArray(value)) return "blob";
|
|
1611
|
+
return "text";
|
|
1612
|
+
}
|
|
1613
|
+
function firstNonNull2(records, index) {
|
|
1614
|
+
for (const row of records) {
|
|
1615
|
+
const value = row[index];
|
|
1616
|
+
if (value !== null && value !== void 0) return value;
|
|
1617
|
+
}
|
|
1618
|
+
return null;
|
|
1619
|
+
}
|
|
1620
|
+
var D1Driver = class {
|
|
1621
|
+
type = "d1";
|
|
1622
|
+
label;
|
|
1623
|
+
/** Not readonly: a local database resolves its id lazily (see `resolveUrl`). */
|
|
1624
|
+
url;
|
|
1625
|
+
headers;
|
|
1626
|
+
/** Declared column types per table, for result-source resolution. */
|
|
1627
|
+
columns = /* @__PURE__ */ new Map();
|
|
1628
|
+
/**
|
|
1629
|
+
* D1 is a REST call, not a session: there is no cancel to send, so stopping
|
|
1630
|
+
* means dropping the request the agent is waiting on. The query may still
|
|
1631
|
+
* finish at Cloudflare, which is why this is `abandon` and not `kill`.
|
|
1632
|
+
*/
|
|
1633
|
+
cancelKind = "abandon";
|
|
1634
|
+
inFlight = null;
|
|
1635
|
+
async cancel() {
|
|
1636
|
+
this.inFlight?.abort();
|
|
1637
|
+
}
|
|
1638
|
+
/** Local databases resolve their id on first use; the remote ones know it up front. */
|
|
1639
|
+
local;
|
|
1640
|
+
resolving = null;
|
|
1641
|
+
constructor(config) {
|
|
1642
|
+
this.local = config.devUrl ?? null;
|
|
1643
|
+
this.label = `d1:${config.databaseId || this.local || ""}`;
|
|
1644
|
+
if (this.local) {
|
|
1645
|
+
this.headers = { "Content-Type": "application/json" };
|
|
1646
|
+
this.url = config.databaseId ? localRawUrl(this.local, config.databaseId) : "";
|
|
1647
|
+
} else {
|
|
1648
|
+
this.headers = { Authorization: `Bearer ${config.apiToken ?? ""}`, "Content-Type": "application/json" };
|
|
1649
|
+
this.url = `${API}/accounts/${encodeURIComponent(config.accountId ?? "")}/d1/database/${encodeURIComponent(config.databaseId ?? "")}/raw`;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* A local dev server usually binds one database, so the connection form does
|
|
1654
|
+
* not have to ask for a uuid it would only copy from wrangler's config.
|
|
1655
|
+
*/
|
|
1656
|
+
async resolveUrl() {
|
|
1657
|
+
if (this.url) return this.url;
|
|
1658
|
+
this.resolving ??= (async () => {
|
|
1659
|
+
const listed = await fetch(`${this.local}${LOCAL_API}/d1/database`, { signal: AbortSignal.timeout(1e4) });
|
|
1660
|
+
if (!listed.ok) throw new D1Error(`No wrangler dev server answering at ${this.local}`, 400);
|
|
1661
|
+
const body = await listed.json();
|
|
1662
|
+
const bound = body.result ?? [];
|
|
1663
|
+
if (bound.length === 0) throw new D1Error("That wrangler dev server has no D1 database bound.", 400);
|
|
1664
|
+
const databases = [...new Map(bound.map((d) => [d.uuid ?? "", d])).values()];
|
|
1665
|
+
if (databases.length > 1) {
|
|
1666
|
+
const choices = databases.map((d) => `${d.name ?? "?"} (${d.uuid ?? "?"})`).join(", ");
|
|
1667
|
+
throw new D1Error(
|
|
1668
|
+
`That dev server binds ${databases.length} databases; put one of these ids in the Database ID field: ${choices}`,
|
|
1669
|
+
400
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
this.url = localRawUrl(this.local, databases[0].uuid ?? "");
|
|
1673
|
+
return this.url;
|
|
1674
|
+
})();
|
|
1675
|
+
return this.resolving;
|
|
1676
|
+
}
|
|
1677
|
+
async raw(sql2) {
|
|
1678
|
+
const url = this.url || await this.resolveUrl();
|
|
1679
|
+
let res;
|
|
1680
|
+
try {
|
|
1681
|
+
const controller = new AbortController();
|
|
1682
|
+
this.inFlight = controller;
|
|
1683
|
+
try {
|
|
1684
|
+
res = await fetch(url, {
|
|
1685
|
+
method: "POST",
|
|
1686
|
+
headers: this.headers,
|
|
1687
|
+
body: JSON.stringify({ sql: sql2 }),
|
|
1688
|
+
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(6e4)])
|
|
1689
|
+
});
|
|
1690
|
+
} finally {
|
|
1691
|
+
if (this.inFlight === controller) this.inFlight = null;
|
|
1692
|
+
}
|
|
1693
|
+
} catch (err) {
|
|
1694
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
1695
|
+
throw new D1Error("Stopped waiting for D1; the query may still finish at Cloudflare.", 400);
|
|
1696
|
+
}
|
|
1697
|
+
throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
1698
|
+
}
|
|
1699
|
+
let body = null;
|
|
1700
|
+
try {
|
|
1701
|
+
body = await res.json();
|
|
1702
|
+
} catch {
|
|
1703
|
+
}
|
|
1704
|
+
if (!res.ok || !body?.success) {
|
|
1705
|
+
const message = (body?.errors?.map((e) => String(e.message)).join("; ") || `Cloudflare API responded ${res.status}`).slice(0, 500);
|
|
1706
|
+
throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
|
|
1707
|
+
}
|
|
1708
|
+
const result = body.result?.[0];
|
|
1709
|
+
if (!result) throw new D1Error("Cloudflare API returned no result");
|
|
1710
|
+
return result;
|
|
1711
|
+
}
|
|
1712
|
+
async tableColumns(table) {
|
|
1713
|
+
const cached3 = this.columns.get(table);
|
|
1714
|
+
if (cached3 !== void 0) return cached3;
|
|
1715
|
+
let out = null;
|
|
1716
|
+
try {
|
|
1717
|
+
const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
1718
|
+
const cols = info.results?.columns ?? [];
|
|
1719
|
+
const name = cols.indexOf("name");
|
|
1720
|
+
const type = cols.indexOf("type");
|
|
1721
|
+
const rows = info.results?.rows ?? [];
|
|
1722
|
+
if (rows.length > 0 && name >= 0) {
|
|
1723
|
+
out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
|
|
1724
|
+
}
|
|
1725
|
+
} catch {
|
|
1726
|
+
out = null;
|
|
1727
|
+
}
|
|
1728
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
1729
|
+
this.columns.set(table, out);
|
|
1730
|
+
return out;
|
|
1731
|
+
}
|
|
1732
|
+
async query(sql2) {
|
|
1733
|
+
if (DATABASE_LIST.test(sql2)) {
|
|
1734
|
+
return {
|
|
1735
|
+
fields: [
|
|
1736
|
+
{ name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
1737
|
+
{ name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
|
|
1738
|
+
{ name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
|
|
1739
|
+
],
|
|
1740
|
+
records: [[0, "main", ""]],
|
|
1741
|
+
affectedRows: 0,
|
|
1742
|
+
insertId: 0,
|
|
1743
|
+
isResultQuery: true
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
|
|
1747
|
+
if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
|
|
1748
|
+
throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
|
|
1749
|
+
}
|
|
1750
|
+
if (this.isDdl(text)) this.columns.clear();
|
|
1751
|
+
const result = await this.raw(text);
|
|
1752
|
+
const columns = result.results?.columns ?? [];
|
|
1753
|
+
const records = result.results?.rows ?? [];
|
|
1754
|
+
if (columns.length === 0 && !RESULT_KIND.test(text)) {
|
|
1755
|
+
return {
|
|
1756
|
+
fields: [],
|
|
1757
|
+
records: [],
|
|
1758
|
+
affectedRows: result.meta?.changes ?? 0,
|
|
1759
|
+
insertId: result.meta?.last_row_id ?? 0,
|
|
1760
|
+
isResultQuery: false
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
|
|
1764
|
+
const known = source ? await this.tableColumns(source) : null;
|
|
1765
|
+
const names = columns.length > 0 ? columns : known && SELECT_STAR.test(text) ? [...known.keys()] : [];
|
|
1766
|
+
const fields = names.map((name, index) => {
|
|
1767
|
+
const fromTable = known?.has(name) ?? false;
|
|
1768
|
+
const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
|
|
1769
|
+
return {
|
|
1770
|
+
name,
|
|
1771
|
+
orgName: name,
|
|
1772
|
+
type,
|
|
1773
|
+
table: fromTable && source ? source : "",
|
|
1774
|
+
orgTable: fromTable && source ? source : "",
|
|
1775
|
+
db: fromTable ? "main" : "",
|
|
1776
|
+
maxLength: 0
|
|
1777
|
+
};
|
|
1778
|
+
});
|
|
1779
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
1780
|
+
}
|
|
1781
|
+
isDdl(sql2) {
|
|
1782
|
+
return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
|
|
1783
|
+
}
|
|
1784
|
+
async close() {
|
|
1785
|
+
this.columns.clear();
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1788
|
+
var d1DriverFactory = {
|
|
1789
|
+
async open(config) {
|
|
1790
|
+
const driver = new D1Driver(config);
|
|
1791
|
+
await driver.raw("SELECT 1");
|
|
1792
|
+
return driver;
|
|
1793
|
+
},
|
|
1794
|
+
async test(config) {
|
|
1795
|
+
await new D1Driver(config).raw("SELECT 1");
|
|
1796
|
+
},
|
|
1797
|
+
parseConfig(body) {
|
|
1798
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
1799
|
+
const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
|
|
1800
|
+
const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
|
|
1801
|
+
const devUrl = typeof body.devUrl === "string" ? body.devUrl.trim() : "";
|
|
1802
|
+
if (devUrl) return { driver: "d1", devUrl: parseDevUrl(devUrl), databaseId: databaseId || void 0 };
|
|
1803
|
+
if (!accountId || !databaseId || !apiToken) {
|
|
1804
|
+
throw new Error("accountId, databaseId and apiToken are required (or devUrl for a local wrangler dev database)");
|
|
1805
|
+
}
|
|
1806
|
+
return { driver: "d1", accountId, databaseId, apiToken };
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
// src/drivers/do.ts
|
|
1811
|
+
var LOCAL_EXPLORER = "/cdn-cgi/local/explorer/api";
|
|
1812
|
+
var CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
|
|
1813
|
+
var MAX_OBJECTS = 5e3;
|
|
1814
|
+
var DoError = class extends Error {
|
|
1815
|
+
constructor(message, status) {
|
|
1816
|
+
super(message);
|
|
1817
|
+
this.status = status;
|
|
1818
|
+
}
|
|
1819
|
+
status;
|
|
1820
|
+
};
|
|
1821
|
+
var DATABASE_LIST2 = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
|
|
1822
|
+
var RESULT_KIND2 = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
|
|
1823
|
+
var TRANSACTION = /^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i;
|
|
1824
|
+
var DDL = /^\s*(CREATE|ALTER|DROP)\b/i;
|
|
1825
|
+
var HEX_ID = /^[0-9a-f]{64}$/i;
|
|
1826
|
+
function singleSourceTable3(sql2) {
|
|
1827
|
+
const m = /\bFROM\s+("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
|
|
1828
|
+
if (!m) return null;
|
|
1829
|
+
const rest = m[2].replace(
|
|
1830
|
+
/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i,
|
|
1831
|
+
""
|
|
1832
|
+
);
|
|
1833
|
+
if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
|
|
1834
|
+
const raw = m[1];
|
|
1835
|
+
const first = raw[0];
|
|
1836
|
+
if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
|
|
1837
|
+
if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
|
|
1838
|
+
if (first === "[") return raw.slice(1, -1);
|
|
1839
|
+
return raw;
|
|
1840
|
+
}
|
|
1841
|
+
function normalizeDeclaredType3(declared) {
|
|
1842
|
+
if (typeof declared !== "string") return null;
|
|
1843
|
+
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
1844
|
+
return base || null;
|
|
1845
|
+
}
|
|
1846
|
+
function inferTypeFromValue3(value) {
|
|
1847
|
+
if (value === null || value === void 0) return "null";
|
|
1848
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
1849
|
+
if (Array.isArray(value)) return "blob";
|
|
1850
|
+
return "text";
|
|
1851
|
+
}
|
|
1852
|
+
function firstNonNull3(records, index) {
|
|
1853
|
+
for (const row of records) {
|
|
1854
|
+
const value = row[index];
|
|
1855
|
+
if (value !== null && value !== void 0) return value;
|
|
1856
|
+
}
|
|
1857
|
+
return null;
|
|
1858
|
+
}
|
|
1859
|
+
function closingQuote(sql2, start, close) {
|
|
1860
|
+
let i = start + 1;
|
|
1861
|
+
while (i < sql2.length) {
|
|
1862
|
+
if (sql2[i] === close) {
|
|
1863
|
+
if (sql2[i + 1] === close && close !== "]") {
|
|
1864
|
+
i += 2;
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
return i + 1;
|
|
1868
|
+
}
|
|
1869
|
+
i += 1;
|
|
1870
|
+
}
|
|
1871
|
+
return sql2.length;
|
|
1872
|
+
}
|
|
1873
|
+
function skipSpace(sql2, from) {
|
|
1874
|
+
let i = from;
|
|
1875
|
+
while (i < sql2.length && /\s/.test(sql2[i])) i += 1;
|
|
1876
|
+
return i;
|
|
1877
|
+
}
|
|
1878
|
+
function routeStatement(sql2, known) {
|
|
1879
|
+
let out = "";
|
|
1880
|
+
let target = null;
|
|
1881
|
+
let i = 0;
|
|
1882
|
+
while (i < sql2.length) {
|
|
1883
|
+
const ch = sql2[i];
|
|
1884
|
+
if (ch === "'") {
|
|
1885
|
+
const end = closingQuote(sql2, i, "'");
|
|
1886
|
+
out += sql2.slice(i, end);
|
|
1887
|
+
i = end;
|
|
1888
|
+
continue;
|
|
1889
|
+
}
|
|
1890
|
+
if (ch === "-" && sql2[i + 1] === "-") {
|
|
1891
|
+
const end = sql2.indexOf("\n", i);
|
|
1892
|
+
const stop = end === -1 ? sql2.length : end;
|
|
1893
|
+
out += sql2.slice(i, stop);
|
|
1894
|
+
i = stop;
|
|
1895
|
+
continue;
|
|
1896
|
+
}
|
|
1897
|
+
if (ch === "/" && sql2[i + 1] === "*") {
|
|
1898
|
+
const end = sql2.indexOf("*/", i + 2);
|
|
1899
|
+
const stop = end === -1 ? sql2.length : end + 2;
|
|
1900
|
+
out += sql2.slice(i, stop);
|
|
1901
|
+
i = stop;
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
if (ch === '"' || ch === "`" || ch === "[") {
|
|
1905
|
+
const close = ch === "[" ? "]" : ch;
|
|
1906
|
+
const end = closingQuote(sql2, i, close);
|
|
1907
|
+
const raw = sql2.slice(i, end);
|
|
1908
|
+
const inner = ch === "[" ? raw.slice(1, -1) : raw.slice(1, -1).replaceAll(close + close, close);
|
|
1909
|
+
const dot = skipSpace(sql2, end);
|
|
1910
|
+
if (sql2[dot] === "." && known(inner)) {
|
|
1911
|
+
target ??= inner;
|
|
1912
|
+
i = dot + 1;
|
|
1913
|
+
continue;
|
|
1914
|
+
}
|
|
1915
|
+
out += raw;
|
|
1916
|
+
i = end;
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
out += ch;
|
|
1920
|
+
i += 1;
|
|
1921
|
+
}
|
|
1922
|
+
return { sql: out, target };
|
|
1923
|
+
}
|
|
1924
|
+
async function fetchJson(url, init, unreachable, abort) {
|
|
1925
|
+
let res;
|
|
1926
|
+
try {
|
|
1927
|
+
const signal = abort ? AbortSignal.any([abort, AbortSignal.timeout(6e4)]) : AbortSignal.timeout(6e4);
|
|
1928
|
+
res = await fetch(url, { ...init, signal });
|
|
1929
|
+
} catch (err) {
|
|
1930
|
+
if (err instanceof Error && err.name === "AbortError" && abort?.aborted) {
|
|
1931
|
+
throw new DoError("Stopped waiting for the request; the query may still finish.", 400);
|
|
1932
|
+
}
|
|
1933
|
+
throw unreachable(err instanceof Error ? err.message : String(err));
|
|
1934
|
+
}
|
|
1935
|
+
let body = null;
|
|
1936
|
+
try {
|
|
1937
|
+
body = await res.json();
|
|
1938
|
+
} catch {
|
|
1939
|
+
}
|
|
1940
|
+
return { res, body };
|
|
1941
|
+
}
|
|
1942
|
+
var LocalTransport = class {
|
|
1943
|
+
label;
|
|
1944
|
+
base;
|
|
1945
|
+
/** The request being waited on, so a stop can drop it. */
|
|
1946
|
+
inFlight = null;
|
|
1947
|
+
abort() {
|
|
1948
|
+
this.inFlight?.abort();
|
|
1949
|
+
}
|
|
1950
|
+
/** Signal for the next request; replaces whatever was tracked before. */
|
|
1951
|
+
track() {
|
|
1952
|
+
this.inFlight = new AbortController();
|
|
1953
|
+
return this.inFlight.signal;
|
|
1954
|
+
}
|
|
1955
|
+
constructor(devUrl) {
|
|
1956
|
+
this.label = `do:${devUrl}`;
|
|
1957
|
+
this.base = `${devUrl.replace(/\/+$/, "")}${LOCAL_EXPLORER}`;
|
|
1958
|
+
}
|
|
1959
|
+
async call(path, body) {
|
|
1960
|
+
const origin = this.base.replace(LOCAL_EXPLORER, "");
|
|
1961
|
+
const { res, body: parsed } = await fetchJson(
|
|
1962
|
+
`${this.base}${path}`,
|
|
1963
|
+
{
|
|
1964
|
+
method: body === void 0 ? "GET" : "POST",
|
|
1965
|
+
headers: body === void 0 ? void 0 : { "Content-Type": "application/json" },
|
|
1966
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
1967
|
+
},
|
|
1968
|
+
(reason) => new DoError(
|
|
1969
|
+
`Cannot reach a wrangler dev server at ${origin} (${reason}). Run \`wrangler dev\` in your Worker project and try again.`,
|
|
1970
|
+
400
|
|
1971
|
+
),
|
|
1972
|
+
this.track()
|
|
1973
|
+
);
|
|
1974
|
+
const envelope = parsed && typeof parsed.success === "boolean" && (parsed.result !== void 0 || Array.isArray(parsed.errors)) ? parsed : null;
|
|
1975
|
+
if (!envelope) {
|
|
1976
|
+
throw new DoError(
|
|
1977
|
+
`${origin} answered (HTTP ${res.status}) but is not a wrangler dev server. Check the port, and that \`wrangler dev\` (wrangler 4.x) is what is running there.`,
|
|
1978
|
+
400
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
if (!res.ok || !envelope.success) {
|
|
1982
|
+
throw new DoError(errorText(envelope.errors) || `wrangler dev responded ${res.status}`, res.ok ? void 0 : 400);
|
|
1983
|
+
}
|
|
1984
|
+
if (envelope.result === void 0) throw new DoError("wrangler dev returned no result");
|
|
1985
|
+
return envelope.result;
|
|
1986
|
+
}
|
|
1987
|
+
async namespaces() {
|
|
1988
|
+
const list = await this.call("/workers/durable_objects/namespaces");
|
|
1989
|
+
return list.map((ns) => ({ id: ns.id, sqlite: ns.use_sqlite !== false }));
|
|
1990
|
+
}
|
|
1991
|
+
objects(namespaceId) {
|
|
1992
|
+
return this.call(`/workers/durable_objects/namespaces/${encodeURIComponent(namespaceId)}/objects`);
|
|
1993
|
+
}
|
|
1994
|
+
async run(target, sql2) {
|
|
1995
|
+
const selector = target.name ? { durable_object_name: target.name } : { durable_object_id: target.id };
|
|
1996
|
+
const results = await this.call(
|
|
1997
|
+
`/workers/durable_objects/namespaces/${encodeURIComponent(target.namespaceId)}/query`,
|
|
1998
|
+
{ ...selector, queries: [{ sql: sql2 }] }
|
|
1999
|
+
);
|
|
2000
|
+
const result = results[0];
|
|
2001
|
+
if (!result) throw new DoError("wrangler dev returned no result");
|
|
2002
|
+
return result;
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
var RemoteTransport = class {
|
|
2006
|
+
label;
|
|
2007
|
+
base;
|
|
2008
|
+
/** The request being waited on, so a stop can drop it. */
|
|
2009
|
+
inFlight = null;
|
|
2010
|
+
abort() {
|
|
2011
|
+
this.inFlight?.abort();
|
|
2012
|
+
}
|
|
2013
|
+
/** Signal for the next request; replaces whatever was tracked before. */
|
|
2014
|
+
track() {
|
|
2015
|
+
this.inFlight = new AbortController();
|
|
2016
|
+
return this.inFlight.signal;
|
|
2017
|
+
}
|
|
2018
|
+
headers;
|
|
2019
|
+
only;
|
|
2020
|
+
constructor(config) {
|
|
2021
|
+
this.label = `do:${config.accountId}${config.namespaceId ? `/${config.namespaceId}` : ""}`;
|
|
2022
|
+
this.base = `${CLOUDFLARE_API}/accounts/${encodeURIComponent(config.accountId)}/workers/durable_objects/namespaces`;
|
|
2023
|
+
this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
|
|
2024
|
+
this.only = config.namespaceId || void 0;
|
|
2025
|
+
}
|
|
2026
|
+
async call(path, body) {
|
|
2027
|
+
const { res, body: parsed } = await fetchJson(
|
|
2028
|
+
`${this.base}${path}`,
|
|
2029
|
+
{
|
|
2030
|
+
method: body === void 0 ? "GET" : "POST",
|
|
2031
|
+
headers: this.headers,
|
|
2032
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
2033
|
+
},
|
|
2034
|
+
(reason) => new DoError(`Cannot reach the Cloudflare API: ${reason}`, 400),
|
|
2035
|
+
this.track()
|
|
2036
|
+
);
|
|
2037
|
+
if (res.status === 401 || res.status === 403) {
|
|
2038
|
+
throw new DoError(
|
|
2039
|
+
errorText(parsed?.errors) || "Cloudflare refused the API token. It needs the Workers Scripts Read permission on this account.",
|
|
2040
|
+
401
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
if (res.status === 404 || res.status === 405) {
|
|
2044
|
+
throw new DoError(
|
|
2045
|
+
"Cloudflare did not accept this request (HTTP " + res.status + "). The Durable Object query endpoint is unofficial and may have changed; the Data Studio tab in the Cloudflare dashboard still works.",
|
|
2046
|
+
400
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
if (!res.ok || !parsed?.success) {
|
|
2050
|
+
throw new DoError(errorText(parsed?.errors) || `Cloudflare API responded ${res.status}`, 400);
|
|
2051
|
+
}
|
|
2052
|
+
return parsed;
|
|
2053
|
+
}
|
|
2054
|
+
async namespaces() {
|
|
2055
|
+
if (this.only) return [{ id: this.only, sqlite: true }];
|
|
2056
|
+
const body = await this.call("");
|
|
2057
|
+
return (body.result ?? []).map((ns) => ({ id: ns.id, sqlite: ns.use_sqlite !== false }));
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* Cloudflare's listing returns ids and `hasStoredData`, never names. It also
|
|
2061
|
+
* answers an empty page for small `limit` values, so the parameter is left
|
|
2062
|
+
* off and the default page size is used, following `result_info.cursor`.
|
|
2063
|
+
*/
|
|
2064
|
+
async objects(namespaceId) {
|
|
2065
|
+
const out = [];
|
|
2066
|
+
let cursor;
|
|
2067
|
+
do {
|
|
2068
|
+
const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
|
|
2069
|
+
const body = await this.call(
|
|
2070
|
+
`/${encodeURIComponent(namespaceId)}/objects${query}`
|
|
2071
|
+
);
|
|
2072
|
+
for (const object of body.result ?? []) out.push({ id: object.id });
|
|
2073
|
+
cursor = body.result_info?.cursor || void 0;
|
|
2074
|
+
} while (cursor && out.length < MAX_OBJECTS);
|
|
2075
|
+
return out;
|
|
2076
|
+
}
|
|
2077
|
+
async run(target, sql2) {
|
|
2078
|
+
if (!target.id) {
|
|
2079
|
+
throw new DoError(
|
|
2080
|
+
`"${target.name}" is a name, and deployed Durable Objects can only be opened by id: idFromName() is a one-way hash and Cloudflare does not resolve it here. Pick an object from the schema tree.`,
|
|
2081
|
+
400
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
const body = await this.call(
|
|
2085
|
+
`/${encodeURIComponent(target.namespaceId)}/query`,
|
|
2086
|
+
{ durable_object_id: target.id, queries: [{ sql: sql2 }] }
|
|
2087
|
+
);
|
|
2088
|
+
const result = body.result?.results?.[0];
|
|
2089
|
+
if (!result) throw new DoError("Cloudflare returned no result");
|
|
2090
|
+
return result;
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
function errorText(errors) {
|
|
2094
|
+
if (!Array.isArray(errors)) return "";
|
|
2095
|
+
return errors.map((e) => typeof e === "string" ? e : String(e?.message ?? "")).filter(Boolean).join("; ").slice(0, 500);
|
|
2096
|
+
}
|
|
2097
|
+
var DoDriver = class {
|
|
2098
|
+
constructor(type, transport) {
|
|
2099
|
+
this.type = type;
|
|
2100
|
+
this.transport = transport;
|
|
2101
|
+
this.label = transport.label;
|
|
2102
|
+
}
|
|
2103
|
+
type;
|
|
2104
|
+
transport;
|
|
2105
|
+
label;
|
|
2106
|
+
/** Database name -> the object it addresses, refreshed by every listing. */
|
|
2107
|
+
targets = /* @__PURE__ */ new Map();
|
|
2108
|
+
/** Declared column types per `database table`, for result-source resolution. */
|
|
2109
|
+
columns = /* @__PURE__ */ new Map();
|
|
2110
|
+
/**
|
|
2111
|
+
* These are HTTP calls, not sessions: there is nothing to cancel upstream, so
|
|
2112
|
+
* stopping drops the request the agent waits on. `abandon`, not `kill`.
|
|
2113
|
+
*/
|
|
2114
|
+
cancelKind = "abandon";
|
|
2115
|
+
async cancel() {
|
|
2116
|
+
this.transport.abort();
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Every object of every SQLite-backed namespace, as app-level database
|
|
2120
|
+
* names. Locally, a `wrangler dev` session registers itself in wrangler's
|
|
2121
|
+
* dev registry, so sessions of other projects show up here too.
|
|
2122
|
+
*/
|
|
2123
|
+
async listDatabases() {
|
|
2124
|
+
const namespaces = await this.transport.namespaces();
|
|
2125
|
+
const names = [];
|
|
2126
|
+
this.targets.clear();
|
|
2127
|
+
for (const ns of namespaces) {
|
|
2128
|
+
if (!ns.sqlite) continue;
|
|
2129
|
+
let objects;
|
|
2130
|
+
try {
|
|
2131
|
+
objects = await this.transport.objects(ns.id);
|
|
2132
|
+
} catch {
|
|
2133
|
+
continue;
|
|
2134
|
+
}
|
|
2135
|
+
for (const object of objects) {
|
|
2136
|
+
const ref = object.name || object.id;
|
|
2137
|
+
if (!ref) continue;
|
|
2138
|
+
const database = `${ns.id}/${ref}`;
|
|
2139
|
+
this.targets.set(database, { namespaceId: ns.id, name: object.name, id: object.id });
|
|
2140
|
+
names.push(database);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
return names.sort();
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Resolve a database name to the object it addresses. Names are cached by
|
|
2147
|
+
* the last listing; one that was never listed is still usable, so a saved
|
|
2148
|
+
* query tab keeps working — the trailing part is read as an id when it looks
|
|
2149
|
+
* like one and as a name otherwise.
|
|
2150
|
+
*/
|
|
2151
|
+
targetFor(database) {
|
|
2152
|
+
const cached3 = this.targets.get(database);
|
|
2153
|
+
if (cached3) return cached3;
|
|
2154
|
+
const slash = database.indexOf("/");
|
|
2155
|
+
if (slash <= 0 || slash === database.length - 1) {
|
|
2156
|
+
throw new DoError(
|
|
2157
|
+
`"${database}" is not a Durable Object. Pick one from the schema tree (they are named "<namespace>/<object>").`,
|
|
2158
|
+
400
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
const namespaceId = database.slice(0, slash);
|
|
2162
|
+
const ref = database.slice(slash + 1);
|
|
2163
|
+
return HEX_ID.test(ref) ? { namespaceId, id: ref } : { namespaceId, name: ref };
|
|
2164
|
+
}
|
|
2165
|
+
async tableColumns(database, target, table) {
|
|
2166
|
+
const key = `${database} ${table}`;
|
|
2167
|
+
const cached3 = this.columns.get(key);
|
|
2168
|
+
if (cached3 !== void 0) return cached3;
|
|
2169
|
+
let out = null;
|
|
2170
|
+
try {
|
|
2171
|
+
const info = await this.transport.run(target, `PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
2172
|
+
const cols = info.columns ?? [];
|
|
2173
|
+
const name = cols.indexOf("name");
|
|
2174
|
+
const type = cols.indexOf("type");
|
|
2175
|
+
const rows = info.rows ?? [];
|
|
2176
|
+
if (rows.length > 0 && name >= 0) {
|
|
2177
|
+
out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType3(type >= 0 ? r[type] : null)]));
|
|
2178
|
+
}
|
|
2179
|
+
} catch {
|
|
2180
|
+
out = null;
|
|
2181
|
+
}
|
|
2182
|
+
if (this.columns.size > 200) this.columns.clear();
|
|
2183
|
+
this.columns.set(key, out);
|
|
2184
|
+
return out;
|
|
2185
|
+
}
|
|
2186
|
+
async query(sql2, database) {
|
|
2187
|
+
if (DATABASE_LIST2.test(sql2)) {
|
|
2188
|
+
const names = await this.listDatabases();
|
|
2189
|
+
return {
|
|
2190
|
+
fields: ["seq", "name", "file"].map((name2) => ({
|
|
2191
|
+
name: name2,
|
|
2192
|
+
orgName: name2,
|
|
2193
|
+
type: name2 === "seq" ? "integer" : "text",
|
|
2194
|
+
table: "",
|
|
2195
|
+
orgTable: "",
|
|
2196
|
+
db: "",
|
|
2197
|
+
maxLength: 0
|
|
2198
|
+
})),
|
|
2199
|
+
records: names.map((name2, seq) => [seq, name2, ""]),
|
|
2200
|
+
affectedRows: 0,
|
|
2201
|
+
insertId: 0,
|
|
2202
|
+
isResultQuery: true
|
|
2203
|
+
};
|
|
2204
|
+
}
|
|
2205
|
+
const routed = routeStatement(sql2, (name2) => this.targets.has(name2) || name2.includes("/"));
|
|
2206
|
+
const text = routed.sql;
|
|
2207
|
+
if (TRANSACTION.test(text)) {
|
|
2208
|
+
throw new DoError(
|
|
2209
|
+
"Durable Object storage does not take transaction statements over this API; every request auto-commits."
|
|
2210
|
+
);
|
|
2211
|
+
}
|
|
2212
|
+
const name = routed.target ?? database;
|
|
2213
|
+
if (!name) {
|
|
2214
|
+
throw new DoError(
|
|
2215
|
+
'No Durable Object selected. Pick one in the schema tree, or qualify the table as "<namespace>/<object>"."<table>".',
|
|
2216
|
+
400
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
const target = this.targetFor(name);
|
|
2220
|
+
if (DDL.test(text)) this.columns.clear();
|
|
2221
|
+
const result = await this.transport.run(target, text);
|
|
2222
|
+
const columns = result.columns ?? [];
|
|
2223
|
+
const records = result.rows ?? [];
|
|
2224
|
+
if (columns.length === 0 && !RESULT_KIND2.test(text)) {
|
|
2225
|
+
return {
|
|
2226
|
+
fields: [],
|
|
2227
|
+
records: [],
|
|
2228
|
+
affectedRows: result.meta?.rows_written ?? 0,
|
|
2229
|
+
insertId: 0,
|
|
2230
|
+
isResultQuery: false
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
const source = RESULT_KIND2.test(text) ? singleSourceTable3(text) : null;
|
|
2234
|
+
const known = source ? await this.tableColumns(name, target, source) : null;
|
|
2235
|
+
const fields = columns.map((column, index) => {
|
|
2236
|
+
const fromTable = known?.has(column) ?? false;
|
|
2237
|
+
const type = (fromTable ? known?.get(column) : null) ?? inferTypeFromValue3(firstNonNull3(records, index));
|
|
2238
|
+
return {
|
|
2239
|
+
name: column,
|
|
2240
|
+
orgName: column,
|
|
2241
|
+
type,
|
|
2242
|
+
table: fromTable && source ? source : "",
|
|
2243
|
+
orgTable: fromTable && source ? source : "",
|
|
2244
|
+
db: fromTable ? name : "",
|
|
2245
|
+
maxLength: 0
|
|
2246
|
+
};
|
|
2247
|
+
});
|
|
2248
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
2249
|
+
}
|
|
2250
|
+
async close() {
|
|
2251
|
+
this.targets.clear();
|
|
2252
|
+
this.columns.clear();
|
|
2253
|
+
}
|
|
2254
|
+
};
|
|
2255
|
+
var LOOPBACK2 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", "0.0.0.0"]);
|
|
2256
|
+
function parseDevUrl2(raw) {
|
|
2257
|
+
let url;
|
|
2258
|
+
try {
|
|
2259
|
+
url = new URL(raw);
|
|
2260
|
+
} catch {
|
|
2261
|
+
throw new Error(`Not a valid URL: ${raw}`);
|
|
2262
|
+
}
|
|
2263
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2264
|
+
throw new Error("devUrl must be an http(s) URL");
|
|
2265
|
+
}
|
|
2266
|
+
if (!LOOPBACK2.has(url.hostname.toLowerCase())) {
|
|
2267
|
+
throw new Error(
|
|
2268
|
+
"devUrl must point at a wrangler dev server on this machine (localhost). Use the deployed Durable Objects connection for objects that are not local."
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
return url.origin;
|
|
2272
|
+
}
|
|
2273
|
+
var doDriverFactory = {
|
|
2274
|
+
async open(config) {
|
|
2275
|
+
const driver = new DoDriver("do", new LocalTransport(config.devUrl));
|
|
2276
|
+
await driver.query("PRAGMA database_list");
|
|
2277
|
+
return driver;
|
|
2278
|
+
},
|
|
2279
|
+
async test(config) {
|
|
2280
|
+
await new DoDriver("do", new LocalTransport(config.devUrl)).query("PRAGMA database_list");
|
|
2281
|
+
},
|
|
2282
|
+
parseConfig(body) {
|
|
2283
|
+
const devUrl = typeof body.devUrl === "string" ? body.devUrl.trim() : "";
|
|
2284
|
+
if (!devUrl) throw new Error("devUrl is required");
|
|
2285
|
+
return { driver: "do", devUrl: parseDevUrl2(devUrl) };
|
|
2286
|
+
}
|
|
2287
|
+
};
|
|
2288
|
+
var doRemoteDriverFactory = {
|
|
2289
|
+
async open(config) {
|
|
2290
|
+
const driver = new DoDriver("do-remote", new RemoteTransport(config));
|
|
2291
|
+
await driver.query("PRAGMA database_list");
|
|
2292
|
+
return driver;
|
|
2293
|
+
},
|
|
2294
|
+
async test(config) {
|
|
2295
|
+
await new DoDriver("do-remote", new RemoteTransport(config)).query("PRAGMA database_list");
|
|
2296
|
+
},
|
|
2297
|
+
parseConfig(body) {
|
|
2298
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
2299
|
+
const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
|
|
2300
|
+
const namespaceId = typeof body.namespaceId === "string" ? body.namespaceId.trim() : "";
|
|
2301
|
+
if (!accountId || !apiToken) throw new Error("accountId and apiToken are required");
|
|
2302
|
+
return { driver: "do-remote", accountId, apiToken, ...namespaceId ? { namespaceId } : {} };
|
|
2303
|
+
}
|
|
2304
|
+
};
|
|
2305
|
+
|
|
2306
|
+
// src/ssh-tunnel.ts
|
|
2307
|
+
import { createServer } from "net";
|
|
2308
|
+
import { mkdir as mkdir4, readFile as readFile6, appendFile } from "fs/promises";
|
|
2309
|
+
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
2310
|
+
import { homedir as homedir7 } from "os";
|
|
2311
|
+
import { basename as basename3, join as join8 } from "path";
|
|
2312
|
+
import { Client } from "ssh2";
|
|
2313
|
+
|
|
2314
|
+
// src/ssh-config.ts
|
|
2315
|
+
import { readFile as readFile5, readdir } from "fs/promises";
|
|
2316
|
+
import { homedir as homedir6 } from "os";
|
|
2317
|
+
import { dirname as dirname2, isAbsolute, join as join7, basename as basename2 } from "path";
|
|
2318
|
+
var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
|
|
2319
|
+
var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join7(homedir6(), ".ssh", "config");
|
|
2320
|
+
var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
|
|
2321
|
+
(f) => join7(homedir6(), ".ssh", f)
|
|
2322
|
+
);
|
|
2323
|
+
function expandHome2(path) {
|
|
2324
|
+
return path === "~" || path.startsWith("~/") ? join7(homedir6(), path.slice(1)) : path;
|
|
2325
|
+
}
|
|
2326
|
+
function globToRegExp(pattern) {
|
|
2327
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
2328
|
+
return new RegExp(`^${escaped}$`, "i");
|
|
2329
|
+
}
|
|
2330
|
+
function hostPatternMatches(patterns, host) {
|
|
2331
|
+
let matched = false;
|
|
2332
|
+
for (const raw of patterns) {
|
|
2333
|
+
const negated = raw.startsWith("!");
|
|
2334
|
+
const pattern = negated ? raw.slice(1) : raw;
|
|
2335
|
+
if (!globToRegExp(pattern).test(host)) continue;
|
|
2336
|
+
if (negated) return false;
|
|
2337
|
+
matched = true;
|
|
2338
|
+
}
|
|
2339
|
+
return matched;
|
|
2340
|
+
}
|
|
2341
|
+
function tokenize(line) {
|
|
2342
|
+
const tokens = [];
|
|
2343
|
+
const re = /"([^"]*)"|(\S+)/g;
|
|
2344
|
+
let m;
|
|
2345
|
+
while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
|
|
2346
|
+
return tokens;
|
|
2347
|
+
}
|
|
2348
|
+
function parseLine(line) {
|
|
2349
|
+
const trimmed = line.trim();
|
|
2350
|
+
if (!trimmed || trimmed.startsWith("#")) return void 0;
|
|
2351
|
+
const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
|
|
2352
|
+
const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
|
|
2353
|
+
return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
|
|
2354
|
+
}
|
|
2355
|
+
async function expandGlob(path) {
|
|
2356
|
+
const base = basename2(path);
|
|
2357
|
+
if (!/[*?]/.test(base)) return [path];
|
|
2358
|
+
const dir = dirname2(path);
|
|
2359
|
+
try {
|
|
2360
|
+
const re = globToRegExp(base);
|
|
2361
|
+
return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join7(dir, name));
|
|
2362
|
+
} catch {
|
|
2363
|
+
return [];
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
async function loadLines(file, seen) {
|
|
2367
|
+
if (seen.has(file)) return [];
|
|
2368
|
+
seen.add(file);
|
|
2369
|
+
try {
|
|
2370
|
+
return (await readFile5(file, "utf8")).split(/\r?\n/);
|
|
2371
|
+
} catch {
|
|
2372
|
+
return [];
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
|
|
2376
|
+
const result = { identityFiles: [] };
|
|
2377
|
+
if (!file) return result;
|
|
2378
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2379
|
+
async function walk(path, active) {
|
|
2380
|
+
let blockActive = active;
|
|
2381
|
+
for (const line of await loadLines(path, seen)) {
|
|
2382
|
+
const d = parseLine(line);
|
|
2383
|
+
if (!d) continue;
|
|
2384
|
+
switch (d.keyword) {
|
|
2385
|
+
case "host":
|
|
2386
|
+
blockActive = hostPatternMatches(d.args, host);
|
|
2387
|
+
continue;
|
|
2388
|
+
case "match":
|
|
2389
|
+
blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
|
|
2390
|
+
continue;
|
|
2391
|
+
case "include":
|
|
2392
|
+
for (const inc of d.args) {
|
|
2393
|
+
const target = expandHome2(inc);
|
|
2394
|
+
const abs = isAbsolute(target) ? target : join7(homedir6(), ".ssh", target);
|
|
2395
|
+
for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
|
|
2396
|
+
}
|
|
2397
|
+
continue;
|
|
2398
|
+
}
|
|
2399
|
+
if (!blockActive) continue;
|
|
2400
|
+
const value = d.args[0];
|
|
2401
|
+
if (value === void 0) continue;
|
|
2402
|
+
switch (d.keyword) {
|
|
2403
|
+
case "hostname":
|
|
2404
|
+
result.hostName ??= value.replace(/%h/g, host);
|
|
2405
|
+
break;
|
|
2406
|
+
case "port":
|
|
2407
|
+
result.port ??= Number(value) || void 0;
|
|
2408
|
+
break;
|
|
2409
|
+
case "user":
|
|
2410
|
+
result.user ??= value;
|
|
2411
|
+
break;
|
|
2412
|
+
case "identityfile":
|
|
2413
|
+
for (const f of d.args) {
|
|
2414
|
+
const expanded = expandHome2(f.replace(/%d/g, homedir6()).replace(/%h/g, host));
|
|
2415
|
+
if (expanded.split(/[\\/]/).includes("..")) continue;
|
|
2416
|
+
result.identityFiles.push(expanded);
|
|
2417
|
+
}
|
|
2418
|
+
break;
|
|
2419
|
+
case "identityagent":
|
|
2420
|
+
result.identityAgent ??= value === "none" ? void 0 : expandHome2(value.replace(/%d/g, homedir6()));
|
|
2421
|
+
break;
|
|
2422
|
+
case "proxyjump":
|
|
2423
|
+
result.proxyJump ??= value;
|
|
2424
|
+
break;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
await walk(file, true);
|
|
2429
|
+
return result;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// src/ssh-tunnel.ts
|
|
2433
|
+
var TunnelError = class extends Error {
|
|
2434
|
+
constructor(message, status) {
|
|
2435
|
+
super(message);
|
|
2436
|
+
this.status = status;
|
|
2437
|
+
this.name = "TunnelError";
|
|
2438
|
+
}
|
|
2439
|
+
status;
|
|
2440
|
+
};
|
|
2441
|
+
var HostKeyError = class extends TunnelError {
|
|
2442
|
+
hostKey;
|
|
2443
|
+
constructor(host, port, type, fingerprint2) {
|
|
2444
|
+
super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
|
|
2445
|
+
this.name = "HostKeyError";
|
|
2446
|
+
this.hostKey = { host, port, type, fingerprint: fingerprint2 };
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
var CONNECT_TIMEOUT_MS = 2e4;
|
|
2450
|
+
var PLANCHE_DIR3 = join8(homedir7(), ".planche");
|
|
2451
|
+
var PLANCHE_KNOWN_HOSTS = join8(PLANCHE_DIR3, "known_hosts");
|
|
2452
|
+
var OPENSSH_KNOWN_HOSTS = join8(homedir7(), ".ssh", "known_hosts");
|
|
2453
|
+
function parseSshConfig(value) {
|
|
2454
|
+
if (value === void 0 || value === null) return void 0;
|
|
2455
|
+
if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
|
|
2456
|
+
const body = value;
|
|
2457
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
2458
|
+
const user = typeof body.user === "string" ? body.user.trim() : "";
|
|
2459
|
+
const auth = body.auth;
|
|
2460
|
+
if (!host) throw new TunnelError("ssh.host is required", 400);
|
|
2461
|
+
if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
|
|
2462
|
+
if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
|
|
2463
|
+
const port = Math.trunc(Number(body.port)) || 22;
|
|
2464
|
+
if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
|
|
2465
|
+
if (auth !== "password" && auth !== "key" && auth !== "agent") {
|
|
2466
|
+
throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
|
|
2467
|
+
}
|
|
2468
|
+
const str = (v) => typeof v === "string" && v ? v : void 0;
|
|
2469
|
+
const config = {
|
|
2470
|
+
host,
|
|
2471
|
+
port,
|
|
2472
|
+
user,
|
|
2473
|
+
auth,
|
|
2474
|
+
password: auth === "password" ? str(body.password) ?? "" : void 0,
|
|
2475
|
+
privateKey: auth === "key" ? str(body.privateKey) : void 0,
|
|
2476
|
+
passphrase: auth === "key" ? str(body.passphrase) : void 0,
|
|
2477
|
+
acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
|
|
2478
|
+
};
|
|
2479
|
+
return config;
|
|
2480
|
+
}
|
|
2481
|
+
function keyType(blob) {
|
|
2482
|
+
if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
|
|
2483
|
+
const len = blob.readUInt32BE(0);
|
|
2484
|
+
if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
|
|
2485
|
+
const type = blob.subarray(4, 4 + len).toString("ascii");
|
|
2486
|
+
if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
|
|
2487
|
+
return type;
|
|
2488
|
+
}
|
|
2489
|
+
function knownHostsEntryMatches(field, patterns) {
|
|
2490
|
+
if (field.startsWith("|1|")) {
|
|
2491
|
+
const [, , salt, hash] = field.split("|");
|
|
2492
|
+
if (!salt || !hash) return false;
|
|
2493
|
+
const key = Buffer.from(salt, "base64");
|
|
2494
|
+
const want = Buffer.from(hash, "base64");
|
|
2495
|
+
return patterns.some((p) => {
|
|
2496
|
+
const got = createHmac("sha1", key).update(p).digest();
|
|
2497
|
+
return got.length === want.length && timingSafeEqual(got, want);
|
|
2498
|
+
});
|
|
2499
|
+
}
|
|
2500
|
+
const entries = field.toLowerCase().split(",");
|
|
2501
|
+
return patterns.some((p) => hostPatternMatches(entries, p));
|
|
2502
|
+
}
|
|
2503
|
+
function hostPatterns(host, port) {
|
|
2504
|
+
const h = host.toLowerCase();
|
|
2505
|
+
return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
|
|
2506
|
+
}
|
|
2507
|
+
async function readKnownHosts(file) {
|
|
2508
|
+
try {
|
|
2509
|
+
return (await readFile6(file, "utf8")).split(/\r?\n/);
|
|
2510
|
+
} catch {
|
|
2511
|
+
return [];
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
async function lookupHostKey(host, port, blob) {
|
|
2515
|
+
const patterns = hostPatterns(host, port);
|
|
2516
|
+
const type = keyType(blob);
|
|
2517
|
+
const encoded = blob.toString("base64");
|
|
2518
|
+
let sawHost = false;
|
|
2519
|
+
const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
|
|
2520
|
+
for (const raw of lines) {
|
|
2521
|
+
const line = raw.trim();
|
|
2522
|
+
if (!line || line.startsWith("#")) continue;
|
|
2523
|
+
const fields = line.split(/\s+/);
|
|
2524
|
+
const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
|
|
2525
|
+
const [hosts, lineType, lineKey] = fields;
|
|
2526
|
+
if (!hosts || !lineType || !lineKey) continue;
|
|
2527
|
+
if (!knownHostsEntryMatches(hosts, patterns)) continue;
|
|
2528
|
+
if (marker === "@revoked") {
|
|
2529
|
+
if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
|
|
2530
|
+
continue;
|
|
2531
|
+
}
|
|
2532
|
+
if (marker === "@cert-authority") continue;
|
|
2533
|
+
sawHost = true;
|
|
2534
|
+
if (lineType === type && lineKey === encoded) return "match";
|
|
2535
|
+
}
|
|
2536
|
+
return sawHost ? "mismatch" : "unknown";
|
|
2537
|
+
}
|
|
2538
|
+
async function rememberHostKey(host, port, blob) {
|
|
2539
|
+
await mkdir4(PLANCHE_DIR3, { recursive: true, mode: 448 });
|
|
2540
|
+
const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
|
|
2541
|
+
await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
|
|
2542
|
+
`, { mode: 384 });
|
|
2543
|
+
}
|
|
2544
|
+
function fingerprint(blob) {
|
|
2545
|
+
return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
|
|
2546
|
+
}
|
|
2547
|
+
async function resolveConfig(ssh) {
|
|
2548
|
+
const cfg = await resolveSshHost(ssh.host);
|
|
2549
|
+
if (cfg.proxyJump) {
|
|
2550
|
+
throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
|
|
2551
|
+
}
|
|
2552
|
+
if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
|
|
2553
|
+
throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
|
|
2554
|
+
}
|
|
2555
|
+
const resolved = {
|
|
2556
|
+
...ssh,
|
|
2557
|
+
host: cfg.hostName ?? ssh.host,
|
|
2558
|
+
port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
|
|
2559
|
+
user: ssh.user || cfg.user || ""
|
|
2560
|
+
};
|
|
2561
|
+
if (!resolved.user) {
|
|
2562
|
+
throw new TunnelError(
|
|
2563
|
+
SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
|
|
2564
|
+
400
|
|
2565
|
+
);
|
|
2566
|
+
}
|
|
2567
|
+
if (resolved.auth === "key" && !resolved.privateKey) {
|
|
2568
|
+
if (SSH_CONFIG_DISABLED) {
|
|
2569
|
+
throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
|
|
2570
|
+
}
|
|
2571
|
+
const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
|
|
2572
|
+
for (const file of candidates) {
|
|
2573
|
+
try {
|
|
2574
|
+
resolved.privateKey = await readFile6(file, "utf8");
|
|
2575
|
+
console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
|
|
2576
|
+
break;
|
|
2577
|
+
} catch {
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
if (!resolved.privateKey) {
|
|
2581
|
+
throw new TunnelError(
|
|
2582
|
+
`No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename3(f)).join(", ")} in ~/.ssh)`,
|
|
2583
|
+
400
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
return { ...resolved, identityAgent: cfg.identityAgent };
|
|
2588
|
+
}
|
|
2589
|
+
function toConnectConfig(ssh) {
|
|
2590
|
+
const base = {
|
|
2591
|
+
host: ssh.host,
|
|
2592
|
+
port: ssh.port,
|
|
2593
|
+
username: ssh.user,
|
|
2594
|
+
readyTimeout: CONNECT_TIMEOUT_MS,
|
|
2595
|
+
keepaliveInterval: 3e4
|
|
2596
|
+
};
|
|
2597
|
+
switch (ssh.auth) {
|
|
2598
|
+
case "password":
|
|
2599
|
+
return { ...base, password: ssh.password ?? "", tryKeyboard: true };
|
|
2600
|
+
case "key":
|
|
2601
|
+
return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
|
|
2602
|
+
case "agent": {
|
|
2603
|
+
const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
|
|
2604
|
+
if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
|
|
2605
|
+
return { ...base, agent };
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
function describeSshError(err, ssh) {
|
|
2610
|
+
const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
|
|
2611
|
+
if (err instanceof TunnelError) return err;
|
|
2612
|
+
switch (err.level) {
|
|
2613
|
+
case "client-authentication":
|
|
2614
|
+
return new TunnelError(`SSH authentication failed for ${target}`, 401);
|
|
2615
|
+
case "client-timeout":
|
|
2616
|
+
return new TunnelError(`SSH connection to ${target} timed out`, 400);
|
|
2617
|
+
default:
|
|
2618
|
+
return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
async function openSshTunnel(requested, target) {
|
|
2622
|
+
const ssh = await resolveConfig(requested);
|
|
2623
|
+
const client2 = new Client();
|
|
2624
|
+
const connectConfig = toConnectConfig(ssh);
|
|
2625
|
+
let verifyError;
|
|
2626
|
+
connectConfig.hostVerifier = (key, verified) => {
|
|
2627
|
+
lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
|
|
2628
|
+
const fp = fingerprint(key);
|
|
2629
|
+
if (result === "mismatch") {
|
|
2630
|
+
verifyError = new TunnelError(
|
|
2631
|
+
`Host key for ${ssh.host}:${ssh.port} has changed (${fp}). Remove the old entry from ~/.ssh/known_hosts or ~/.planche/known_hosts if this is expected.`,
|
|
2632
|
+
400
|
|
2633
|
+
);
|
|
2634
|
+
verified(false);
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (result === "unknown") {
|
|
2638
|
+
if (ssh.acceptHostKey !== fp) {
|
|
2639
|
+
verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
|
|
2640
|
+
verified(false);
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
|
|
2644
|
+
await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
|
|
2645
|
+
}
|
|
2646
|
+
verified(true);
|
|
2647
|
+
}).catch((err) => {
|
|
2648
|
+
verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
|
|
2649
|
+
verified(false);
|
|
2650
|
+
});
|
|
2651
|
+
};
|
|
2652
|
+
await new Promise((resolve3, reject) => {
|
|
2653
|
+
client2.once("ready", resolve3);
|
|
2654
|
+
client2.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
2655
|
+
try {
|
|
2656
|
+
client2.connect(connectConfig);
|
|
2657
|
+
} catch (err) {
|
|
2658
|
+
reject(describeSshError(err, ssh));
|
|
2659
|
+
}
|
|
2660
|
+
});
|
|
2661
|
+
client2.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
|
|
2662
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
2663
|
+
let forwardError;
|
|
2664
|
+
const server = createServer((socket) => {
|
|
2665
|
+
sockets.add(socket);
|
|
2666
|
+
socket.once("close", () => sockets.delete(socket));
|
|
2667
|
+
socket.on("error", () => socket.destroy());
|
|
2668
|
+
client2.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
2669
|
+
if (err) {
|
|
2670
|
+
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
2671
|
+
const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
|
|
2672
|
+
forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
|
|
2673
|
+
console.error(`[ssh] ${forwardError}`);
|
|
2674
|
+
socket.destroy();
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
stream.on("error", () => socket.destroy());
|
|
2678
|
+
socket.pipe(stream).pipe(socket);
|
|
2679
|
+
stream.once("close", () => socket.destroy());
|
|
2680
|
+
socket.once("close", () => stream.destroy());
|
|
2681
|
+
});
|
|
2682
|
+
});
|
|
2683
|
+
await new Promise((resolve3, reject) => {
|
|
2684
|
+
server.once("error", reject);
|
|
2685
|
+
server.listen(0, "127.0.0.1", () => resolve3());
|
|
2686
|
+
}).catch((err) => {
|
|
2687
|
+
client2.end();
|
|
2688
|
+
throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
|
|
2689
|
+
});
|
|
2690
|
+
const { port } = server.address();
|
|
2691
|
+
let closed = false;
|
|
2692
|
+
const close = () => new Promise((resolve3) => {
|
|
2693
|
+
if (closed) return resolve3();
|
|
2694
|
+
closed = true;
|
|
2695
|
+
for (const s of sockets) s.destroy();
|
|
2696
|
+
server.close(() => {
|
|
2697
|
+
client2.end();
|
|
2698
|
+
resolve3();
|
|
2699
|
+
});
|
|
2700
|
+
});
|
|
2701
|
+
client2.once("close", () => void close());
|
|
2702
|
+
return {
|
|
2703
|
+
host: "127.0.0.1",
|
|
2704
|
+
port,
|
|
2705
|
+
get forwardError() {
|
|
2706
|
+
return forwardError;
|
|
2707
|
+
},
|
|
2708
|
+
close
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
// src/drivers/index.ts
|
|
2713
|
+
var factories = {
|
|
2714
|
+
mysql: mysqlDriverFactory,
|
|
2715
|
+
postgres: postgresDriverFactory,
|
|
2716
|
+
sqlite: sqliteDriverFactory,
|
|
2717
|
+
mssql: mssqlDriverFactory,
|
|
2718
|
+
oracle: oracleDriverFactory,
|
|
2719
|
+
d1: d1DriverFactory,
|
|
2720
|
+
do: doDriverFactory,
|
|
2721
|
+
"do-remote": doRemoteDriverFactory
|
|
2722
|
+
};
|
|
2723
|
+
function isDriverType(value) {
|
|
2724
|
+
return typeof value === "string" && value in factories;
|
|
2725
|
+
}
|
|
2726
|
+
function isDirectDriver(config) {
|
|
2727
|
+
return config.driver === "sqlite" || config.driver === "d1" || config.driver === "do" || config.driver === "do-remote";
|
|
2728
|
+
}
|
|
2729
|
+
function parseDriverConfig(body) {
|
|
2730
|
+
const driver = body.driver ?? "mysql";
|
|
2731
|
+
if (!isDriverType(driver)) {
|
|
2732
|
+
throw new Error(`Unsupported driver: ${String(driver)}`);
|
|
2733
|
+
}
|
|
2734
|
+
const config = factories[driver].parseConfig(body);
|
|
2735
|
+
if (isDirectDriver(config)) return config;
|
|
2736
|
+
const ssh = parseSshConfig(body.ssh);
|
|
2737
|
+
return ssh ? { ...config, ssh } : config;
|
|
2738
|
+
}
|
|
2739
|
+
async function withTunnel(config, run2) {
|
|
2740
|
+
if (isDirectDriver(config) || !config.ssh) return run2(config, void 0);
|
|
2741
|
+
const tunnel = await openSshTunnel(config.ssh, { host: config.host, port: config.port });
|
|
2742
|
+
recordUsage("ssh_tunnel");
|
|
2743
|
+
try {
|
|
2744
|
+
return await run2({ ...config, host: tunnel.host, port: tunnel.port, ssh: void 0 }, tunnel);
|
|
2745
|
+
} catch (err) {
|
|
2746
|
+
await tunnel.close();
|
|
2747
|
+
throw tunnel.forwardError ? new TunnelError(tunnel.forwardError, 400) : err;
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
function tunneled(driver, tunnel, label) {
|
|
2751
|
+
return {
|
|
2752
|
+
type: driver.type,
|
|
2753
|
+
label,
|
|
2754
|
+
query: (sql2, database) => driver.query(sql2, database),
|
|
2755
|
+
setReadOnly: driver.setReadOnly ? () => driver.setReadOnly() : void 0,
|
|
2756
|
+
// Forwarded, or a tunnelled connection would report that its engine cannot
|
|
2757
|
+
// interrupt a statement when it is only this wrapper that cannot.
|
|
2758
|
+
cancel: driver.cancel ? () => driver.cancel() : void 0,
|
|
2759
|
+
async close() {
|
|
2760
|
+
try {
|
|
2761
|
+
await driver.close();
|
|
2762
|
+
} finally {
|
|
2763
|
+
await tunnel.close();
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
}
|
|
2768
|
+
function openDriver(config) {
|
|
2769
|
+
return withTunnel(config, async (direct, tunnel) => {
|
|
2770
|
+
const driver = await openDirect(direct);
|
|
2771
|
+
if (!tunnel || isDirectDriver(config) || !config.ssh) return driver;
|
|
2772
|
+
const label = `${config.user}@${config.host}:${config.port} via ssh ${config.ssh.user}@${config.ssh.host}:${config.ssh.port}`;
|
|
2773
|
+
return tunneled(driver, tunnel, label);
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
function testDriver(config) {
|
|
2777
|
+
return withTunnel(config, async (direct, tunnel) => {
|
|
2778
|
+
try {
|
|
2779
|
+
await testDirect(direct);
|
|
2780
|
+
} finally {
|
|
2781
|
+
await tunnel?.close();
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2785
|
+
function openDirect(config) {
|
|
2786
|
+
switch (config.driver) {
|
|
2787
|
+
case "mysql":
|
|
2788
|
+
return factories.mysql.open(config);
|
|
2789
|
+
case "postgres":
|
|
2790
|
+
return factories.postgres.open(config);
|
|
2791
|
+
case "sqlite":
|
|
2792
|
+
return factories.sqlite.open(config);
|
|
2793
|
+
case "mssql":
|
|
2794
|
+
return factories.mssql.open(config);
|
|
2795
|
+
case "oracle":
|
|
2796
|
+
return factories.oracle.open(config);
|
|
2797
|
+
case "d1":
|
|
2798
|
+
return factories.d1.open(config);
|
|
2799
|
+
case "do":
|
|
2800
|
+
return factories.do.open(config);
|
|
2801
|
+
case "do-remote":
|
|
2802
|
+
return factories["do-remote"].open(config);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
function testDirect(config) {
|
|
2806
|
+
switch (config.driver) {
|
|
2807
|
+
case "mysql":
|
|
2808
|
+
return factories.mysql.test(config);
|
|
2809
|
+
case "postgres":
|
|
2810
|
+
return factories.postgres.test(config);
|
|
2811
|
+
case "sqlite":
|
|
2812
|
+
return factories.sqlite.test(config);
|
|
2813
|
+
case "mssql":
|
|
2814
|
+
return factories.mssql.test(config);
|
|
2815
|
+
case "oracle":
|
|
2816
|
+
return factories.oracle.test(config);
|
|
2817
|
+
case "d1":
|
|
2818
|
+
return factories.d1.test(config);
|
|
2819
|
+
case "do":
|
|
2820
|
+
return factories.do.test(config);
|
|
2821
|
+
case "do-remote":
|
|
2822
|
+
return factories["do-remote"].test(config);
|
|
2823
|
+
}
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// src/gateway.ts
|
|
2827
|
+
function loadGatewayConfig(file) {
|
|
2828
|
+
const path = resolve2(file);
|
|
2829
|
+
let raw;
|
|
2830
|
+
try {
|
|
2831
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
2832
|
+
} catch (err) {
|
|
2833
|
+
throw new Error(`Cannot read gateway config ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2834
|
+
}
|
|
2835
|
+
const data = raw;
|
|
2836
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.profiles) || data.profiles.length === 0) {
|
|
2837
|
+
throw new Error(`Gateway config ${path} needs a non-empty "profiles" array`);
|
|
2838
|
+
}
|
|
2839
|
+
const names = /* @__PURE__ */ new Set();
|
|
2840
|
+
const profiles = data.profiles.map((entry, i) => {
|
|
2841
|
+
const e = entry ?? {};
|
|
2842
|
+
const name = typeof e.name === "string" ? e.name.trim() : "";
|
|
2843
|
+
if (!name) throw new Error(`Gateway profile #${i + 1} has no "name"`);
|
|
2844
|
+
if (names.has(name)) throw new Error(`Gateway profile name is not unique: ${name}`);
|
|
2845
|
+
names.add(name);
|
|
2846
|
+
const { name: _n, readOnly, ...driver } = e;
|
|
2847
|
+
void _n;
|
|
2848
|
+
let config;
|
|
2849
|
+
try {
|
|
2850
|
+
config = parseDriverConfig(driver);
|
|
2851
|
+
} catch (err) {
|
|
2852
|
+
throw new Error(`Gateway profile "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
2853
|
+
}
|
|
2854
|
+
return { name, readOnly: readOnly !== false, config };
|
|
2855
|
+
});
|
|
2856
|
+
return { name: typeof data.name === "string" && data.name.trim() ? data.name.trim() : "Planche gateway", profiles };
|
|
2857
|
+
}
|
|
2858
|
+
function describeProfile(entry) {
|
|
2859
|
+
const c = entry.config;
|
|
2860
|
+
const database = "database" in c && typeof c.database === "string" ? c.database : c.driver === "oracle" ? c.serviceName : void 0;
|
|
2861
|
+
return { name: entry.name, driver: c.driver, readOnly: entry.readOnly, ...database ? { database } : {} };
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
// src/server.ts
|
|
2865
|
+
import express from "express";
|
|
2866
|
+
import { createHash as createHash2, randomUUID as randomUUID2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
2867
|
+
|
|
2868
|
+
// src/bridge.ts
|
|
2869
|
+
var CALL_TIMEOUT_MS = 12e4;
|
|
2870
|
+
var client = null;
|
|
2871
|
+
var tools = [];
|
|
2872
|
+
var pending = /* @__PURE__ */ new Map();
|
|
2873
|
+
var nextId = 0;
|
|
2874
|
+
function bridgeTools() {
|
|
2875
|
+
return tools;
|
|
2876
|
+
}
|
|
2877
|
+
function attachBridge(res) {
|
|
2878
|
+
client?.end();
|
|
2879
|
+
client = res;
|
|
2880
|
+
res.on("close", () => {
|
|
2881
|
+
if (client !== res) return;
|
|
2882
|
+
client = null;
|
|
2883
|
+
tools = [];
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
function setBridgeTools(next) {
|
|
2887
|
+
tools = next;
|
|
2888
|
+
}
|
|
2889
|
+
function callThroughBridge(name, input) {
|
|
2890
|
+
const target = client;
|
|
2891
|
+
if (!target) {
|
|
2892
|
+
return Promise.resolve({ content: "Planche is not open in a browser, so its tools cannot run.", isError: true });
|
|
2893
|
+
}
|
|
2894
|
+
const id = `call-${++nextId}`;
|
|
2895
|
+
return new Promise((resolve3) => {
|
|
2896
|
+
const timer2 = setTimeout(() => {
|
|
2897
|
+
pending.delete(id);
|
|
2898
|
+
resolve3({ content: `The app did not answer "${name}" within ${CALL_TIMEOUT_MS / 1e3}s.`, isError: true });
|
|
2899
|
+
}, CALL_TIMEOUT_MS);
|
|
2900
|
+
pending.set(id, { resolve: resolve3, timer: timer2 });
|
|
2901
|
+
target.write(`data: ${JSON.stringify({ id, name, input })}
|
|
2902
|
+
|
|
2903
|
+
`);
|
|
2904
|
+
});
|
|
2905
|
+
}
|
|
2906
|
+
function resolveBridgeCall(id, content, isError) {
|
|
2907
|
+
const waiting = pending.get(id);
|
|
2908
|
+
if (!waiting) return;
|
|
2909
|
+
pending.delete(id);
|
|
2910
|
+
clearTimeout(waiting.timer);
|
|
2911
|
+
waiting.resolve({ content, isError });
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
// src/mcp.ts
|
|
2915
|
+
var FALLBACK_PROTOCOL = "2025-06-18";
|
|
2916
|
+
async function handleMcp(req, res) {
|
|
2917
|
+
const msg = req.body ?? {};
|
|
2918
|
+
const reply = (result) => res.json({ jsonrpc: "2.0", id: msg.id, result });
|
|
2919
|
+
switch (msg.method) {
|
|
2920
|
+
case "initialize":
|
|
2921
|
+
reply({
|
|
2922
|
+
protocolVersion: typeof msg.params?.protocolVersion === "string" ? msg.params.protocolVersion : FALLBACK_PROTOCOL,
|
|
2923
|
+
capabilities: { tools: {} },
|
|
2924
|
+
serverInfo: { name: "planche", version: "1" }
|
|
2925
|
+
});
|
|
2926
|
+
return;
|
|
2927
|
+
case "tools/list":
|
|
2928
|
+
reply({
|
|
2929
|
+
tools: bridgeTools().map((tool) => ({
|
|
2930
|
+
name: tool.name,
|
|
2931
|
+
description: tool.description,
|
|
2932
|
+
inputSchema: tool.inputSchema
|
|
2933
|
+
}))
|
|
2934
|
+
});
|
|
2935
|
+
return;
|
|
2936
|
+
case "tools/call": {
|
|
2937
|
+
const name = typeof msg.params?.name === "string" ? msg.params.name : "";
|
|
2938
|
+
const args = msg.params?.arguments ?? {};
|
|
2939
|
+
const { content, isError } = await callThroughBridge(name, args);
|
|
2940
|
+
reply({ content: [{ type: "text", text: content }], isError });
|
|
2941
|
+
return;
|
|
2942
|
+
}
|
|
2943
|
+
default:
|
|
2944
|
+
if (msg.id === void 0) {
|
|
2945
|
+
res.status(202).end();
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
reply({});
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// src/tunneling.ts
|
|
2953
|
+
import { randomUUID } from "crypto";
|
|
2954
|
+
|
|
2955
|
+
// src/drivers/connection-lost.ts
|
|
2956
|
+
var ConnectionLostError = class extends Error {
|
|
2957
|
+
status = 404;
|
|
2958
|
+
constructor(message) {
|
|
2959
|
+
super(message);
|
|
2960
|
+
this.name = "ConnectionLostError";
|
|
2961
|
+
}
|
|
2962
|
+
};
|
|
2963
|
+
var LOST_CODES = /* @__PURE__ */ new Set([
|
|
2964
|
+
// mysql2
|
|
2965
|
+
"PROTOCOL_CONNECTION_LOST",
|
|
2966
|
+
"PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR",
|
|
2967
|
+
"PROTOCOL_ENQUEUE_AFTER_QUIT",
|
|
2968
|
+
"PROTOCOL_ENQUEUE_AFTER_DESTROY",
|
|
2969
|
+
// node sockets (pg, mssql/tedious surface these directly)
|
|
2970
|
+
"ECONNRESET",
|
|
2971
|
+
"EPIPE",
|
|
2972
|
+
"ETIMEDOUT",
|
|
2973
|
+
"ECONNABORTED",
|
|
2974
|
+
"ENETUNREACH",
|
|
2975
|
+
"EHOSTUNREACH",
|
|
2976
|
+
"ENETDOWN",
|
|
2977
|
+
// mssql
|
|
2978
|
+
"ESOCKET",
|
|
2979
|
+
"ECLOSE",
|
|
2980
|
+
"ENOTOPEN",
|
|
2981
|
+
// pg: admin_shutdown / crash_shutdown / cannot_connect_now
|
|
2982
|
+
"57P01",
|
|
2983
|
+
"57P02",
|
|
2984
|
+
"57P03"
|
|
2985
|
+
]);
|
|
2986
|
+
var LOST_MESSAGES = [
|
|
2987
|
+
/connection is in closed state/i,
|
|
2988
|
+
// mysql2 "Can't add new command when connection is in closed state"
|
|
2989
|
+
/connection lost/i,
|
|
2990
|
+
/connection terminated/i,
|
|
2991
|
+
// pg
|
|
2992
|
+
/client has encountered a connection error/i,
|
|
2993
|
+
// pg
|
|
2994
|
+
/client was closed and is not queryable/i,
|
|
2995
|
+
// pg
|
|
2996
|
+
/connection is closed/i,
|
|
2997
|
+
// mssql
|
|
2998
|
+
/socket hang up/i,
|
|
2999
|
+
/ORA-0311[34]\b/,
|
|
3000
|
+
/ORA-01012\b/,
|
|
3001
|
+
/ORA-12541\b/,
|
|
3002
|
+
/ORA-03135\b/,
|
|
3003
|
+
/NJS-500\b/,
|
|
3004
|
+
/NJS-521\b/
|
|
3005
|
+
// oracle
|
|
3006
|
+
];
|
|
3007
|
+
function isConnectionLost(err) {
|
|
3008
|
+
if (!err || typeof err !== "object") return false;
|
|
3009
|
+
const e = err;
|
|
3010
|
+
if (typeof e.code === "string" && LOST_CODES.has(e.code)) return true;
|
|
3011
|
+
if (e.fatal === true) return true;
|
|
3012
|
+
if (typeof e.errorNum === "number" && [3113, 3114, 1012, 12541, 3135].includes(e.errorNum)) return true;
|
|
3013
|
+
const message = typeof e.message === "string" ? e.message : "";
|
|
3014
|
+
if (LOST_MESSAGES.some((re) => re.test(message))) return true;
|
|
3015
|
+
return e.originalError !== void 0 && e.originalError !== err && isConnectionLost(e.originalError);
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
// src/read-only.ts
|
|
3019
|
+
var ReadOnlyViolation = class extends Error {
|
|
3020
|
+
status = 403;
|
|
3021
|
+
constructor(message) {
|
|
3022
|
+
super(message);
|
|
3023
|
+
this.name = "ReadOnlyViolation";
|
|
3024
|
+
}
|
|
3025
|
+
};
|
|
3026
|
+
var READ_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "VALUES", "TABLE", "PRAGMA"]);
|
|
3027
|
+
function splitStatements(sql2) {
|
|
3028
|
+
const out = [];
|
|
3029
|
+
let cur = "";
|
|
3030
|
+
let i = 0;
|
|
3031
|
+
const n = sql2.length;
|
|
3032
|
+
while (i < n) {
|
|
3033
|
+
const ch = sql2[i];
|
|
3034
|
+
const next = sql2[i + 1];
|
|
3035
|
+
if (ch === "-" && next === "-") {
|
|
3036
|
+
while (i < n && sql2[i] !== "\n") i++;
|
|
3037
|
+
cur += " ";
|
|
3038
|
+
continue;
|
|
3039
|
+
}
|
|
3040
|
+
if (ch === "#") {
|
|
3041
|
+
while (i < n && sql2[i] !== "\n") i++;
|
|
3042
|
+
cur += " ";
|
|
3043
|
+
continue;
|
|
3044
|
+
}
|
|
3045
|
+
if (ch === "/" && next === "*") {
|
|
3046
|
+
i += 2;
|
|
3047
|
+
while (i < n && !(sql2[i] === "*" && sql2[i + 1] === "/")) i++;
|
|
3048
|
+
i += 2;
|
|
3049
|
+
cur += " ";
|
|
3050
|
+
continue;
|
|
3051
|
+
}
|
|
3052
|
+
if (ch === "'" || ch === '"' || ch === "`" || ch === "[") {
|
|
3053
|
+
const close = ch === "[" ? "]" : ch;
|
|
3054
|
+
i++;
|
|
3055
|
+
while (i < n) {
|
|
3056
|
+
if (sql2[i] === "\\" && close === "'") {
|
|
3057
|
+
i += 2;
|
|
3058
|
+
continue;
|
|
3059
|
+
}
|
|
3060
|
+
if (sql2[i] === close) {
|
|
3061
|
+
if (sql2[i + 1] === close && close !== "]") {
|
|
3062
|
+
i += 2;
|
|
3063
|
+
continue;
|
|
3064
|
+
}
|
|
3065
|
+
break;
|
|
3066
|
+
}
|
|
3067
|
+
i++;
|
|
3068
|
+
}
|
|
3069
|
+
i++;
|
|
3070
|
+
cur += ch === "[" ? " x " : " 'x' ";
|
|
3071
|
+
continue;
|
|
3072
|
+
}
|
|
3073
|
+
if (ch === "$" && next === "$") {
|
|
3074
|
+
const end = sql2.indexOf("$$", i + 2);
|
|
3075
|
+
i = end === -1 ? n : end + 2;
|
|
3076
|
+
cur += " 'x' ";
|
|
3077
|
+
continue;
|
|
3078
|
+
}
|
|
3079
|
+
if (ch === ";") {
|
|
3080
|
+
out.push(cur);
|
|
3081
|
+
cur = "";
|
|
3082
|
+
i++;
|
|
3083
|
+
continue;
|
|
3084
|
+
}
|
|
3085
|
+
cur += ch;
|
|
3086
|
+
i++;
|
|
3087
|
+
}
|
|
3088
|
+
out.push(cur);
|
|
3089
|
+
return out.map((s) => s.trim()).filter((s) => s.length > 0);
|
|
3090
|
+
}
|
|
3091
|
+
function assertReadOnlyStatement(sql2) {
|
|
3092
|
+
const statements = splitStatements(sql2);
|
|
3093
|
+
if (statements.length === 0) return;
|
|
3094
|
+
if (statements.length > 1) throw new ReadOnlyViolation("Read-only profile: one statement per request");
|
|
3095
|
+
const stmt = statements[0];
|
|
3096
|
+
const first = /^[A-Za-z]+/.exec(stmt)?.[0].toUpperCase() ?? "";
|
|
3097
|
+
if (!READ_KEYWORDS.has(first)) throw new ReadOnlyViolation(`Read-only profile: ${first || "this statement"} is not allowed`);
|
|
3098
|
+
if (first === "WITH") {
|
|
3099
|
+
const words = stmt.toUpperCase().match(/\b(INSERT|UPDATE|DELETE|MERGE|REPLACE)\b/);
|
|
3100
|
+
if (words) throw new ReadOnlyViolation(`Read-only profile: ${words[1]} inside WITH is not allowed`);
|
|
3101
|
+
}
|
|
3102
|
+
if (first === "PRAGMA" && /[=(]/.test(stmt)) throw new ReadOnlyViolation("Read-only profile: PRAGMA may only read");
|
|
3103
|
+
if (/\bINTO\s+(OUTFILE|DUMPFILE)\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: INTO OUTFILE is not allowed");
|
|
3104
|
+
if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: locking reads are not allowed");
|
|
3105
|
+
if (/\bLOCK\s+IN\s+SHARE\s+MODE\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: locking reads are not allowed");
|
|
3106
|
+
if (first === "SELECT" && /\bINTO\b/i.test(stmt) && !/\bINTO\s+@/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: SELECT INTO is not allowed");
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
// src/tunneling.ts
|
|
3110
|
+
var IDLE_TIMEOUT_MS = Number(process.env.PLANCHE_IDLE_TIMEOUT_MS) || 30 * 60 * 1e3;
|
|
3111
|
+
var MAX_CONNECTIONS = Number(process.env.PLANCHE_MAX_CONNECTIONS) || 64;
|
|
3112
|
+
var ConnectionManager = class {
|
|
3113
|
+
connections = /* @__PURE__ */ new Map();
|
|
3114
|
+
lastUsed = /* @__PURE__ */ new Map();
|
|
3115
|
+
/** Connections opened for a read-only gateway profile: writes are refused before reaching the driver. */
|
|
3116
|
+
readOnly = /* @__PURE__ */ new Set();
|
|
3117
|
+
constructor() {
|
|
3118
|
+
const timer2 = setInterval(() => void this.closeIdle(), 6e4);
|
|
3119
|
+
timer2.unref();
|
|
3120
|
+
}
|
|
3121
|
+
/** Close connections idle for longer than `IDLE_TIMEOUT_MS`. */
|
|
3122
|
+
async closeIdle() {
|
|
3123
|
+
const cutoff = Date.now() - IDLE_TIMEOUT_MS;
|
|
3124
|
+
for (const [id, used] of this.lastUsed) {
|
|
3125
|
+
if (used < cutoff) {
|
|
3126
|
+
console.log(`[ConnectionManager] Closing idle connection: ${id}`);
|
|
3127
|
+
await this.disconnect(id).catch(() => void 0);
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
/**
|
|
3132
|
+
* Open a new connection and return a unique connection ID.
|
|
3133
|
+
*/
|
|
3134
|
+
async connect(config, options = {}) {
|
|
3135
|
+
if (this.connections.size >= MAX_CONNECTIONS) {
|
|
3136
|
+
throw new Error(`Too many open connections (${MAX_CONNECTIONS}); disconnect some first`);
|
|
3137
|
+
}
|
|
3138
|
+
const driver = await openDriver(config);
|
|
3139
|
+
if (options.readOnly && driver.setReadOnly) {
|
|
3140
|
+
try {
|
|
3141
|
+
await driver.setReadOnly();
|
|
3142
|
+
} catch (err) {
|
|
3143
|
+
await driver.close().catch(() => void 0);
|
|
3144
|
+
throw err;
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
const connectionId = randomUUID();
|
|
3148
|
+
this.connections.set(connectionId, driver);
|
|
3149
|
+
this.lastUsed.set(connectionId, Date.now());
|
|
3150
|
+
if (options.readOnly) this.readOnly.add(connectionId);
|
|
3151
|
+
console.log(`[ConnectionManager] New ${driver.type} connection: ${connectionId} -> ${driver.label}`);
|
|
3152
|
+
return connectionId;
|
|
3153
|
+
}
|
|
3154
|
+
has(connectionId) {
|
|
3155
|
+
return this.connections.has(connectionId);
|
|
3156
|
+
}
|
|
3157
|
+
/**
|
|
3158
|
+
* Execute a SQL query on an existing connection.
|
|
3159
|
+
* Optionally switch database before executing (drivers that support it).
|
|
3160
|
+
*/
|
|
3161
|
+
async query(connectionId, sql2, database) {
|
|
3162
|
+
const driver = this.connections.get(connectionId);
|
|
3163
|
+
if (!driver) {
|
|
3164
|
+
throw new Error(`Connection not found: ${connectionId}`);
|
|
3165
|
+
}
|
|
3166
|
+
this.lastUsed.set(connectionId, Date.now());
|
|
3167
|
+
if (this.readOnly.has(connectionId)) assertReadOnlyStatement(sql2);
|
|
3168
|
+
try {
|
|
3169
|
+
return await driver.query(sql2, database);
|
|
3170
|
+
} catch (err) {
|
|
3171
|
+
if (!isConnectionLost(err)) throw err;
|
|
3172
|
+
console.log(`[ConnectionManager] Connection lost, evicting: ${connectionId}`);
|
|
3173
|
+
this.connections.delete(connectionId);
|
|
3174
|
+
this.lastUsed.delete(connectionId);
|
|
3175
|
+
this.readOnly.delete(connectionId);
|
|
3176
|
+
await driver.close().catch(() => void 0);
|
|
3177
|
+
throw new ConnectionLostError(`Connection lost: ${err instanceof Error ? err.message : String(err)}`);
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
/**
|
|
3181
|
+
* Interrupt whatever is running on a connection. Best effort: an engine with
|
|
3182
|
+
* no way to interrupt (better-sqlite3 blocks the thread) reports false rather
|
|
3183
|
+
* than claiming a cancel that did not happen. The connection stays open —
|
|
3184
|
+
* the point is to free it, not to drop it.
|
|
3185
|
+
*/
|
|
3186
|
+
async cancel(connectionId) {
|
|
3187
|
+
const driver = this.connections.get(connectionId);
|
|
3188
|
+
if (!driver) throw new Error(`Connection not found: ${connectionId}`);
|
|
3189
|
+
if (!driver.cancel) return { cancelled: false, abandoned: false };
|
|
3190
|
+
await driver.cancel();
|
|
3191
|
+
return { cancelled: true, abandoned: driver.cancelKind === "abandon" };
|
|
3192
|
+
}
|
|
3193
|
+
/**
|
|
3194
|
+
* Close and remove a connection.
|
|
3195
|
+
*/
|
|
3196
|
+
async disconnect(connectionId) {
|
|
3197
|
+
const driver = this.connections.get(connectionId);
|
|
3198
|
+
if (!driver) {
|
|
3199
|
+
throw new Error(`Connection not found: ${connectionId}`);
|
|
3200
|
+
}
|
|
3201
|
+
await driver.close();
|
|
3202
|
+
this.connections.delete(connectionId);
|
|
3203
|
+
this.lastUsed.delete(connectionId);
|
|
3204
|
+
this.readOnly.delete(connectionId);
|
|
3205
|
+
console.log(`[ConnectionManager] Disconnected: ${connectionId}`);
|
|
3206
|
+
}
|
|
3207
|
+
/**
|
|
3208
|
+
* Validate a config by opening and immediately closing a connection.
|
|
3209
|
+
*/
|
|
3210
|
+
async testConnection(config) {
|
|
3211
|
+
await testDriver(config);
|
|
3212
|
+
return true;
|
|
3213
|
+
}
|
|
3214
|
+
/**
|
|
3215
|
+
* Get the number of active connections.
|
|
3216
|
+
*/
|
|
3217
|
+
get activeConnections() {
|
|
3218
|
+
return this.connections.size;
|
|
3219
|
+
}
|
|
3220
|
+
/**
|
|
3221
|
+
* Close all connections (for graceful shutdown).
|
|
3222
|
+
*/
|
|
3223
|
+
async disconnectAll() {
|
|
3224
|
+
const promises = Array.from(this.connections.entries()).map(
|
|
3225
|
+
async ([id, driver]) => {
|
|
3226
|
+
try {
|
|
3227
|
+
await driver.close();
|
|
3228
|
+
console.log(`[ConnectionManager] Disconnected: ${id}`);
|
|
3229
|
+
} catch {
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
);
|
|
3233
|
+
await Promise.all(promises);
|
|
3234
|
+
this.connections.clear();
|
|
3235
|
+
this.lastUsed.clear();
|
|
3236
|
+
this.readOnly.clear();
|
|
3237
|
+
}
|
|
3238
|
+
};
|
|
3239
|
+
|
|
3240
|
+
// src/server.ts
|
|
3241
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
3242
|
+
import { join as join9 } from "path";
|
|
3243
|
+
function errorStatus(err) {
|
|
3244
|
+
const status = err?.status;
|
|
3245
|
+
return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
|
|
3246
|
+
}
|
|
3247
|
+
function fail(res, status, message, extra = {}) {
|
|
3248
|
+
res.status(status).json({ success: false, message, ...extra });
|
|
3249
|
+
}
|
|
3250
|
+
function errorExtra(err) {
|
|
3251
|
+
if (err instanceof HostKeyError) {
|
|
3252
|
+
const body = { code: "host-key-unknown", hostKey: err.hostKey };
|
|
3253
|
+
return body;
|
|
3254
|
+
}
|
|
3255
|
+
return {};
|
|
3256
|
+
}
|
|
3257
|
+
var errorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
3258
|
+
function secretEquals(given, expected) {
|
|
3259
|
+
const a = createHash2("sha256").update(given).digest();
|
|
3260
|
+
const b = createHash2("sha256").update(expected).digest();
|
|
3261
|
+
return timingSafeEqual2(a, b);
|
|
3262
|
+
}
|
|
3263
|
+
function bearerOf(header) {
|
|
3264
|
+
return header?.startsWith("Bearer ") ? header.slice(7) : null;
|
|
3265
|
+
}
|
|
3266
|
+
var AuthThrottle = class {
|
|
3267
|
+
constructor(max = 10, windowMs = 6e4, lockMs = 3e4) {
|
|
3268
|
+
this.max = max;
|
|
3269
|
+
this.windowMs = windowMs;
|
|
3270
|
+
this.lockMs = lockMs;
|
|
3271
|
+
}
|
|
3272
|
+
max;
|
|
3273
|
+
windowMs;
|
|
3274
|
+
lockMs;
|
|
3275
|
+
failures = /* @__PURE__ */ new Map();
|
|
3276
|
+
lockedUntil = /* @__PURE__ */ new Map();
|
|
3277
|
+
/** Keyed by peer address so one local process cannot lock the browser out. */
|
|
3278
|
+
locked(peer) {
|
|
3279
|
+
return Date.now() < (this.lockedUntil.get(peer) ?? 0);
|
|
3280
|
+
}
|
|
3281
|
+
fail(peer) {
|
|
3282
|
+
const now = Date.now();
|
|
3283
|
+
const recent = (this.failures.get(peer) ?? []).filter((t) => now - t < this.windowMs);
|
|
3284
|
+
recent.push(now);
|
|
3285
|
+
if (recent.length >= this.max) {
|
|
3286
|
+
this.lockedUntil.set(peer, now + this.lockMs);
|
|
3287
|
+
this.failures.delete(peer);
|
|
3288
|
+
console.warn(`[agent] Too many bad tokens from ${peer}; refusing its authentication for ${this.lockMs / 1e3}s`);
|
|
3289
|
+
} else {
|
|
3290
|
+
this.failures.set(peer, recent);
|
|
3291
|
+
}
|
|
3292
|
+
if (this.failures.size > 1e3) this.failures.clear();
|
|
3293
|
+
}
|
|
3294
|
+
};
|
|
3295
|
+
function accessControl(allowed) {
|
|
3296
|
+
return (req, res, next) => {
|
|
3297
|
+
const origin = req.headers.origin;
|
|
3298
|
+
if (origin === void 0) {
|
|
3299
|
+
next();
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
if (!allowed.has(origin)) {
|
|
3303
|
+
fail(res, 403, `Origin not allowed: ${origin}`);
|
|
3304
|
+
return;
|
|
3305
|
+
}
|
|
3306
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
3307
|
+
res.setHeader("Vary", "Origin");
|
|
3308
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
3309
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
3310
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
3311
|
+
if (req.headers["access-control-request-private-network"] === "true") {
|
|
3312
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
3313
|
+
}
|
|
3314
|
+
if (req.headers["access-control-request-local-network"] === "true") {
|
|
3315
|
+
res.setHeader("Access-Control-Allow-Local-Network", "true");
|
|
3316
|
+
}
|
|
3317
|
+
if (req.method === "OPTIONS") {
|
|
3318
|
+
res.sendStatus(204);
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
3321
|
+
next();
|
|
3322
|
+
};
|
|
3323
|
+
}
|
|
3324
|
+
function createAgent(options) {
|
|
3325
|
+
const app = express();
|
|
3326
|
+
const manager = new ConnectionManager();
|
|
3327
|
+
const allowedOrigins = new Set(options.allowedOrigins.map((o) => o.replace(/\/+$/, "")));
|
|
3328
|
+
const allowedHosts = options.allowedHosts ? new Set(options.allowedHosts.map((h) => h.toLowerCase())) : null;
|
|
3329
|
+
const throttle = new AuthThrottle();
|
|
3330
|
+
const pairThrottle = new AuthThrottle();
|
|
3331
|
+
let pairing = options.pairingCode;
|
|
3332
|
+
let lastPairOffer = 0;
|
|
3333
|
+
app.set("env", "production");
|
|
3334
|
+
app.disable("x-powered-by");
|
|
3335
|
+
app.disable("etag");
|
|
3336
|
+
const peerOf = (req) => req.socket.remoteAddress ?? "unknown";
|
|
3337
|
+
const authorized = (req) => {
|
|
3338
|
+
const peer = peerOf(req);
|
|
3339
|
+
if (throttle.locked(peer)) return false;
|
|
3340
|
+
const given = bearerOf(req.headers.authorization);
|
|
3341
|
+
if (given === null) return false;
|
|
3342
|
+
const ok = secretEquals(given, options.authToken);
|
|
3343
|
+
if (!ok) throttle.fail(peer);
|
|
3344
|
+
return ok;
|
|
3345
|
+
};
|
|
3346
|
+
if (options.logRequests) {
|
|
3347
|
+
app.use((req, _res, next) => {
|
|
3348
|
+
const hasAuth = !!req.headers.authorization;
|
|
3349
|
+
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${req.method} ${req.url} (auth: ${hasAuth})`);
|
|
3350
|
+
next();
|
|
3351
|
+
});
|
|
3352
|
+
}
|
|
3353
|
+
app.use((req, res, next) => {
|
|
3354
|
+
res.setHeader("Cache-Control", "no-store");
|
|
3355
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
3356
|
+
if (allowedHosts) {
|
|
3357
|
+
const host = req.headers.host?.toLowerCase();
|
|
3358
|
+
if (!host || !allowedHosts.has(host)) {
|
|
3359
|
+
fail(res, 403, "Invalid Host header");
|
|
3360
|
+
return;
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
next();
|
|
3364
|
+
});
|
|
3365
|
+
app.use(accessControl(allowedOrigins));
|
|
3366
|
+
if (options.appDir) serveApp(app, options.appDir, options.shell);
|
|
3367
|
+
const PAIR_OFFER_INTERVAL_MS = 6e4;
|
|
3368
|
+
function offerPairing(req) {
|
|
3369
|
+
if (!options.onUnpaired) return false;
|
|
3370
|
+
const origin = req.headers.origin;
|
|
3371
|
+
if (typeof origin !== "string" || !allowedOrigins.has(origin.replace(/\/+$/, ""))) return false;
|
|
3372
|
+
const now = Date.now();
|
|
3373
|
+
if (pairing && now <= pairing.expiresAt) return false;
|
|
3374
|
+
if (now - lastPairOffer < PAIR_OFFER_INTERVAL_MS) return false;
|
|
3375
|
+
lastPairOffer = now;
|
|
3376
|
+
pairing = { code: randomUUID2(), expiresAt: now + 10 * 60 * 1e3 };
|
|
3377
|
+
options.onUnpaired(pairing, origin);
|
|
3378
|
+
return true;
|
|
3379
|
+
}
|
|
3380
|
+
app.get("/health", async (req, res) => {
|
|
3381
|
+
const body = {
|
|
3382
|
+
success: true,
|
|
3383
|
+
name: "planche",
|
|
3384
|
+
version: options.version,
|
|
3385
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
3386
|
+
activeConnections: manager.activeConnections,
|
|
3387
|
+
uptime: process.uptime()
|
|
3388
|
+
};
|
|
3389
|
+
const ai = await aiConfigView();
|
|
3390
|
+
body.ai = { configured: ai.configured, model: ai.model, tools: true };
|
|
3391
|
+
if (options.install) body.install = options.install;
|
|
3392
|
+
if (options.gateway) body.gateway = { name: options.gateway.name, profiles: options.gateway.profiles.length };
|
|
3393
|
+
if (req.headers.authorization !== void 0) {
|
|
3394
|
+
body.authorized = authorized(req);
|
|
3395
|
+
}
|
|
3396
|
+
if (body.authorized !== true) void offerPairing(req);
|
|
3397
|
+
res.json(body);
|
|
3398
|
+
});
|
|
3399
|
+
app.post("/pair", express.json({ limit: "1kb" }), (req, res) => {
|
|
3400
|
+
if (pairThrottle.locked(peerOf(req))) {
|
|
3401
|
+
fail(res, 429, "Too many failed pairing attempts; try again shortly");
|
|
3402
|
+
return;
|
|
3403
|
+
}
|
|
3404
|
+
const code = typeof req.body?.code === "string" ? req.body.code : "";
|
|
3405
|
+
if (!pairing || Date.now() > pairing.expiresAt || !code || !secretEquals(code, pairing.code)) {
|
|
3406
|
+
pairThrottle.fail(peerOf(req));
|
|
3407
|
+
const printed = offerPairing(req);
|
|
3408
|
+
fail(res, 401, printed ? "Pairing code is invalid or has expired; a fresh link has been printed in the agent's terminal" : "Pairing code is invalid or has expired; open the pairing link shown in the agent's terminal");
|
|
3409
|
+
return;
|
|
3410
|
+
}
|
|
3411
|
+
pairing = void 0;
|
|
3412
|
+
const body = { success: true, token: options.authToken };
|
|
3413
|
+
res.json(body);
|
|
3414
|
+
});
|
|
3415
|
+
async function mcpAuthorized(req) {
|
|
3416
|
+
if (req.path !== "/mcp") return false;
|
|
3417
|
+
const given = bearerOf(req.headers.authorization);
|
|
3418
|
+
const expected = await readMcpToken();
|
|
3419
|
+
if (!given || !expected) return false;
|
|
3420
|
+
return secretEquals(given, expected);
|
|
3421
|
+
}
|
|
3422
|
+
app.use((req, res, next) => {
|
|
3423
|
+
if (throttle.locked(peerOf(req))) {
|
|
3424
|
+
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
3425
|
+
return;
|
|
3426
|
+
}
|
|
3427
|
+
if (authorized(req)) {
|
|
3428
|
+
next();
|
|
3429
|
+
return;
|
|
3430
|
+
}
|
|
3431
|
+
void mcpAuthorized(req).then((ok) => {
|
|
3432
|
+
if (ok) {
|
|
3433
|
+
next();
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
fail(res, 401, "Unauthorized: invalid or missing auth token");
|
|
3437
|
+
});
|
|
3438
|
+
});
|
|
3439
|
+
app.use(express.json({ limit: options.bodyLimit }));
|
|
3440
|
+
app.get("/profiles", (_req, res) => {
|
|
3441
|
+
if (!options.gateway) {
|
|
3442
|
+
fail(res, 404, "Not a gateway");
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
const body = { success: true, gateway: options.gateway.name, profiles: options.gateway.profiles.map(describeProfile) };
|
|
3446
|
+
res.json(body);
|
|
3447
|
+
});
|
|
3448
|
+
const resolveConnect = (body) => {
|
|
3449
|
+
const b = body ?? {};
|
|
3450
|
+
if (options.gateway) {
|
|
3451
|
+
if (typeof b.profile !== "string") throw Object.assign(new Error("Gateway mode: choose a profile (POST { profile })"), { status: 403 });
|
|
3452
|
+
const entry = options.gateway.profiles.find((p) => p.name === b.profile);
|
|
3453
|
+
if (!entry) throw Object.assign(new Error(`Unknown gateway profile: ${b.profile}`), { status: 404 });
|
|
3454
|
+
return { config: entry.config, readOnly: entry.readOnly };
|
|
3455
|
+
}
|
|
3456
|
+
return { config: parseDriverConfig(b), readOnly: false };
|
|
3457
|
+
};
|
|
3458
|
+
app.post("/connect", async (req, res) => {
|
|
3459
|
+
try {
|
|
3460
|
+
const { config, readOnly } = resolveConnect(req.body);
|
|
3461
|
+
const connectionId = await manager.connect(config, { readOnly });
|
|
3462
|
+
res.json({ success: true, connectionId, message: "Connection Successful" });
|
|
3463
|
+
} catch (err) {
|
|
3464
|
+
const message = errorMessage(err);
|
|
3465
|
+
console.error("[connect] Error:", message);
|
|
3466
|
+
fail(res, errorStatus(err), message, errorExtra(err));
|
|
3467
|
+
}
|
|
3468
|
+
});
|
|
3469
|
+
app.post("/query", async (req, res) => {
|
|
3470
|
+
try {
|
|
3471
|
+
const { connectionId, sql: sql2, database } = req.body ?? {};
|
|
3472
|
+
if (typeof connectionId !== "string" || typeof sql2 !== "string" || !connectionId || !sql2) {
|
|
3473
|
+
fail(res, 400, "connectionId and sql are required");
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
if (!manager.has(connectionId)) {
|
|
3477
|
+
fail(res, 404, `Connection not found: ${connectionId}`);
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
const start = Date.now();
|
|
3481
|
+
const result = await manager.query(connectionId, sql2, typeof database === "string" ? database : void 0);
|
|
3482
|
+
const execTime = Date.now() - start;
|
|
3483
|
+
res.json({
|
|
3484
|
+
success: true,
|
|
3485
|
+
fields: result.fields,
|
|
3486
|
+
records: result.records,
|
|
3487
|
+
affectedRows: result.affectedRows,
|
|
3488
|
+
insertId: result.insertId,
|
|
3489
|
+
isResultQuery: result.isResultQuery,
|
|
3490
|
+
execTime
|
|
3491
|
+
});
|
|
3492
|
+
} catch (err) {
|
|
3493
|
+
const message = errorMessage(err);
|
|
3494
|
+
console.error("[query] Error:", message);
|
|
3495
|
+
if (err instanceof ConnectionLostError) {
|
|
3496
|
+
fail(res, 404, message);
|
|
3497
|
+
return;
|
|
3498
|
+
}
|
|
3499
|
+
fail(res, 422, message);
|
|
3500
|
+
}
|
|
3501
|
+
});
|
|
3502
|
+
app.post("/usage", (req, res) => {
|
|
3503
|
+
const feature = (req.body ?? {}).feature;
|
|
3504
|
+
if (isProFeature(feature)) recordUsage(feature);
|
|
3505
|
+
res.json({ success: true });
|
|
3506
|
+
});
|
|
3507
|
+
app.get("/usage", async (_req, res) => {
|
|
3508
|
+
await flushUsage();
|
|
3509
|
+
res.json(await usageSnapshot());
|
|
3510
|
+
});
|
|
3511
|
+
app.get("/ai/config", async (_req, res) => {
|
|
3512
|
+
res.json({ success: true, ...await aiConfigView() });
|
|
3513
|
+
});
|
|
3514
|
+
app.put("/ai/config", async (req, res) => {
|
|
3515
|
+
const current = await aiConfigView();
|
|
3516
|
+
if (current.fromEnvironment) {
|
|
3517
|
+
fail(res, 409, "This agent's AI settings come from its environment and cannot be changed from the app.");
|
|
3518
|
+
return;
|
|
3519
|
+
}
|
|
3520
|
+
const body = req.body ?? {};
|
|
3521
|
+
const provider = body.provider === "openai" || body.provider === "gemini" || body.provider === "cli" || body.provider === "anthropic-profile" ? body.provider : "anthropic";
|
|
3522
|
+
const keyless = isKeyless(provider);
|
|
3523
|
+
const key = typeof body.key === "string" ? body.key.trim() : "";
|
|
3524
|
+
if (!key && !keyless) {
|
|
3525
|
+
fail(res, 400, "key is required");
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
const model = typeof body.model === "string" && body.model.trim() ? body.model.trim() : defaultModelFor(provider);
|
|
3529
|
+
if (provider === "cli" && !isCliCommand(model)) {
|
|
3530
|
+
fail(res, 400, `model must name an installed CLI: ${CLI_COMMANDS.join(", ")}`);
|
|
3531
|
+
return;
|
|
3532
|
+
}
|
|
3533
|
+
const baseUrl = typeof body.baseUrl === "string" ? body.baseUrl.trim() : "";
|
|
3534
|
+
if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
|
|
3535
|
+
fail(res, 400, "baseUrl must be an http(s) URL");
|
|
3536
|
+
return;
|
|
3537
|
+
}
|
|
3538
|
+
const written = await saveAiConfig({
|
|
3539
|
+
provider,
|
|
3540
|
+
model,
|
|
3541
|
+
baseUrl: baseUrl || void 0,
|
|
3542
|
+
key
|
|
3543
|
+
}, body.remember === true);
|
|
3544
|
+
console.log(
|
|
3545
|
+
provider === "cli" ? `[agent] AI answers will come from the local "${model}" CLI; no key is held. The choice is kept in ${aiConfigPath}.` : provider === "anthropic-profile" ? `[agent] AI answers will use this machine's own Anthropic credential; no key is held. The choice is kept in ${aiConfigPath}.` : written ? `[agent] AI key saved to ${aiConfigPath} (owner-only). Remove it with "Forget key" in Settings, or by deleting that file.` : "[agent] AI key accepted for this session only; it is not written to disk."
|
|
3546
|
+
);
|
|
3547
|
+
res.json({ success: true, ...await aiConfigView() });
|
|
3548
|
+
});
|
|
3549
|
+
app.delete("/ai/config", async (_req, res) => {
|
|
3550
|
+
await forgetAiConfig();
|
|
3551
|
+
res.json({ success: true, ...await aiConfigView() });
|
|
3552
|
+
});
|
|
3553
|
+
app.post("/ai/run", async (req, res) => {
|
|
3554
|
+
const body = req.body ?? {};
|
|
3555
|
+
const hasTurns = Array.isArray(body.messages) && body.messages.length > 0;
|
|
3556
|
+
const hasUser = typeof body.user === "string" && body.user.trim() !== "";
|
|
3557
|
+
if (typeof body.system !== "string" || !hasTurns && !hasUser) {
|
|
3558
|
+
fail(res, 400, "system and one of user/messages are required");
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
const stop = new AbortController();
|
|
3562
|
+
res.on("close", () => {
|
|
3563
|
+
if (!res.writableFinished) stop.abort();
|
|
3564
|
+
});
|
|
3565
|
+
try {
|
|
3566
|
+
const answer = await runPrompt({
|
|
3567
|
+
system: body.system,
|
|
3568
|
+
...hasUser ? { user: body.user } : {},
|
|
3569
|
+
...hasTurns ? { messages: body.messages } : {},
|
|
3570
|
+
...Array.isArray(body.tools) && body.tools.length > 0 ? { tools: body.tools } : {},
|
|
3571
|
+
model: typeof body.model === "string" ? body.model : void 0
|
|
3572
|
+
}, stop.signal);
|
|
3573
|
+
if (!stop.signal.aborted) res.json(answer);
|
|
3574
|
+
} catch (err) {
|
|
3575
|
+
if (stop.signal.aborted) return;
|
|
3576
|
+
const e = err instanceof AiError ? err : new AiError(502, String(err));
|
|
3577
|
+
fail(res, e.status, e.message);
|
|
3578
|
+
}
|
|
3579
|
+
});
|
|
3580
|
+
app.post("/mcp", async (req, res) => {
|
|
3581
|
+
await handleMcp(req, res);
|
|
3582
|
+
});
|
|
3583
|
+
app.get("/bridge/events", (req, res) => {
|
|
3584
|
+
res.writeHead(200, {
|
|
3585
|
+
"content-type": "text/event-stream",
|
|
3586
|
+
"cache-control": "no-store",
|
|
3587
|
+
connection: "keep-alive"
|
|
3588
|
+
});
|
|
3589
|
+
res.write(": open\n\n");
|
|
3590
|
+
attachBridge(res);
|
|
3591
|
+
});
|
|
3592
|
+
app.post("/bridge/tools", (req, res) => {
|
|
3593
|
+
const tools2 = req.body?.tools;
|
|
3594
|
+
if (!Array.isArray(tools2)) {
|
|
3595
|
+
fail(res, 400, "tools must be an array");
|
|
3596
|
+
return;
|
|
3597
|
+
}
|
|
3598
|
+
setBridgeTools(tools2);
|
|
3599
|
+
res.json({ success: true });
|
|
3600
|
+
});
|
|
3601
|
+
app.post("/bridge/result", (req, res) => {
|
|
3602
|
+
const { id, content, isError } = req.body ?? {};
|
|
3603
|
+
if (typeof id !== "string") {
|
|
3604
|
+
fail(res, 400, "id is required");
|
|
3605
|
+
return;
|
|
3606
|
+
}
|
|
3607
|
+
resolveBridgeCall(id, typeof content === "string" ? content : "", isError === true);
|
|
3608
|
+
res.json({ success: true });
|
|
3609
|
+
});
|
|
3610
|
+
app.post("/mcp/setup", async (_req, res) => {
|
|
3611
|
+
const url = mcpUrl();
|
|
3612
|
+
if (!url) {
|
|
3613
|
+
fail(res, 503, "The agent is not listening yet.");
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3616
|
+
const body = { success: true, url, token: await ensureMcpToken(), path: mcpTokenPath };
|
|
3617
|
+
console.log(`[agent] MCP token handed to the app; stored at ${mcpTokenPath} (owner-only).`);
|
|
3618
|
+
res.json(body);
|
|
3619
|
+
});
|
|
3620
|
+
app.delete("/mcp/setup", async (_req, res) => {
|
|
3621
|
+
await forgetMcpToken();
|
|
3622
|
+
console.log(`[agent] MCP token revoked; ${mcpTokenPath} removed.`);
|
|
3623
|
+
res.json({ success: true });
|
|
3624
|
+
});
|
|
3625
|
+
app.post("/cancel", async (req, res) => {
|
|
3626
|
+
try {
|
|
3627
|
+
const connectionId = req.body?.connectionId;
|
|
3628
|
+
if (typeof connectionId !== "string" || !connectionId) {
|
|
3629
|
+
fail(res, 400, "connectionId is required");
|
|
3630
|
+
return;
|
|
3631
|
+
}
|
|
3632
|
+
const { cancelled, abandoned } = await manager.cancel(connectionId);
|
|
3633
|
+
const body = {
|
|
3634
|
+
success: true,
|
|
3635
|
+
cancelled,
|
|
3636
|
+
abandoned,
|
|
3637
|
+
message: !cancelled ? "This engine cannot interrupt a running statement" : abandoned ? "Stopped waiting for the request; the query may still finish upstream" : "Cancel sent"
|
|
3638
|
+
};
|
|
3639
|
+
res.json(body);
|
|
3640
|
+
} catch (err) {
|
|
3641
|
+
fail(res, errorStatus(err), errorMessage(err));
|
|
3642
|
+
}
|
|
3643
|
+
});
|
|
3644
|
+
app.post("/disconnect", async (req, res) => {
|
|
3645
|
+
try {
|
|
3646
|
+
const { connectionId } = req.body ?? {};
|
|
3647
|
+
if (typeof connectionId !== "string" || !connectionId) {
|
|
3648
|
+
fail(res, 400, "connectionId is required");
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
if (!manager.has(connectionId)) {
|
|
3652
|
+
fail(res, 404, `Connection not found: ${connectionId}`);
|
|
3653
|
+
return;
|
|
3654
|
+
}
|
|
3655
|
+
await manager.disconnect(connectionId);
|
|
3656
|
+
res.json({ success: true, message: "Disconnected" });
|
|
3657
|
+
} catch (err) {
|
|
3658
|
+
const message = errorMessage(err);
|
|
3659
|
+
console.error("[disconnect] Error:", message);
|
|
3660
|
+
fail(res, 500, message);
|
|
3661
|
+
}
|
|
3662
|
+
});
|
|
3663
|
+
app.post("/test-connection", async (req, res) => {
|
|
3664
|
+
try {
|
|
3665
|
+
await manager.testConnection(resolveConnect(req.body).config);
|
|
3666
|
+
res.json({ success: true, message: "Connection test successful" });
|
|
3667
|
+
} catch (err) {
|
|
3668
|
+
const message = errorMessage(err);
|
|
3669
|
+
console.error("[test-connection] Error:", message);
|
|
3670
|
+
fail(res, errorStatus(err), message, errorExtra(err));
|
|
3671
|
+
}
|
|
3672
|
+
});
|
|
3673
|
+
app.use((_req, res) => fail(res, 404, "Not found"));
|
|
3674
|
+
app.use((err, _req, res, _next) => {
|
|
3675
|
+
const e = err;
|
|
3676
|
+
const status = e?.type === "entity.too.large" ? 413 : typeof e?.status === "number" && e.status >= 400 && e.status < 600 ? e.status : 400;
|
|
3677
|
+
console.error("[agent] Request error:", e?.message ?? String(err));
|
|
3678
|
+
if (res.headersSent) return;
|
|
3679
|
+
fail(res, status, status === 413 ? "Request body too large" : status === 400 ? "Malformed request" : "Request failed");
|
|
3680
|
+
});
|
|
3681
|
+
return { app, manager };
|
|
3682
|
+
}
|
|
3683
|
+
function serveApp(app, appDir, shell) {
|
|
3684
|
+
const csp = [
|
|
3685
|
+
"default-src 'self'",
|
|
3686
|
+
"script-src 'self' 'wasm-unsafe-eval'",
|
|
3687
|
+
"connect-src 'self'",
|
|
3688
|
+
"img-src 'self' data: blob:",
|
|
3689
|
+
"style-src 'self' 'unsafe-inline'",
|
|
3690
|
+
"font-src 'self' data:",
|
|
3691
|
+
"worker-src 'self' blob:",
|
|
3692
|
+
"child-src 'self' blob:",
|
|
3693
|
+
"object-src 'none'",
|
|
3694
|
+
"base-uri 'none'",
|
|
3695
|
+
"form-action 'none'",
|
|
3696
|
+
"frame-ancestors 'none'"
|
|
3697
|
+
].join("; ");
|
|
3698
|
+
const indexFile = join9(appDir, "index.html");
|
|
3699
|
+
const indexHtml = existsSync2(indexFile) ? readFileSync2(indexFile, "utf8").replace("<head>", '<head>\n <meta name="planche-agent" content="same-origin" />' + (shell ? `
|
|
3700
|
+
<meta name="planche-shell" content="${shell}" />` : "")) : null;
|
|
3701
|
+
const headers = (res) => {
|
|
3702
|
+
res.setHeader("Content-Security-Policy", csp);
|
|
3703
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
3704
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
3705
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
3706
|
+
};
|
|
3707
|
+
app.get(["/", "/index.html"], (_req, res) => {
|
|
3708
|
+
if (!indexHtml) {
|
|
3709
|
+
fail(res, 404, "App not bundled");
|
|
3710
|
+
return;
|
|
3711
|
+
}
|
|
3712
|
+
headers(res);
|
|
3713
|
+
res.type("html").send(indexHtml);
|
|
3714
|
+
});
|
|
3715
|
+
app.use(express.static(appDir, {
|
|
3716
|
+
index: false,
|
|
3717
|
+
setHeaders: (res, path) => {
|
|
3718
|
+
if (path.endsWith(".html")) headers(res);
|
|
3719
|
+
if (/\/assets\//.test(path)) res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
|
3720
|
+
}
|
|
3721
|
+
}));
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
export {
|
|
3725
|
+
PROTOCOL_VERSION,
|
|
3726
|
+
configureCliBridge,
|
|
3727
|
+
mcpTokenPath,
|
|
3728
|
+
ensureMcpToken,
|
|
3729
|
+
forgetMcpToken,
|
|
3730
|
+
flushUsage,
|
|
3731
|
+
loadGatewayConfig,
|
|
3732
|
+
createAgent
|
|
3733
|
+
};
|