planche 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/README.md +39 -0
  2. package/app/_headers +19 -0
  3. package/app/assets/AboutDialog-HsaLhYrq.js +1 -0
  4. package/app/assets/ConnectDialog-DAYnaSzA.js +1 -0
  5. package/app/assets/CopyDatabaseDialog-DCDkX1Zt.js +1 -0
  6. package/app/assets/CopyTableDialog-BylicT1d.js +1 -0
  7. package/app/assets/CreateDatabaseDialog-i7_uV-wh.js +1 -0
  8. package/app/assets/DataDiffDialog-B4SeJXry.js +1 -0
  9. package/app/assets/DownloadToCSVDialog-jX0lEP8B.js +3 -0
  10. package/app/assets/EditSchemaDialog-BVOHVgBR.js +1 -0
  11. package/app/assets/FindReplaceDialog-B0rD26fg.js +1 -0
  12. package/app/assets/FlushDialog-CNu4QuYo.js +1 -0
  13. package/app/assets/LinkColumnDialog-BwUBtMiy.js +1 -0
  14. package/app/assets/LocalHistoryDialog-DUlsyn8i.js +1 -0
  15. package/app/assets/ProcessListDialog-Chv324I7.js +1 -0
  16. package/app/assets/QuickCommandPalette-BG5A7y3V.js +1 -0
  17. package/app/assets/RowFormDialog-C5agNu3l.js +2 -0
  18. package/app/assets/SafetySettingsDialog-iaN6H-EP.js +1 -0
  19. package/app/assets/SchemaDiffDialog-D7re7sup.js +7 -0
  20. package/app/assets/SchemaToHTMLDialog-Biq3ELkl.js +27 -0
  21. package/app/assets/StatusDialog-D8CcfFkP.js +1 -0
  22. package/app/assets/TermsDialog-DbHxb0ld.js +1 -0
  23. package/app/assets/UserAddDialog-B_fz-cXy.js +1 -0
  24. package/app/assets/UserManagerDialog-ku7exEzu.js +1 -0
  25. package/app/assets/VariablesDialog-BP2RcCBK.js +1 -0
  26. package/app/assets/abap-08VXUWAP.js +1 -0
  27. package/app/assets/apex-BWPQTe0t.js +1 -0
  28. package/app/assets/app-storage-B6v72zLH.js +1 -0
  29. package/app/assets/azcli-Bc_sGQ0U.js +1 -0
  30. package/app/assets/bat-i0X4ZdIN.js +1 -0
  31. package/app/assets/bicep-B5-_aFwp.js +2 -0
  32. package/app/assets/button-DkRlMkru.js +1 -0
  33. package/app/assets/cameligo-DMUM7wLl.js +1 -0
  34. package/app/assets/chevron-up-Dy_mz-03.js +1 -0
  35. package/app/assets/clojure-Cm7r79vr.js +1 -0
  36. package/app/assets/codicon-ngg6Pgfi.ttf +0 -0
  37. package/app/assets/coffee-Ba7i2nA0.js +1 -0
  38. package/app/assets/connection-store-CJ_al8qv.js +1 -0
  39. package/app/assets/cpp-C7h46wYY.js +1 -0
  40. package/app/assets/csharp-BKxtCVv1.js +1 -0
  41. package/app/assets/csp-bTuwJoIa.js +1 -0
  42. package/app/assets/css-DIMkf-bt.js +3 -0
  43. package/app/assets/css.worker-Dsm2X3j_.js +89 -0
  44. package/app/assets/cssMode-BK7_e-YI.js +1 -0
  45. package/app/assets/cypher-CVaqCwHa.js +1 -0
  46. package/app/assets/dart-onAF5SnQ.js +1 -0
  47. package/app/assets/database-BIhkgl2O.js +1 -0
  48. package/app/assets/dbms-Cq4imB7l.js +353 -0
  49. package/app/assets/de-B_f2X0cM.js +2 -0
  50. package/app/assets/dialog-BtwqThq6.js +1 -0
  51. package/app/assets/dist-BV6Hwye5.js +41 -0
  52. package/app/assets/dist-C2pydad-.js +1 -0
  53. package/app/assets/dist-C3MLFgG1.js +1 -0
  54. package/app/assets/dist-Roudme38.js +3 -0
  55. package/app/assets/dockerfile-DZFCIeNp.js +1 -0
  56. package/app/assets/ecl-D05T4iGw.js +1 -0
  57. package/app/assets/editor-Br_kD0ds.css +1 -0
  58. package/app/assets/editor.api2-JBjomUWM.js +872 -0
  59. package/app/assets/editor.worker-BboMRUer.js +26 -0
  60. package/app/assets/elixir-6RTg0lbw.js +1 -0
  61. package/app/assets/engine-DBTzC3si.js +1 -0
  62. package/app/assets/file-name-CnYBZPf9.js +1 -0
  63. package/app/assets/flow9-C5_-GSwl.js +1 -0
  64. package/app/assets/fr-DigYoMZT.js +2 -0
  65. package/app/assets/freemarker2-Ca8y5qEc.js +3 -0
  66. package/app/assets/fsharp-C8Ef5oNN.js +1 -0
  67. package/app/assets/go-C-y9NEjX.js +1 -0
  68. package/app/assets/graphql-fmXr3nnJ.js +1 -0
  69. package/app/assets/handlebars-CMoDOM1Y.js +1 -0
  70. package/app/assets/hcl-CpzslTdj.js +1 -0
  71. package/app/assets/html-BOS8Hfbf.js +1 -0
  72. package/app/assets/html.worker-fD12ABR7.js +502 -0
  73. package/app/assets/htmlMode-CRjI81Qd.js +1 -0
  74. package/app/assets/i18n-C7R6hKCp.js +2 -0
  75. package/app/assets/ini-sBoK_t0W.js +1 -0
  76. package/app/assets/ja-CPMeuUuT.js +2 -0
  77. package/app/assets/java-BEtHBSE6.js +1 -0
  78. package/app/assets/javascript-2ntxzhMa.js +1 -0
  79. package/app/assets/json.worker-B2CIyALT.js +58 -0
  80. package/app/assets/jsonMode-C8yGgOEo.js +7 -0
  81. package/app/assets/julia-Bri6UV-V.js +1 -0
  82. package/app/assets/ko-IAtUoTg5.js +2 -0
  83. package/app/assets/kotlin-BOotOW0E.js +1 -0
  84. package/app/assets/less-B9JPFI3C.js +2 -0
  85. package/app/assets/lexon-CfSJPG6W.js +1 -0
  86. package/app/assets/liquid-DThFAxbS.js +1 -0
  87. package/app/assets/lspLanguageFeatures-DQW0rv3P.js +4 -0
  88. package/app/assets/lua-CsQS60Ue.js +1 -0
  89. package/app/assets/m3-D-oSqn_W.js +1 -0
  90. package/app/assets/main-DI8DxG3x.css +2 -0
  91. package/app/assets/main-NJl8KiiX.js +73 -0
  92. package/app/assets/markdown-Cimd5fb3.js +1 -0
  93. package/app/assets/mdx-CI71gDmg.js +1 -0
  94. package/app/assets/mips-CIPQ_RoX.js +1 -0
  95. package/app/assets/msdax-DauUninz.js +1 -0
  96. package/app/assets/mysql-SOo6toE5.js +1 -0
  97. package/app/assets/objective-c-FvmIjYaQ.js +1 -0
  98. package/app/assets/pascal-DrH0SRf2.js +1 -0
  99. package/app/assets/pascaligo-D-ptJ9y-.js +1 -0
  100. package/app/assets/perl-oz_6vUea.js +1 -0
  101. package/app/assets/pgsql-DTj74zXo.js +1 -0
  102. package/app/assets/php-nr791fC2.js +1 -0
  103. package/app/assets/pla-CopQ2nXW.js +1 -0
  104. package/app/assets/postiats-43DmfD33.js +1 -0
  105. package/app/assets/powerquery-D3hlyOfw.js +1 -0
  106. package/app/assets/powershell-DmHpPYUd.js +1 -0
  107. package/app/assets/preload-helper-Czpn1I53.js +1 -0
  108. package/app/assets/protobuf-C531GsRP.js +2 -0
  109. package/app/assets/pug-Z5eAx3Zn.js +1 -0
  110. package/app/assets/python-Ck7s8L2p.js +1 -0
  111. package/app/assets/qsharp-DkqhCAOL.js +1 -0
  112. package/app/assets/query-execution-CR2mkIdH.js +7 -0
  113. package/app/assets/query-store-DlzOIWw1.js +1 -0
  114. package/app/assets/r-BwWrilGY.js +1 -0
  115. package/app/assets/razor-DiN-9kkz.js +1 -0
  116. package/app/assets/redis-ClamHrr6.js +1 -0
  117. package/app/assets/redshift-DT7zqm-g.js +1 -0
  118. package/app/assets/restructuredtext-BYgofb2h.js +1 -0
  119. package/app/assets/rolldown-runtime-hePW80VL.js +1 -0
  120. package/app/assets/ruby-DezsRK8O.js +1 -0
  121. package/app/assets/rust-DdL9SqIa.js +1 -0
  122. package/app/assets/sb-CcwsVR0C.js +1 -0
  123. package/app/assets/scala-DHpiXF5c.js +1 -0
  124. package/app/assets/schema-nodes-n_QZJb21.js +1 -0
  125. package/app/assets/scheme-BeGwcela.js +1 -0
  126. package/app/assets/scss-gp-XZpBa.js +3 -0
  127. package/app/assets/select-CqYxAwnu.js +1 -0
  128. package/app/assets/settings-store-DxKFgC4Z.js +1 -0
  129. package/app/assets/shell-CC2rA5mh.js +1 -0
  130. package/app/assets/solidity-BEEn4gHE.js +1 -0
  131. package/app/assets/sophia-CRfGWb83.js +1 -0
  132. package/app/assets/sparql-D_Lu-MrJ.js +1 -0
  133. package/app/assets/sql-NEE52Syq.js +1 -0
  134. package/app/assets/sqlite3-BVKGSWc-.wasm +0 -0
  135. package/app/assets/sqlite3-opfs-async-proxy-D_xnb1D8.js +876 -0
  136. package/app/assets/sqlite3-worker1-CVKz6JuG.js +43 -0
  137. package/app/assets/st-DbInun42.js +1 -0
  138. package/app/assets/storage-worker-DEIYmjYK.js +43 -0
  139. package/app/assets/swift-Bxkupp3x.js +1 -0
  140. package/app/assets/systemverilog-Bz4Y3fRF.js +1 -0
  141. package/app/assets/table-copy-2y2RNth1.js +1 -0
  142. package/app/assets/table-definition-Bs5VSn0X.js +1 -0
  143. package/app/assets/tcl-DISqw1ZD.js +1 -0
  144. package/app/assets/ts.worker-BwG70EDZ.js +67734 -0
  145. package/app/assets/tsMode-Dzctobqp.js +11 -0
  146. package/app/assets/twig-De2hgUGE.js +1 -0
  147. package/app/assets/typescript-Ww5oHWDp.js +1 -0
  148. package/app/assets/typespec-B8J7ngcE.js +1 -0
  149. package/app/assets/ui-store-BPY9VfQP.js +1 -0
  150. package/app/assets/use-engine-F2Y_trPz.js +1 -0
  151. package/app/assets/user-store-D5dzuD-E.js +1 -0
  152. package/app/assets/vb-DV3o63ZY.js +1 -0
  153. package/app/assets/wgsl-DpFanUEy.js +298 -0
  154. package/app/assets/xml-CT99FsSc.js +1 -0
  155. package/app/assets/yaml-DA78fpmA.js +1 -0
  156. package/app/assets/zh-DCKqmOQA.js +2 -0
  157. package/app/cloudflare-d1-client/index.html +111 -0
  158. package/app/de/cloudflare-d1-client/index.html +111 -0
  159. package/app/de/index.html +123 -0
  160. package/app/de/mariadb-client/index.html +111 -0
  161. package/app/de/mysql-client/index.html +112 -0
  162. package/app/de/neon-client/index.html +111 -0
  163. package/app/de/oracle-client/index.html +111 -0
  164. package/app/de/postgresql-client/index.html +111 -0
  165. package/app/de/sql-server-client/index.html +111 -0
  166. package/app/de/sqlite-viewer/index.html +112 -0
  167. package/app/de/supabase-client/index.html +111 -0
  168. package/app/favicon.svg +1 -0
  169. package/app/fr/cloudflare-d1-client/index.html +111 -0
  170. package/app/fr/index.html +123 -0
  171. package/app/fr/mariadb-client/index.html +111 -0
  172. package/app/fr/mysql-client/index.html +112 -0
  173. package/app/fr/neon-client/index.html +111 -0
  174. package/app/fr/oracle-client/index.html +111 -0
  175. package/app/fr/postgresql-client/index.html +111 -0
  176. package/app/fr/sql-server-client/index.html +111 -0
  177. package/app/fr/sqlite-viewer/index.html +112 -0
  178. package/app/fr/supabase-client/index.html +111 -0
  179. package/app/icons.svg +24 -0
  180. package/app/index.html +123 -0
  181. package/app/ja/cloudflare-d1-client/index.html +111 -0
  182. package/app/ja/index.html +123 -0
  183. package/app/ja/mariadb-client/index.html +111 -0
  184. package/app/ja/mysql-client/index.html +112 -0
  185. package/app/ja/neon-client/index.html +111 -0
  186. package/app/ja/oracle-client/index.html +111 -0
  187. package/app/ja/postgresql-client/index.html +111 -0
  188. package/app/ja/sql-server-client/index.html +111 -0
  189. package/app/ja/sqlite-viewer/index.html +112 -0
  190. package/app/ja/supabase-client/index.html +111 -0
  191. package/app/ko/cloudflare-d1-client/index.html +111 -0
  192. package/app/ko/index.html +123 -0
  193. package/app/ko/mariadb-client/index.html +111 -0
  194. package/app/ko/mysql-client/index.html +112 -0
  195. package/app/ko/neon-client/index.html +111 -0
  196. package/app/ko/oracle-client/index.html +111 -0
  197. package/app/ko/postgresql-client/index.html +111 -0
  198. package/app/ko/sql-server-client/index.html +111 -0
  199. package/app/ko/sqlite-viewer/index.html +112 -0
  200. package/app/ko/supabase-client/index.html +111 -0
  201. package/app/landing.css +25 -0
  202. package/app/mariadb-client/index.html +111 -0
  203. package/app/mysql-client/index.html +112 -0
  204. package/app/neon-client/index.html +111 -0
  205. package/app/og-image.png +0 -0
  206. package/app/oracle-client/index.html +111 -0
  207. package/app/postgresql-client/index.html +111 -0
  208. package/app/robots.txt +3 -0
  209. package/app/sample-shop.db +0 -0
  210. package/app/sitemap.xml +603 -0
  211. package/app/sql-server-client/index.html +111 -0
  212. package/app/sqlite-viewer/index.html +112 -0
  213. package/app/supabase-client/index.html +111 -0
  214. package/app/zh/cloudflare-d1-client/index.html +111 -0
  215. package/app/zh/index.html +123 -0
  216. package/app/zh/mariadb-client/index.html +111 -0
  217. package/app/zh/mysql-client/index.html +112 -0
  218. package/app/zh/neon-client/index.html +111 -0
  219. package/app/zh/oracle-client/index.html +111 -0
  220. package/app/zh/postgresql-client/index.html +111 -0
  221. package/app/zh/sql-server-client/index.html +111 -0
  222. package/app/zh/sqlite-viewer/index.html +112 -0
  223. package/app/zh/supabase-client/index.html +111 -0
  224. package/dist/index.js +1437 -1184
  225. package/package.json +5 -3
package/dist/index.js CHANGED
@@ -6,421 +6,9 @@ import { createRequire } from "module";
6
6
  import { spawn } from "child_process";
7
7
  import { parseArgs } from "util";
8
8
 
9
- // src/server.ts
10
- import express from "express";
11
- import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
12
-
13
- // ../shared/contract.ts
14
- var PROTOCOL_VERSION = 2;
15
-
16
- // src/ssh-tunnel.ts
17
- import { createServer } from "net";
18
- import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
19
- import { createHash, createHmac, timingSafeEqual } from "crypto";
20
- import { homedir as homedir2 } from "os";
21
- import { basename as basename2, join as join2 } from "path";
22
- import { Client } from "ssh2";
23
-
24
- // src/ssh-config.ts
25
- import { readFile, readdir } from "fs/promises";
26
- import { homedir } from "os";
27
- import { dirname, isAbsolute, join, basename } from "path";
28
- var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
29
- var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
30
- var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
31
- (f) => join(homedir(), ".ssh", f)
32
- );
33
- function expandHome(path) {
34
- return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
35
- }
36
- function globToRegExp(pattern) {
37
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
38
- return new RegExp(`^${escaped}$`, "i");
39
- }
40
- function hostPatternMatches(patterns, host) {
41
- let matched = false;
42
- for (const raw of patterns) {
43
- const negated = raw.startsWith("!");
44
- const pattern = negated ? raw.slice(1) : raw;
45
- if (!globToRegExp(pattern).test(host)) continue;
46
- if (negated) return false;
47
- matched = true;
48
- }
49
- return matched;
50
- }
51
- function tokenize(line) {
52
- const tokens = [];
53
- const re = /"([^"]*)"|(\S+)/g;
54
- let m;
55
- while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
56
- return tokens;
57
- }
58
- function parseLine(line) {
59
- const trimmed = line.trim();
60
- if (!trimmed || trimmed.startsWith("#")) return void 0;
61
- const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
62
- const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
63
- return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
64
- }
65
- async function expandGlob(path) {
66
- const base = basename(path);
67
- if (!/[*?]/.test(base)) return [path];
68
- const dir = dirname(path);
69
- try {
70
- const re = globToRegExp(base);
71
- return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join(dir, name));
72
- } catch {
73
- return [];
74
- }
75
- }
76
- async function loadLines(file, seen) {
77
- if (seen.has(file)) return [];
78
- seen.add(file);
79
- try {
80
- return (await readFile(file, "utf8")).split(/\r?\n/);
81
- } catch {
82
- return [];
83
- }
84
- }
85
- async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
86
- const result = { identityFiles: [] };
87
- if (!file) return result;
88
- const seen = /* @__PURE__ */ new Set();
89
- async function walk(path, active) {
90
- let blockActive = active;
91
- for (const line of await loadLines(path, seen)) {
92
- const d = parseLine(line);
93
- if (!d) continue;
94
- switch (d.keyword) {
95
- case "host":
96
- blockActive = hostPatternMatches(d.args, host);
97
- continue;
98
- case "match":
99
- blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
100
- continue;
101
- case "include":
102
- for (const inc of d.args) {
103
- const target = expandHome(inc);
104
- const abs = isAbsolute(target) ? target : join(homedir(), ".ssh", target);
105
- for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
106
- }
107
- continue;
108
- }
109
- if (!blockActive) continue;
110
- const value = d.args[0];
111
- if (value === void 0) continue;
112
- switch (d.keyword) {
113
- case "hostname":
114
- result.hostName ??= value.replace(/%h/g, host);
115
- break;
116
- case "port":
117
- result.port ??= Number(value) || void 0;
118
- break;
119
- case "user":
120
- result.user ??= value;
121
- break;
122
- case "identityfile":
123
- for (const f of d.args) {
124
- const expanded = expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host));
125
- if (expanded.split(/[\\/]/).includes("..")) continue;
126
- result.identityFiles.push(expanded);
127
- }
128
- break;
129
- case "identityagent":
130
- result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
131
- break;
132
- case "proxyjump":
133
- result.proxyJump ??= value;
134
- break;
135
- }
136
- }
137
- }
138
- await walk(file, true);
139
- return result;
140
- }
141
-
142
- // src/ssh-tunnel.ts
143
- var TunnelError = class extends Error {
144
- constructor(message, status) {
145
- super(message);
146
- this.status = status;
147
- this.name = "TunnelError";
148
- }
149
- status;
150
- };
151
- var HostKeyError = class extends TunnelError {
152
- hostKey;
153
- constructor(host, port, type, fingerprint2) {
154
- super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
155
- this.name = "HostKeyError";
156
- this.hostKey = { host, port, type, fingerprint: fingerprint2 };
157
- }
158
- };
159
- var CONNECT_TIMEOUT_MS = 2e4;
160
- var PLANCHE_DIR = join2(homedir2(), ".planche");
161
- var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
162
- var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
163
- function parseSshConfig(value) {
164
- if (value === void 0 || value === null) return void 0;
165
- if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
166
- const body = value;
167
- const host = typeof body.host === "string" ? body.host.trim() : "";
168
- const user = typeof body.user === "string" ? body.user.trim() : "";
169
- const auth = body.auth;
170
- if (!host) throw new TunnelError("ssh.host is required", 400);
171
- if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
172
- if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
173
- const port = Math.trunc(Number(body.port)) || 22;
174
- if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
175
- if (auth !== "password" && auth !== "key" && auth !== "agent") {
176
- throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
177
- }
178
- const str = (v) => typeof v === "string" && v ? v : void 0;
179
- const config = {
180
- host,
181
- port,
182
- user,
183
- auth,
184
- password: auth === "password" ? str(body.password) ?? "" : void 0,
185
- privateKey: auth === "key" ? str(body.privateKey) : void 0,
186
- passphrase: auth === "key" ? str(body.passphrase) : void 0,
187
- acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
188
- };
189
- return config;
190
- }
191
- function keyType(blob) {
192
- if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
193
- const len = blob.readUInt32BE(0);
194
- if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
195
- const type = blob.subarray(4, 4 + len).toString("ascii");
196
- if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
197
- return type;
198
- }
199
- function knownHostsEntryMatches(field, patterns) {
200
- if (field.startsWith("|1|")) {
201
- const [, , salt, hash] = field.split("|");
202
- if (!salt || !hash) return false;
203
- const key = Buffer.from(salt, "base64");
204
- const want = Buffer.from(hash, "base64");
205
- return patterns.some((p) => {
206
- const got = createHmac("sha1", key).update(p).digest();
207
- return got.length === want.length && timingSafeEqual(got, want);
208
- });
209
- }
210
- const entries = field.toLowerCase().split(",");
211
- return patterns.some((p) => hostPatternMatches(entries, p));
212
- }
213
- function hostPatterns(host, port) {
214
- const h = host.toLowerCase();
215
- return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
216
- }
217
- async function readKnownHosts(file) {
218
- try {
219
- return (await readFile2(file, "utf8")).split(/\r?\n/);
220
- } catch {
221
- return [];
222
- }
223
- }
224
- async function lookupHostKey(host, port, blob) {
225
- const patterns = hostPatterns(host, port);
226
- const type = keyType(blob);
227
- const encoded = blob.toString("base64");
228
- let sawHost = false;
229
- const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
230
- for (const raw of lines) {
231
- const line = raw.trim();
232
- if (!line || line.startsWith("#")) continue;
233
- const fields = line.split(/\s+/);
234
- const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
235
- const [hosts, lineType, lineKey] = fields;
236
- if (!hosts || !lineType || !lineKey) continue;
237
- if (!knownHostsEntryMatches(hosts, patterns)) continue;
238
- if (marker === "@revoked") {
239
- if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
240
- continue;
241
- }
242
- if (marker === "@cert-authority") continue;
243
- sawHost = true;
244
- if (lineType === type && lineKey === encoded) return "match";
245
- }
246
- return sawHost ? "mismatch" : "unknown";
247
- }
248
- async function rememberHostKey(host, port, blob) {
249
- await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
250
- const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
251
- await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
252
- `, { mode: 384 });
253
- }
254
- function fingerprint(blob) {
255
- return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
256
- }
257
- async function resolveConfig(ssh) {
258
- const cfg = await resolveSshHost(ssh.host);
259
- if (cfg.proxyJump) {
260
- throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
261
- }
262
- if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
263
- throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
264
- }
265
- const resolved = {
266
- ...ssh,
267
- host: cfg.hostName ?? ssh.host,
268
- port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
269
- user: ssh.user || cfg.user || ""
270
- };
271
- if (!resolved.user) {
272
- throw new TunnelError(
273
- SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
274
- 400
275
- );
276
- }
277
- if (resolved.auth === "key" && !resolved.privateKey) {
278
- if (SSH_CONFIG_DISABLED) {
279
- throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
280
- }
281
- const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
282
- for (const file of candidates) {
283
- try {
284
- resolved.privateKey = await readFile2(file, "utf8");
285
- console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
286
- break;
287
- } catch {
288
- }
289
- }
290
- if (!resolved.privateKey) {
291
- throw new TunnelError(
292
- `No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename2(f)).join(", ")} in ~/.ssh)`,
293
- 400
294
- );
295
- }
296
- }
297
- return { ...resolved, identityAgent: cfg.identityAgent };
298
- }
299
- function toConnectConfig(ssh) {
300
- const base = {
301
- host: ssh.host,
302
- port: ssh.port,
303
- username: ssh.user,
304
- readyTimeout: CONNECT_TIMEOUT_MS,
305
- keepaliveInterval: 3e4
306
- };
307
- switch (ssh.auth) {
308
- case "password":
309
- return { ...base, password: ssh.password ?? "", tryKeyboard: true };
310
- case "key":
311
- return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
312
- case "agent": {
313
- const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
314
- if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
315
- return { ...base, agent };
316
- }
317
- }
318
- }
319
- function describeSshError(err, ssh) {
320
- const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
321
- if (err instanceof TunnelError) return err;
322
- switch (err.level) {
323
- case "client-authentication":
324
- return new TunnelError(`SSH authentication failed for ${target}`, 401);
325
- case "client-timeout":
326
- return new TunnelError(`SSH connection to ${target} timed out`, 400);
327
- default:
328
- return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
329
- }
330
- }
331
- async function openSshTunnel(requested, target) {
332
- const ssh = await resolveConfig(requested);
333
- const client = new Client();
334
- const connectConfig = toConnectConfig(ssh);
335
- let verifyError;
336
- connectConfig.hostVerifier = (key, verified) => {
337
- lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
338
- const fp = fingerprint(key);
339
- if (result === "mismatch") {
340
- verifyError = new TunnelError(
341
- `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.`,
342
- 400
343
- );
344
- verified(false);
345
- return;
346
- }
347
- if (result === "unknown") {
348
- if (ssh.acceptHostKey !== fp) {
349
- verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
350
- verified(false);
351
- return;
352
- }
353
- console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
354
- await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
355
- }
356
- verified(true);
357
- }).catch((err) => {
358
- verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
359
- verified(false);
360
- });
361
- };
362
- await new Promise((resolve2, reject) => {
363
- client.once("ready", resolve2);
364
- client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
365
- try {
366
- client.connect(connectConfig);
367
- } catch (err) {
368
- reject(describeSshError(err, ssh));
369
- }
370
- });
371
- client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
372
- const sockets = /* @__PURE__ */ new Set();
373
- let forwardError;
374
- const server = createServer((socket) => {
375
- sockets.add(socket);
376
- socket.once("close", () => sockets.delete(socket));
377
- socket.on("error", () => socket.destroy());
378
- client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
379
- if (err) {
380
- const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
381
- 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?";
382
- forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
383
- console.error(`[ssh] ${forwardError}`);
384
- socket.destroy();
385
- return;
386
- }
387
- stream.on("error", () => socket.destroy());
388
- socket.pipe(stream).pipe(socket);
389
- stream.once("close", () => socket.destroy());
390
- socket.once("close", () => stream.destroy());
391
- });
392
- });
393
- await new Promise((resolve2, reject) => {
394
- server.once("error", reject);
395
- server.listen(0, "127.0.0.1", () => resolve2());
396
- }).catch((err) => {
397
- client.end();
398
- throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
399
- });
400
- const { port } = server.address();
401
- let closed = false;
402
- const close = () => new Promise((resolve2) => {
403
- if (closed) return resolve2();
404
- closed = true;
405
- for (const s of sockets) s.destroy();
406
- server.close(() => {
407
- client.end();
408
- resolve2();
409
- });
410
- });
411
- client.once("close", () => void close());
412
- return {
413
- host: "127.0.0.1",
414
- port,
415
- get forwardError() {
416
- return forwardError;
417
- },
418
- close
419
- };
420
- }
421
-
422
- // src/tunneling.ts
423
- import { randomUUID } from "crypto";
9
+ // src/gateway.ts
10
+ import { readFileSync } from "fs";
11
+ import { resolve as resolve2 } from "path";
424
12
 
425
13
  // src/drivers/mysql.ts
426
14
  import mysql from "mysql2/promise";
@@ -510,225 +98,592 @@ var MySqlDriver = class {
510
98
  );
511
99
  return { fields: mappedFields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
512
100
  }
513
- const header = results;
101
+ const header = results;
102
+ return {
103
+ fields: [],
104
+ records: [],
105
+ affectedRows: header.affectedRows ?? 0,
106
+ insertId: header.insertId ?? 0,
107
+ isResultQuery: false
108
+ };
109
+ }
110
+ async setReadOnly() {
111
+ await this.connection.query("SET SESSION TRANSACTION READ ONLY");
112
+ }
113
+ async close() {
114
+ await this.connection.end();
115
+ }
116
+ };
117
+ var mysqlDriverFactory = {
118
+ async open(config) {
119
+ const connection = await mysql.createConnection(toConnectionOptions(config));
120
+ connection.on("error", (err) => console.error("[mysql] Connection error:", err.message));
121
+ return new MySqlDriver(connection, config);
122
+ },
123
+ async test(config) {
124
+ const connection = await mysql.createConnection({
125
+ ...toConnectionOptions(config),
126
+ database: void 0
127
+ });
128
+ await connection.end();
129
+ },
130
+ parseConfig(body) {
131
+ const host = typeof body.host === "string" ? body.host.trim() : "";
132
+ const user = typeof body.user === "string" ? body.user : "";
133
+ if (!host || !user) {
134
+ throw new Error("host and user are required");
135
+ }
136
+ return {
137
+ driver: "mysql",
138
+ host,
139
+ port: Number(body.port) || 3306,
140
+ user,
141
+ password: typeof body.password === "string" ? body.password : "",
142
+ database: typeof body.database === "string" && body.database ? body.database : void 0,
143
+ charset: typeof body.charset === "string" && body.charset ? body.charset : void 0,
144
+ ssl: body.ssl === true,
145
+ sslVerify: body.sslVerify === true
146
+ };
147
+ }
148
+ };
149
+
150
+ // src/drivers/postgres.ts
151
+ import pg from "pg";
152
+ var TEXT_OIDS = [
153
+ 20,
154
+ // int8
155
+ 1700,
156
+ // numeric
157
+ 1082,
158
+ // date
159
+ 1114,
160
+ // timestamp
161
+ 1184,
162
+ // timestamptz
163
+ 1083,
164
+ // time
165
+ 1266,
166
+ // timetz
167
+ 1186
168
+ // interval
169
+ ];
170
+ var types = new pg.TypeOverrides();
171
+ for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
172
+ var BUILTIN_TYPES = {
173
+ 16: "bool",
174
+ 17: "bytea",
175
+ 18: "char",
176
+ 19: "name",
177
+ 20: "int8",
178
+ 21: "int2",
179
+ 23: "int4",
180
+ 25: "text",
181
+ 26: "oid",
182
+ 114: "json",
183
+ 142: "xml",
184
+ 700: "float4",
185
+ 701: "float8",
186
+ 1042: "bpchar",
187
+ 1043: "varchar",
188
+ 1082: "date",
189
+ 1083: "time",
190
+ 1114: "timestamp",
191
+ 1184: "timestamptz",
192
+ 1186: "interval",
193
+ 1266: "timetz",
194
+ 1700: "numeric",
195
+ 2950: "uuid",
196
+ 3802: "jsonb",
197
+ 1560: "bit",
198
+ 1562: "varbit",
199
+ 600: "point",
200
+ 869: "inet",
201
+ 650: "cidr",
202
+ 829: "macaddr",
203
+ 1007: "_int4",
204
+ 1009: "_text"
205
+ };
206
+ function toClientConfig(config) {
207
+ return {
208
+ host: config.host,
209
+ port: config.port,
210
+ user: config.user,
211
+ password: config.password,
212
+ database: config.database,
213
+ ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0,
214
+ types
215
+ };
216
+ }
217
+ var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
218
+ var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
219
+ var PostgresDriver = class {
220
+ constructor(client, config) {
221
+ this.client = client;
222
+ this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
223
+ }
224
+ client;
225
+ type = "postgres";
226
+ label;
227
+ /**
228
+ * Schema currently first in `search_path`, or null when unknown (after the
229
+ * client set it itself). The app's "database" for a Postgres connection is
230
+ * a schema of the bound database, switched with `SET search_path`.
231
+ */
232
+ currentSchema = null;
233
+ typeNames = { ...BUILTIN_TYPES };
234
+ /**
235
+ * Result fields name their source only by OID (`tableID` / `columnID`);
236
+ * the frontend needs schema/table/column names to decide whether a grid is
237
+ * editable and to build UPDATEs, so they are resolved here and cached.
238
+ */
239
+ relations = /* @__PURE__ */ new Map();
240
+ async loadTypeNames() {
241
+ try {
242
+ const res = await this.client.query(
243
+ "SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
244
+ );
245
+ for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
246
+ } catch {
247
+ }
248
+ }
249
+ async resolveRelations(tableIds) {
250
+ const missing = [...new Set(tableIds)].filter((id) => id > 0 && !this.relations.has(id));
251
+ if (missing.length === 0) return;
252
+ try {
253
+ const res = await this.client.query(
254
+ `SELECT c.oid, n.nspname, c.relname, a.attnum, a.attname
255
+ FROM pg_class c
256
+ JOIN pg_namespace n ON n.oid = c.relnamespace
257
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
258
+ WHERE c.oid = ANY($1::oid[])`,
259
+ [missing]
260
+ );
261
+ for (const row of res.rows) {
262
+ const oid = Number(row.oid);
263
+ let rel = this.relations.get(oid);
264
+ if (!rel) {
265
+ rel = { schema: row.nspname, table: row.relname, columns: /* @__PURE__ */ new Map() };
266
+ this.relations.set(oid, rel);
267
+ }
268
+ rel.columns.set(Number(row.attnum), row.attname);
269
+ }
270
+ } catch {
271
+ }
272
+ }
273
+ async query(sql2, database) {
274
+ if (database && database !== this.currentSchema) {
275
+ await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
276
+ this.currentSchema = database;
277
+ }
278
+ const result = await this.client.query({ text: sql2, rowMode: "array" });
279
+ if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
280
+ const results = Array.isArray(result) ? result : [result];
281
+ const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
282
+ if (last.fields.length > 0) {
283
+ await this.resolveRelations(last.fields.map((f) => f.tableID));
284
+ const fields = last.fields.map((field) => {
285
+ const rel = this.relations.get(field.tableID);
286
+ return {
287
+ name: field.name,
288
+ orgName: rel?.columns.get(field.columnID) ?? field.name,
289
+ type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
290
+ table: rel?.table ?? "",
291
+ orgTable: rel?.table ?? "",
292
+ db: rel?.schema ?? "",
293
+ maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
294
+ };
295
+ });
296
+ return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
297
+ }
514
298
  return {
515
299
  fields: [],
516
300
  records: [],
517
- affectedRows: header.affectedRows ?? 0,
518
- insertId: header.insertId ?? 0,
301
+ affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
302
+ insertId: 0,
519
303
  isResultQuery: false
520
304
  };
521
305
  }
306
+ async setReadOnly() {
307
+ await this.client.query("SET default_transaction_read_only = on");
308
+ }
522
309
  async close() {
523
- await this.connection.end();
310
+ await this.client.end();
524
311
  }
525
312
  };
526
- var mysqlDriverFactory = {
313
+ var postgresDriverFactory = {
527
314
  async open(config) {
528
- const connection = await mysql.createConnection(toConnectionOptions(config));
529
- connection.on("error", (err) => console.error("[mysql] Connection error:", err.message));
530
- return new MySqlDriver(connection, config);
315
+ const client = new pg.Client(toClientConfig(config));
316
+ await client.connect();
317
+ client.on("error", (err) => console.error("[postgres] Connection error:", err.message));
318
+ const driver = new PostgresDriver(client, config);
319
+ await driver.loadTypeNames();
320
+ return driver;
531
321
  },
532
322
  async test(config) {
533
- const connection = await mysql.createConnection({
534
- ...toConnectionOptions(config),
535
- database: void 0
323
+ const client = new pg.Client(toClientConfig(config));
324
+ await client.connect();
325
+ await client.end();
326
+ },
327
+ parseConfig(body) {
328
+ const host = typeof body.host === "string" ? body.host.trim() : "";
329
+ const user = typeof body.user === "string" ? body.user : "";
330
+ if (!host || !user) {
331
+ throw new Error("host and user are required");
332
+ }
333
+ return {
334
+ driver: "postgres",
335
+ host,
336
+ port: Number(body.port) || 5432,
337
+ user,
338
+ password: typeof body.password === "string" ? body.password : "",
339
+ database: typeof body.database === "string" && body.database ? body.database : "postgres",
340
+ ssl: body.ssl === true,
341
+ sslVerify: body.sslVerify === true
342
+ };
343
+ }
344
+ };
345
+
346
+ // src/drivers/sqlite.ts
347
+ import Database from "better-sqlite3";
348
+ import { existsSync, realpathSync } from "fs";
349
+ import { homedir } from "os";
350
+ import { basename, dirname, join, relative, resolve, sep } from "path";
351
+ function normalizeDeclaredType(declared) {
352
+ if (!declared) return null;
353
+ const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
354
+ return base || null;
355
+ }
356
+ function inferTypeFromValue(value) {
357
+ if (value === null || value === void 0) return "null";
358
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
359
+ if (typeof value === "bigint") return "bigint";
360
+ if (Buffer.isBuffer(value)) return "blob";
361
+ return "text";
362
+ }
363
+ var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome(process.env.PLANCHE_SQLITE_DIR)) : null;
364
+ var DEFAULT_ROOT = homedir();
365
+ var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
366
+ var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
367
+ var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
368
+ function expandHome(path) {
369
+ return path === "~" || path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path;
370
+ }
371
+ function realish(file) {
372
+ let probe = file;
373
+ const rest = [];
374
+ for (; ; ) {
375
+ try {
376
+ return rest.length ? join(realpathSync.native(probe), ...rest) : realpathSync.native(probe);
377
+ } catch {
378
+ const parent = dirname(probe);
379
+ if (parent === probe) return file;
380
+ rest.unshift(basename(probe));
381
+ probe = parent;
382
+ }
383
+ }
384
+ }
385
+ function isInside(path, root) {
386
+ return path === root || path.startsWith(root + sep);
387
+ }
388
+ function resolveFile(file, forCreate = false) {
389
+ if (file === ":memory:") return file;
390
+ if (file.startsWith("file:")) throw new Error("SQLite URI filenames are not supported by the agent; give a plain path");
391
+ const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome(file)));
392
+ if (SQLITE_DIR) {
393
+ if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
394
+ } else {
395
+ const root = realish(DEFAULT_ROOT);
396
+ if (!isInside(resolved, root)) throw new Error("SQLite files outside your home directory need PLANCHE_SQLITE_DIR on the agent");
397
+ const parts = relative(root, resolved).split(sep);
398
+ if (parts.some((part) => part.startsWith("."))) throw new Error("SQLite files in hidden directories need PLANCHE_SQLITE_DIR on the agent");
399
+ 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`);
400
+ }
401
+ if (forCreate && !DB_EXTENSIONS.test(resolved)) {
402
+ throw new Error("A new SQLite file must end with .db, .db3, .s3db, .sl3, .sqlite or .sqlite3");
403
+ }
404
+ return resolved;
405
+ }
406
+ function openDatabase(config) {
407
+ const file = resolveFile(config.file, config.createIfMissing === true);
408
+ const isMemory = file === ":memory:";
409
+ if (!isMemory && !config.createIfMissing && !existsSync(file)) {
410
+ throw new Error(`SQLite file not found: ${config.file}`);
411
+ }
412
+ const db = new Database(file, {
413
+ readonly: config.readonly ?? false,
414
+ fileMustExist: !isMemory && !config.createIfMissing
415
+ });
416
+ db.pragma("trusted_schema = OFF");
417
+ if (config.readonly) db.pragma("query_only = ON");
418
+ return db;
419
+ }
420
+ function stripLiteralsAndComments(sql2) {
421
+ let out = "";
422
+ let i = 0;
423
+ while (i < sql2.length) {
424
+ const c = sql2[i];
425
+ if (c === "'" || c === '"' || c === "`") {
426
+ let j = i + 1;
427
+ while (j < sql2.length) {
428
+ if (sql2[j] === c) {
429
+ if (sql2[j + 1] === c) j += 2;
430
+ else {
431
+ j++;
432
+ break;
433
+ }
434
+ } else j++;
435
+ }
436
+ out += c + c;
437
+ i = j;
438
+ continue;
439
+ }
440
+ if (c === "[") {
441
+ const j = sql2.indexOf("]", i);
442
+ i = j < 0 ? sql2.length : j + 1;
443
+ out += "[]";
444
+ continue;
445
+ }
446
+ if (c === "-" && sql2[i + 1] === "-") {
447
+ const j = sql2.indexOf("\n", i);
448
+ i = j < 0 ? sql2.length : j;
449
+ out += " ";
450
+ continue;
451
+ }
452
+ if (c === "/" && sql2[i + 1] === "*") {
453
+ const j = sql2.indexOf("*/", i + 2);
454
+ i = j < 0 ? sql2.length : j + 2;
455
+ out += " ";
456
+ continue;
457
+ }
458
+ out += c;
459
+ i++;
460
+ }
461
+ return out;
462
+ }
463
+ function assertNoFileStatements(sql2) {
464
+ const text = stripLiteralsAndComments(sql2);
465
+ if (!ALLOW_ATTACH && /\bATTACH\b/i.test(text)) {
466
+ throw new Error("ATTACH DATABASE is disabled on the agent (set PLANCHE_SQLITE_ATTACH=1 to allow it)");
467
+ }
468
+ if (/\bVACUUM\b[^;]*\bINTO\b/i.test(text)) {
469
+ throw new Error("VACUUM INTO is not available through the agent");
470
+ }
471
+ if (/\bPRAGMA\s+temp_store_directory\b/i.test(text)) {
472
+ throw new Error("PRAGMA temp_store_directory is not available through the agent");
473
+ }
474
+ }
475
+ var SqliteDriver = class {
476
+ constructor(db, config) {
477
+ this.db = db;
478
+ this.label = config.file;
479
+ }
480
+ db;
481
+ type = "sqlite";
482
+ label;
483
+ async query(sql2) {
484
+ assertNoFileStatements(sql2);
485
+ let statement;
486
+ try {
487
+ statement = this.db.prepare(sql2);
488
+ } catch (err) {
489
+ if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
490
+ this.db.exec(sql2);
491
+ return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
492
+ }
493
+ throw err;
494
+ }
495
+ if (!statement.reader) {
496
+ const info = statement.run();
497
+ return {
498
+ fields: [],
499
+ records: [],
500
+ affectedRows: info.changes,
501
+ insertId: Number(info.lastInsertRowid),
502
+ isResultQuery: false
503
+ };
504
+ }
505
+ const columns = statement.columns();
506
+ const records = statement.raw(true).all();
507
+ const fields = columns.map((col, index) => {
508
+ const declared = normalizeDeclaredType(col.type);
509
+ const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
510
+ return {
511
+ name: col.name,
512
+ orgName: col.column ?? col.name,
513
+ type,
514
+ table: col.table ?? "",
515
+ orgTable: col.table ?? "",
516
+ db: col.database ?? "",
517
+ maxLength: 0
518
+ };
536
519
  });
537
- await connection.end();
520
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
521
+ }
522
+ async setReadOnly() {
523
+ this.db.pragma("query_only = 1");
524
+ }
525
+ async close() {
526
+ if (this.db.inTransaction) {
527
+ try {
528
+ this.db.exec("ROLLBACK");
529
+ } catch {
530
+ }
531
+ }
532
+ this.db.close();
533
+ }
534
+ };
535
+ function firstNonNull(records, index) {
536
+ for (const row of records) {
537
+ const value = row[index];
538
+ if (value !== null && value !== void 0) return value;
539
+ }
540
+ return null;
541
+ }
542
+ var sqliteDriverFactory = {
543
+ async open(config) {
544
+ return new SqliteDriver(openDatabase(config), config);
545
+ },
546
+ async test(config) {
547
+ const db = openDatabase(config);
548
+ try {
549
+ db.prepare("SELECT 1").get();
550
+ } finally {
551
+ db.close();
552
+ }
538
553
  },
539
554
  parseConfig(body) {
540
- const host = typeof body.host === "string" ? body.host.trim() : "";
541
- const user = typeof body.user === "string" ? body.user : "";
542
- if (!host || !user) {
543
- throw new Error("host and user are required");
555
+ const file = typeof body.file === "string" ? body.file.trim() : "";
556
+ if (!file) {
557
+ throw new Error("file is required");
544
558
  }
545
559
  return {
546
- driver: "mysql",
547
- host,
548
- port: Number(body.port) || 3306,
549
- user,
550
- password: typeof body.password === "string" ? body.password : "",
551
- database: typeof body.database === "string" && body.database ? body.database : void 0,
552
- charset: typeof body.charset === "string" && body.charset ? body.charset : void 0,
553
- ssl: body.ssl === true,
554
- sslVerify: body.sslVerify === true
560
+ driver: "sqlite",
561
+ file,
562
+ createIfMissing: body.createIfMissing === true,
563
+ readonly: body.readonly === true
555
564
  };
556
565
  }
557
566
  };
558
567
 
559
- // src/drivers/postgres.ts
560
- import pg from "pg";
561
- var TEXT_OIDS = [
562
- 20,
563
- // int8
564
- 1700,
565
- // numeric
566
- 1082,
567
- // date
568
- 1114,
569
- // timestamp
570
- 1184,
571
- // timestamptz
572
- 1083,
573
- // time
574
- 1266,
575
- // timetz
576
- 1186
577
- // interval
578
- ];
579
- var types = new pg.TypeOverrides();
580
- for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
581
- var BUILTIN_TYPES = {
582
- 16: "bool",
583
- 17: "bytea",
584
- 18: "char",
585
- 19: "name",
586
- 20: "int8",
587
- 21: "int2",
588
- 23: "int4",
589
- 25: "text",
590
- 26: "oid",
591
- 114: "json",
592
- 142: "xml",
593
- 700: "float4",
594
- 701: "float8",
595
- 1042: "bpchar",
596
- 1043: "varchar",
597
- 1082: "date",
598
- 1083: "time",
599
- 1114: "timestamp",
600
- 1184: "timestamptz",
601
- 1186: "interval",
602
- 1266: "timetz",
603
- 1700: "numeric",
604
- 2950: "uuid",
605
- 3802: "jsonb",
606
- 1560: "bit",
607
- 1562: "varbit",
608
- 600: "point",
609
- 869: "inet",
610
- 650: "cidr",
611
- 829: "macaddr",
612
- 1007: "_int4",
613
- 1009: "_text"
614
- };
615
- function toClientConfig(config) {
568
+ // src/drivers/mssql.ts
569
+ import sql from "mssql";
570
+ function toPoolConfig(config) {
616
571
  return {
617
- host: config.host,
572
+ server: config.host,
618
573
  port: config.port,
619
574
  user: config.user,
620
575
  password: config.password,
621
576
  database: config.database,
622
- ssl: config.ssl ? { rejectUnauthorized: config.sslVerify === true } : void 0,
623
- types
577
+ // One physical connection: manual transactions and USE need session affinity.
578
+ pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
579
+ options: {
580
+ encrypt: config.ssl === true,
581
+ trustServerCertificate: config.sslVerify !== true,
582
+ // Keep date/time values as sent by the server rather than shifting to UTC.
583
+ useUTC: false,
584
+ enableArithAbort: true
585
+ },
586
+ requestTimeout: 0
624
587
  };
625
588
  }
626
- var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
627
- var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
628
- var PostgresDriver = class {
629
- constructor(client, config) {
630
- this.client = client;
589
+ var pad = (n, width = 2) => String(n).padStart(width, "0");
590
+ function formatDate(d) {
591
+ const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
592
+ const ms = d.getMilliseconds();
593
+ return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? "." + pad(ms, 3) : ""}`;
594
+ }
595
+ function normalizeValue(value) {
596
+ if (value instanceof Date) return formatDate(value);
597
+ if (Buffer.isBuffer(value)) return value.toString("hex");
598
+ return value;
599
+ }
600
+ var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
601
+ var MssqlDriver = class {
602
+ constructor(pool, config) {
603
+ this.pool = pool;
631
604
  this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
632
605
  }
633
- client;
634
- type = "postgres";
606
+ pool;
607
+ type = "mssql";
635
608
  label;
609
+ /** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
610
+ sources = /* @__PURE__ */ new Map();
636
611
  /**
637
- * Schema currently first in `search_path`, or null when unknown (after the
638
- * client set it itself). The app's "database" for a Postgres connection is
639
- * a schema of the bound database, switched with `SET search_path`.
640
- */
641
- currentSchema = null;
642
- typeNames = { ...BUILTIN_TYPES };
643
- /**
644
- * Result fields name their source only by OID (`tableID` / `columnID`);
645
- * the frontend needs schema/table/column names to decide whether a grid is
646
- * editable and to build UPDATEs, so they are resolved here and cached.
612
+ * The `mssql` driver does not report which table a result column comes
613
+ * from; ask the server to describe the statement instead so the grid can
614
+ * decide whether it is editable and address rows by primary key.
647
615
  */
648
- relations = /* @__PURE__ */ new Map();
649
- async loadTypeNames() {
650
- try {
651
- const res = await this.client.query(
652
- "SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
653
- );
654
- for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
655
- } catch {
656
- }
657
- }
658
- async resolveRelations(tableIds) {
659
- const missing = [...new Set(tableIds)].filter((id) => id > 0 && !this.relations.has(id));
660
- if (missing.length === 0) return;
616
+ async describe(text) {
617
+ const cached = this.sources.get(text);
618
+ if (cached !== void 0) return cached;
619
+ let result = null;
661
620
  try {
662
- const res = await this.client.query(
663
- `SELECT c.oid, n.nspname, c.relname, a.attnum, a.attname
664
- FROM pg_class c
665
- JOIN pg_namespace n ON n.oid = c.relnamespace
666
- JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
667
- WHERE c.oid = ANY($1::oid[])`,
668
- [missing]
621
+ const req = this.pool.request();
622
+ req.input("sql", sql.NVarChar(sql.MAX), text);
623
+ const res = await req.query(
624
+ "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"
669
625
  );
670
- for (const row of res.rows) {
671
- const oid = Number(row.oid);
672
- let rel = this.relations.get(oid);
673
- if (!rel) {
674
- rel = { schema: row.nspname, table: row.relname, columns: /* @__PURE__ */ new Map() };
675
- this.relations.set(oid, rel);
676
- }
677
- rel.columns.set(Number(row.attnum), row.attname);
678
- }
626
+ result = res.recordset.map((r) => ({
627
+ schema: r.source_schema ?? "",
628
+ table: r.source_table ?? "",
629
+ column: r.source_column ?? "",
630
+ type: (r.system_type_name ?? "").toLowerCase()
631
+ }));
679
632
  } catch {
633
+ result = null;
680
634
  }
635
+ if (this.sources.size > 200) this.sources.clear();
636
+ this.sources.set(text, result);
637
+ return result;
681
638
  }
682
- async query(sql2, database) {
683
- if (database && database !== this.currentSchema) {
684
- await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
685
- this.currentSchema = database;
686
- }
687
- const result = await this.client.query({ text: sql2, rowMode: "array" });
688
- if (SEARCH_PATH_STATEMENT.test(sql2)) this.currentSchema = null;
689
- const results = Array.isArray(result) ? result : [result];
690
- const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
691
- if (last.fields.length > 0) {
692
- await this.resolveRelations(last.fields.map((f) => f.tableID));
693
- const fields = last.fields.map((field) => {
694
- const rel = this.relations.get(field.tableID);
639
+ async query(text) {
640
+ const req = this.pool.request();
641
+ req.arrayRowMode = true;
642
+ const res = await req.query(text);
643
+ const sets = res.recordsets;
644
+ const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
645
+ const columns = last?.columns;
646
+ if (last && columns) {
647
+ const meta = Object.values(columns).sort((a, b) => a.index - b.index);
648
+ const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
649
+ const fields = meta.map((col, i) => {
650
+ const src = sources && sources.length === meta.length ? sources[i] : void 0;
651
+ const typeName = col.type?.declaration ?? "";
695
652
  return {
696
- name: field.name,
697
- orgName: rel?.columns.get(field.columnID) ?? field.name,
698
- type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
699
- table: rel?.table ?? "",
700
- orgTable: rel?.table ?? "",
701
- db: rel?.schema ?? "",
702
- maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
653
+ name: col.name,
654
+ orgName: src?.column || col.name,
655
+ type: src?.type || typeName.toLowerCase(),
656
+ table: src?.table ?? "",
657
+ orgTable: src?.table ?? "",
658
+ db: src?.schema ?? "",
659
+ maxLength: typeof col.length === "number" ? col.length : 0
703
660
  };
704
661
  });
705
- return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
662
+ const records = last.map((row) => row.map(normalizeValue));
663
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
706
664
  }
707
665
  return {
708
666
  fields: [],
709
667
  records: [],
710
- affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
668
+ affectedRows: res.rowsAffected.reduce((sum, n) => sum + n, 0),
711
669
  insertId: 0,
712
670
  isResultQuery: false
713
671
  };
714
672
  }
715
673
  async close() {
716
- await this.client.end();
674
+ await this.pool.close();
717
675
  }
718
676
  };
719
- var postgresDriverFactory = {
677
+ var mssqlDriverFactory = {
720
678
  async open(config) {
721
- const client = new pg.Client(toClientConfig(config));
722
- await client.connect();
723
- client.on("error", (err) => console.error("[postgres] Connection error:", err.message));
724
- const driver = new PostgresDriver(client, config);
725
- await driver.loadTypeNames();
726
- return driver;
679
+ const pool = new sql.ConnectionPool(toPoolConfig(config));
680
+ await pool.connect();
681
+ return new MssqlDriver(pool, config);
727
682
  },
728
683
  async test(config) {
729
- const client = new pg.Client(toClientConfig(config));
730
- await client.connect();
731
- await client.end();
684
+ const pool = new sql.ConnectionPool(toPoolConfig(config));
685
+ await pool.connect();
686
+ await pool.close();
732
687
  },
733
688
  parseConfig(body) {
734
689
  const host = typeof body.host === "string" ? body.host.trim() : "";
@@ -737,694 +692,742 @@ var postgresDriverFactory = {
737
692
  throw new Error("host and user are required");
738
693
  }
739
694
  return {
740
- driver: "postgres",
695
+ driver: "mssql",
741
696
  host,
742
- port: Number(body.port) || 5432,
697
+ port: Number(body.port) || 1433,
743
698
  user,
744
699
  password: typeof body.password === "string" ? body.password : "",
745
- database: typeof body.database === "string" && body.database ? body.database : "postgres",
700
+ database: typeof body.database === "string" && body.database ? body.database : "master",
746
701
  ssl: body.ssl === true,
747
702
  sslVerify: body.sslVerify === true
748
703
  };
749
704
  }
750
705
  };
751
706
 
752
- // src/drivers/sqlite.ts
753
- import Database from "better-sqlite3";
754
- import { existsSync, realpathSync } from "fs";
755
- import { homedir as homedir3 } from "os";
756
- import { basename as basename3, dirname as dirname2, join as join3, relative, resolve, sep } from "path";
757
- function normalizeDeclaredType(declared) {
758
- if (!declared) return null;
759
- const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
760
- return base || null;
761
- }
762
- function inferTypeFromValue(value) {
763
- if (value === null || value === void 0) return "null";
764
- if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
765
- if (typeof value === "bigint") return "bigint";
766
- if (Buffer.isBuffer(value)) return "blob";
767
- return "text";
768
- }
769
- var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome2(process.env.PLANCHE_SQLITE_DIR)) : null;
770
- var DEFAULT_ROOT = homedir3();
771
- var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
772
- var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
773
- var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
774
- function expandHome2(path) {
775
- return path === "~" || path.startsWith("~/") ? resolve(homedir3(), path.slice(2)) : path;
776
- }
777
- function realish(file) {
778
- let probe = file;
779
- const rest = [];
780
- for (; ; ) {
781
- try {
782
- return rest.length ? join3(realpathSync.native(probe), ...rest) : realpathSync.native(probe);
783
- } catch {
784
- const parent = dirname2(probe);
785
- if (parent === probe) return file;
786
- rest.unshift(basename3(probe));
787
- probe = parent;
788
- }
789
- }
790
- }
791
- function isInside(path, root) {
792
- return path === root || path.startsWith(root + sep);
793
- }
794
- function resolveFile(file, forCreate = false) {
795
- if (file === ":memory:") return file;
796
- if (file.startsWith("file:")) throw new Error("SQLite URI filenames are not supported by the agent; give a plain path");
797
- const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome2(file)));
798
- if (SQLITE_DIR) {
799
- if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
800
- } else {
801
- const root = realish(DEFAULT_ROOT);
802
- if (!isInside(resolved, root)) throw new Error("SQLite files outside your home directory need PLANCHE_SQLITE_DIR on the agent");
803
- const parts = relative(root, resolved).split(sep);
804
- if (parts.some((part) => part.startsWith("."))) throw new Error("SQLite files in hidden directories need PLANCHE_SQLITE_DIR on the agent");
805
- 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`);
806
- }
807
- if (forCreate && !DB_EXTENSIONS.test(resolved)) {
808
- throw new Error("A new SQLite file must end with .db, .db3, .s3db, .sl3, .sqlite or .sqlite3");
809
- }
810
- return resolved;
707
+ // src/drivers/oracle.ts
708
+ import oracledb from "oracledb";
709
+ oracledb.autoCommit = true;
710
+ oracledb.fetchAsString = [oracledb.CLOB];
711
+ var fetchTypeHandler = (meta) => meta.dbType === oracledb.DB_TYPE_NUMBER ? { type: oracledb.STRING } : void 0;
712
+ var pad2 = (n, width = 2) => String(n).padStart(width, "0");
713
+ function formatDate2(d) {
714
+ const ms = d.getMilliseconds();
715
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${ms ? "." + pad2(ms, 3) : ""}`;
811
716
  }
812
- function openDatabase(config) {
813
- const file = resolveFile(config.file, config.createIfMissing === true);
814
- const isMemory = file === ":memory:";
815
- if (!isMemory && !config.createIfMissing && !existsSync(file)) {
816
- throw new Error(`SQLite file not found: ${config.file}`);
817
- }
818
- const db = new Database(file, {
819
- readonly: config.readonly ?? false,
820
- fileMustExist: !isMemory && !config.createIfMissing
821
- });
822
- db.pragma("trusted_schema = OFF");
823
- if (config.readonly) db.pragma("query_only = ON");
824
- return db;
717
+ function normalizeValue2(value) {
718
+ if (value instanceof Date) return formatDate2(value);
719
+ if (Buffer.isBuffer(value)) return value.toString("hex");
720
+ return value;
825
721
  }
826
- function stripLiteralsAndComments(sql2) {
827
- let out = "";
828
- let i = 0;
829
- while (i < sql2.length) {
830
- const c = sql2[i];
831
- if (c === "'" || c === '"' || c === "`") {
832
- let j = i + 1;
833
- while (j < sql2.length) {
834
- if (sql2[j] === c) {
835
- if (sql2[j + 1] === c) j += 2;
836
- else {
837
- j++;
838
- break;
839
- }
840
- } else j++;
841
- }
842
- out += c + c;
843
- i = j;
844
- continue;
845
- }
846
- if (c === "[") {
847
- const j = sql2.indexOf("]", i);
848
- i = j < 0 ? sql2.length : j + 1;
849
- out += "[]";
850
- continue;
851
- }
852
- if (c === "-" && sql2[i + 1] === "-") {
853
- const j = sql2.indexOf("\n", i);
854
- i = j < 0 ? sql2.length : j;
855
- out += " ";
856
- continue;
857
- }
858
- if (c === "/" && sql2[i + 1] === "*") {
859
- const j = sql2.indexOf("*/", i + 2);
860
- i = j < 0 ? sql2.length : j + 2;
861
- out += " ";
862
- continue;
863
- }
864
- out += c;
865
- i++;
866
- }
867
- return out;
722
+ var SESSION_FORMATS = [
723
+ "ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'",
724
+ "ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3'",
725
+ "ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3 TZH:TZM'"
726
+ ];
727
+ var quoteIdent2 = (name) => `"${name.replaceAll('"', '""')}"`;
728
+ var PLSQL = /^\s*(BEGIN|DECLARE|CREATE\s+(OR\s+REPLACE\s+)?((NON)?EDITIONABLE\s+)?(PROCEDURE|FUNCTION|TRIGGER|PACKAGE|TYPE)\b)/i;
729
+ function normalizeStatement(sql2) {
730
+ const trimmed = sql2.trim().replace(/\n\/\s*$/, "");
731
+ if (PLSQL.test(trimmed)) return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
732
+ return trimmed.replace(/;\s*$/, "");
868
733
  }
869
- function assertNoFileStatements(sql2) {
870
- const text = stripLiteralsAndComments(sql2);
871
- if (!ALLOW_ATTACH && /\bATTACH\b/i.test(text)) {
872
- throw new Error("ATTACH DATABASE is disabled on the agent (set PLANCHE_SQLITE_ATTACH=1 to allow it)");
873
- }
874
- if (/\bVACUUM\b[^;]*\bINTO\b/i.test(text)) {
875
- throw new Error("VACUUM INTO is not available through the agent");
876
- }
877
- if (/\bPRAGMA\s+temp_store_directory\b/i.test(text)) {
878
- throw new Error("PRAGMA temp_store_directory is not available through the agent");
879
- }
734
+ var SET_SCHEMA = /^\s*ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\b/i;
735
+ var START_TX = /^\s*SET\s+TRANSACTION\b/i;
736
+ var END_TX = /^\s*(COMMIT|ROLLBACK)\b/i;
737
+ function singleSourceTable(sql2) {
738
+ const m = /\bFROM\s+("?[\w$#]+"?)(?:\s*\.\s*("?[\w$#]+"?))?([\s\S]*)$/i.exec(sql2);
739
+ if (!m) return null;
740
+ 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, "");
741
+ if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
742
+ const unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replaceAll('""', '"') : v.toUpperCase();
743
+ return m[2] ? { schema: unquote(m[1]), table: unquote(m[2]) } : { table: unquote(m[1]) };
880
744
  }
881
- var SqliteDriver = class {
882
- constructor(db, config) {
883
- this.db = db;
884
- this.label = config.file;
745
+ var OracleDriver = class {
746
+ constructor(conn, config) {
747
+ this.conn = conn;
748
+ this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
749
+ this.currentSchema = config.user.toUpperCase();
885
750
  }
886
- db;
887
- type = "sqlite";
751
+ conn;
752
+ type = "oracle";
888
753
  label;
889
- async query(sql2) {
890
- assertNoFileStatements(sql2);
891
- let statement;
754
+ currentSchema;
755
+ manualTx = false;
756
+ /** Column names per `OWNER.TABLE`, for result-source resolution. */
757
+ columns = /* @__PURE__ */ new Map();
758
+ async init() {
759
+ for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
760
+ }
761
+ async tableColumns(schema, table) {
762
+ const key = `${schema}.${table}`;
763
+ const cached = this.columns.get(key);
764
+ if (cached !== void 0) return cached;
765
+ let out = null;
892
766
  try {
893
- statement = this.db.prepare(sql2);
894
- } catch (err) {
895
- if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
896
- this.db.exec(sql2);
897
- return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
898
- }
899
- throw err;
767
+ const res = await this.conn.execute(
768
+ "SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
769
+ { o: schema, t: table },
770
+ { outFormat: oracledb.OUT_FORMAT_ARRAY }
771
+ );
772
+ out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
773
+ } catch {
774
+ out = null;
900
775
  }
901
- if (!statement.reader) {
902
- const info = statement.run();
903
- return {
904
- fields: [],
905
- records: [],
906
- affectedRows: info.changes,
907
- insertId: Number(info.lastInsertRowid),
908
- isResultQuery: false
909
- };
776
+ if (this.columns.size > 200) this.columns.clear();
777
+ this.columns.set(key, out);
778
+ return out;
779
+ }
780
+ async query(sql2, database) {
781
+ if (database && database !== this.currentSchema) {
782
+ await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
783
+ this.currentSchema = database;
910
784
  }
911
- const columns = statement.columns();
912
- const records = statement.raw(true).all();
913
- const fields = columns.map((col, index) => {
914
- const declared = normalizeDeclaredType(col.type);
915
- const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
916
- return {
917
- name: col.name,
918
- orgName: col.column ?? col.name,
919
- type,
920
- table: col.table ?? "",
921
- orgTable: col.table ?? "",
922
- db: col.database ?? "",
923
- maxLength: 0
924
- };
785
+ const text = normalizeStatement(sql2);
786
+ if (START_TX.test(text)) this.manualTx = true;
787
+ const res = await this.conn.execute(text, [], {
788
+ outFormat: oracledb.OUT_FORMAT_ARRAY,
789
+ fetchTypeHandler,
790
+ autoCommit: !this.manualTx
925
791
  });
926
- return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
792
+ if (SET_SCHEMA.test(text)) this.currentSchema = null;
793
+ if (END_TX.test(text)) this.manualTx = false;
794
+ if (res.metaData && res.metaData.length > 0) {
795
+ const source = singleSourceTable(text);
796
+ const schema = source?.schema ?? this.currentSchema ?? "";
797
+ const known = source && schema ? await this.tableColumns(schema, source.table) : null;
798
+ const fields = res.metaData.map((col) => {
799
+ const fromTable = known?.has(col.name) ?? false;
800
+ return {
801
+ name: col.name,
802
+ orgName: col.name,
803
+ type: (col.dbTypeName ?? "").toLowerCase(),
804
+ table: fromTable ? source.table : "",
805
+ orgTable: fromTable ? source.table : "",
806
+ db: fromTable ? schema : "",
807
+ maxLength: col.byteSize ?? 0
808
+ };
809
+ });
810
+ const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
811
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
812
+ }
813
+ return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
927
814
  }
928
815
  async close() {
929
- if (this.db.inTransaction) {
930
- try {
931
- this.db.exec("ROLLBACK");
932
- } catch {
933
- }
934
- }
935
- this.db.close();
816
+ await this.conn.close();
936
817
  }
937
818
  };
938
- function firstNonNull(records, index) {
939
- for (const row of records) {
940
- const value = row[index];
941
- if (value !== null && value !== void 0) return value;
942
- }
943
- return null;
819
+ function connectionAttributes(config) {
820
+ return {
821
+ user: config.user,
822
+ password: config.password,
823
+ connectString: `${config.host}:${config.port}/${config.serviceName}`
824
+ };
944
825
  }
945
- var sqliteDriverFactory = {
826
+ var oracleDriverFactory = {
946
827
  async open(config) {
947
- return new SqliteDriver(openDatabase(config), config);
828
+ const conn = await oracledb.getConnection(connectionAttributes(config));
829
+ const driver = new OracleDriver(conn, config);
830
+ await driver.init();
831
+ return driver;
948
832
  },
949
833
  async test(config) {
950
- const db = openDatabase(config);
951
- try {
952
- db.prepare("SELECT 1").get();
953
- } finally {
954
- db.close();
955
- }
834
+ const conn = await oracledb.getConnection(connectionAttributes(config));
835
+ await conn.close();
956
836
  },
957
837
  parseConfig(body) {
958
- const file = typeof body.file === "string" ? body.file.trim() : "";
959
- if (!file) {
960
- throw new Error("file is required");
838
+ const host = typeof body.host === "string" ? body.host.trim() : "";
839
+ const user = typeof body.user === "string" ? body.user : "";
840
+ if (!host || !user) {
841
+ throw new Error("host and user are required");
961
842
  }
843
+ if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
844
+ const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
845
+ if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
962
846
  return {
963
- driver: "sqlite",
964
- file,
965
- createIfMissing: body.createIfMissing === true,
966
- readonly: body.readonly === true
847
+ driver: "oracle",
848
+ host,
849
+ port: Number(body.port) || 1521,
850
+ user,
851
+ password: typeof body.password === "string" ? body.password : "",
852
+ serviceName
967
853
  };
968
854
  }
969
855
  };
970
856
 
971
- // src/drivers/mssql.ts
972
- import sql from "mssql";
973
- function toPoolConfig(config) {
974
- return {
975
- server: config.host,
976
- port: config.port,
977
- user: config.user,
978
- password: config.password,
979
- database: config.database,
980
- // One physical connection: manual transactions and USE need session affinity.
981
- pool: { min: 1, max: 1, idleTimeoutMillis: 24 * 60 * 60 * 1e3 },
982
- options: {
983
- encrypt: config.ssl === true,
984
- trustServerCertificate: config.sslVerify !== true,
985
- // Keep date/time values as sent by the server rather than shifting to UTC.
986
- useUTC: false,
987
- enableArithAbort: true
988
- },
989
- requestTimeout: 0
990
- };
857
+ // src/drivers/d1.ts
858
+ var API = "https://api.cloudflare.com/client/v4";
859
+ var D1Error = class extends Error {
860
+ constructor(message, status) {
861
+ super(message);
862
+ this.status = status;
863
+ }
864
+ status;
865
+ };
866
+ var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
867
+ var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
868
+ var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
869
+ function singleSourceTable2(sql2) {
870
+ const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
871
+ if (!m) return null;
872
+ const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
873
+ if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
874
+ const raw = m[1];
875
+ const first = raw[0];
876
+ if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
877
+ if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
878
+ if (first === "[") return raw.slice(1, -1);
879
+ return raw;
991
880
  }
992
- var pad = (n, width = 2) => String(n).padStart(width, "0");
993
- function formatDate(d) {
994
- const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
995
- const ms = d.getMilliseconds();
996
- return `${date} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${ms ? "." + pad(ms, 3) : ""}`;
881
+ function normalizeDeclaredType2(declared) {
882
+ if (typeof declared !== "string") return null;
883
+ const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
884
+ return base || null;
997
885
  }
998
- function normalizeValue(value) {
999
- if (value instanceof Date) return formatDate(value);
1000
- if (Buffer.isBuffer(value)) return value.toString("hex");
1001
- return value;
886
+ function inferTypeFromValue2(value) {
887
+ if (value === null || value === void 0) return "null";
888
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
889
+ if (Array.isArray(value)) return "blob";
890
+ return "text";
1002
891
  }
1003
- var RESULT_QUERY = /^\s*(?:WITH|SELECT|\()/i;
1004
- var MssqlDriver = class {
1005
- constructor(pool, config) {
1006
- this.pool = pool;
1007
- this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
892
+ function firstNonNull2(records, index) {
893
+ for (const row of records) {
894
+ const value = row[index];
895
+ if (value !== null && value !== void 0) return value;
1008
896
  }
1009
- pool;
1010
- type = "mssql";
897
+ return null;
898
+ }
899
+ var D1Driver = class {
900
+ type = "d1";
1011
901
  label;
1012
- /** Source columns per SQL text; `sys.dm_exec_describe_first_result_set` is not free. */
1013
- sources = /* @__PURE__ */ new Map();
1014
- /**
1015
- * The `mssql` driver does not report which table a result column comes
1016
- * from; ask the server to describe the statement instead so the grid can
1017
- * decide whether it is editable and address rows by primary key.
1018
- */
1019
- async describe(text) {
1020
- const cached = this.sources.get(text);
1021
- if (cached !== void 0) return cached;
1022
- let result = null;
902
+ url;
903
+ headers;
904
+ /** Declared column types per table, for result-source resolution. */
905
+ columns = /* @__PURE__ */ new Map();
906
+ constructor(config) {
907
+ this.label = `d1:${config.databaseId}`;
908
+ this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
909
+ this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
910
+ }
911
+ async raw(sql2) {
912
+ let res;
1023
913
  try {
1024
- const req = this.pool.request();
1025
- req.input("sql", sql.NVarChar(sql.MAX), text);
1026
- const res = await req.query(
1027
- "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"
1028
- );
1029
- result = res.recordset.map((r) => ({
1030
- schema: r.source_schema ?? "",
1031
- table: r.source_table ?? "",
1032
- column: r.source_column ?? "",
1033
- type: (r.system_type_name ?? "").toLowerCase()
1034
- }));
914
+ res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }), signal: AbortSignal.timeout(6e4) });
915
+ } catch (err) {
916
+ throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
917
+ }
918
+ let body = null;
919
+ try {
920
+ body = await res.json();
1035
921
  } catch {
1036
- result = null;
1037
922
  }
1038
- if (this.sources.size > 200) this.sources.clear();
1039
- this.sources.set(text, result);
923
+ if (!res.ok || !body?.success) {
924
+ const message = (body?.errors?.map((e) => String(e.message)).join("; ") || `Cloudflare API responded ${res.status}`).slice(0, 500);
925
+ throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
926
+ }
927
+ const result = body.result?.[0];
928
+ if (!result) throw new D1Error("Cloudflare API returned no result");
1040
929
  return result;
1041
930
  }
1042
- async query(text) {
1043
- const req = this.pool.request();
1044
- req.arrayRowMode = true;
1045
- const res = await req.query(text);
1046
- const sets = res.recordsets;
1047
- const last = sets.length > 0 ? sets[sets.length - 1] : void 0;
1048
- const columns = last?.columns;
1049
- if (last && columns) {
1050
- const meta = Object.values(columns).sort((a, b) => a.index - b.index);
1051
- const sources = RESULT_QUERY.test(text) ? await this.describe(text) : null;
1052
- const fields = meta.map((col, i) => {
1053
- const src = sources && sources.length === meta.length ? sources[i] : void 0;
1054
- const typeName = col.type?.declaration ?? "";
1055
- return {
1056
- name: col.name,
1057
- orgName: src?.column || col.name,
1058
- type: src?.type || typeName.toLowerCase(),
1059
- table: src?.table ?? "",
1060
- orgTable: src?.table ?? "",
1061
- db: src?.schema ?? "",
1062
- maxLength: typeof col.length === "number" ? col.length : 0
1063
- };
1064
- });
1065
- const records = last.map((row) => row.map(normalizeValue));
1066
- return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
931
+ async tableColumns(table) {
932
+ const cached = this.columns.get(table);
933
+ if (cached !== void 0) return cached;
934
+ let out = null;
935
+ try {
936
+ const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
937
+ const cols = info.results?.columns ?? [];
938
+ const name = cols.indexOf("name");
939
+ const type = cols.indexOf("type");
940
+ const rows = info.results?.rows ?? [];
941
+ if (rows.length > 0 && name >= 0) {
942
+ out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
943
+ }
944
+ } catch {
945
+ out = null;
946
+ }
947
+ if (this.columns.size > 200) this.columns.clear();
948
+ this.columns.set(table, out);
949
+ return out;
950
+ }
951
+ async query(sql2) {
952
+ if (DATABASE_LIST.test(sql2)) {
953
+ return {
954
+ fields: [
955
+ { name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
956
+ { name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
957
+ { name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
958
+ ],
959
+ records: [[0, "main", ""]],
960
+ affectedRows: 0,
961
+ insertId: 0,
962
+ isResultQuery: true
963
+ };
964
+ }
965
+ const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
966
+ if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
967
+ throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
968
+ }
969
+ if (this.isDdl(text)) this.columns.clear();
970
+ const result = await this.raw(text);
971
+ const columns = result.results?.columns ?? [];
972
+ const records = result.results?.rows ?? [];
973
+ if (columns.length === 0 && !RESULT_KIND.test(text)) {
974
+ return {
975
+ fields: [],
976
+ records: [],
977
+ affectedRows: result.meta?.changes ?? 0,
978
+ insertId: result.meta?.last_row_id ?? 0,
979
+ isResultQuery: false
980
+ };
1067
981
  }
1068
- return {
1069
- fields: [],
1070
- records: [],
1071
- affectedRows: res.rowsAffected.reduce((sum, n) => sum + n, 0),
1072
- insertId: 0,
1073
- isResultQuery: false
1074
- };
982
+ const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
983
+ const known = source ? await this.tableColumns(source) : null;
984
+ const fields = columns.map((name, index) => {
985
+ const fromTable = known?.has(name) ?? false;
986
+ const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
987
+ return {
988
+ name,
989
+ orgName: name,
990
+ type,
991
+ table: fromTable && source ? source : "",
992
+ orgTable: fromTable && source ? source : "",
993
+ db: fromTable ? "main" : "",
994
+ maxLength: 0
995
+ };
996
+ });
997
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
998
+ }
999
+ isDdl(sql2) {
1000
+ return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
1075
1001
  }
1076
1002
  async close() {
1077
- await this.pool.close();
1003
+ this.columns.clear();
1078
1004
  }
1079
1005
  };
1080
- var mssqlDriverFactory = {
1006
+ var d1DriverFactory = {
1081
1007
  async open(config) {
1082
- const pool = new sql.ConnectionPool(toPoolConfig(config));
1083
- await pool.connect();
1084
- return new MssqlDriver(pool, config);
1008
+ const driver = new D1Driver(config);
1009
+ await driver.raw("SELECT 1");
1010
+ return driver;
1085
1011
  },
1086
1012
  async test(config) {
1087
- const pool = new sql.ConnectionPool(toPoolConfig(config));
1088
- await pool.connect();
1089
- await pool.close();
1013
+ await new D1Driver(config).raw("SELECT 1");
1090
1014
  },
1091
1015
  parseConfig(body) {
1092
- const host = typeof body.host === "string" ? body.host.trim() : "";
1093
- const user = typeof body.user === "string" ? body.user : "";
1094
- if (!host || !user) {
1095
- throw new Error("host and user are required");
1016
+ const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
1017
+ const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
1018
+ const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
1019
+ if (!accountId || !databaseId || !apiToken) {
1020
+ throw new Error("accountId, databaseId and apiToken are required");
1096
1021
  }
1097
- return {
1098
- driver: "mssql",
1099
- host,
1100
- port: Number(body.port) || 1433,
1101
- user,
1102
- password: typeof body.password === "string" ? body.password : "",
1103
- database: typeof body.database === "string" && body.database ? body.database : "master",
1104
- ssl: body.ssl === true,
1105
- sslVerify: body.sslVerify === true
1106
- };
1022
+ return { driver: "d1", accountId, databaseId, apiToken };
1107
1023
  }
1108
1024
  };
1109
1025
 
1110
- // src/drivers/oracle.ts
1111
- import oracledb from "oracledb";
1112
- oracledb.autoCommit = true;
1113
- oracledb.fetchAsString = [oracledb.CLOB];
1114
- var fetchTypeHandler = (meta) => meta.dbType === oracledb.DB_TYPE_NUMBER ? { type: oracledb.STRING } : void 0;
1115
- var pad2 = (n, width = 2) => String(n).padStart(width, "0");
1116
- function formatDate2(d) {
1117
- const ms = d.getMilliseconds();
1118
- return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}${ms ? "." + pad2(ms, 3) : ""}`;
1026
+ // src/ssh-tunnel.ts
1027
+ import { createServer } from "net";
1028
+ import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
1029
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
1030
+ import { homedir as homedir3 } from "os";
1031
+ import { basename as basename3, join as join3 } from "path";
1032
+ import { Client } from "ssh2";
1033
+
1034
+ // src/ssh-config.ts
1035
+ import { readFile, readdir } from "fs/promises";
1036
+ import { homedir as homedir2 } from "os";
1037
+ import { dirname as dirname2, isAbsolute, join as join2, basename as basename2 } from "path";
1038
+ var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
1039
+ var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join2(homedir2(), ".ssh", "config");
1040
+ var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
1041
+ (f) => join2(homedir2(), ".ssh", f)
1042
+ );
1043
+ function expandHome2(path) {
1044
+ return path === "~" || path.startsWith("~/") ? join2(homedir2(), path.slice(1)) : path;
1119
1045
  }
1120
- function normalizeValue2(value) {
1121
- if (value instanceof Date) return formatDate2(value);
1122
- if (Buffer.isBuffer(value)) return value.toString("hex");
1123
- return value;
1046
+ function globToRegExp(pattern) {
1047
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1048
+ return new RegExp(`^${escaped}$`, "i");
1124
1049
  }
1125
- var SESSION_FORMATS = [
1126
- "ALTER SESSION SET NLS_DATE_FORMAT = 'YYYY-MM-DD HH24:MI:SS'",
1127
- "ALTER SESSION SET NLS_TIMESTAMP_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3'",
1128
- "ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT = 'YYYY-MM-DD HH24:MI:SS.FF3 TZH:TZM'"
1129
- ];
1130
- var quoteIdent2 = (name) => `"${name.replaceAll('"', '""')}"`;
1131
- var PLSQL = /^\s*(BEGIN|DECLARE|CREATE\s+(OR\s+REPLACE\s+)?((NON)?EDITIONABLE\s+)?(PROCEDURE|FUNCTION|TRIGGER|PACKAGE|TYPE)\b)/i;
1132
- function normalizeStatement(sql2) {
1133
- const trimmed = sql2.trim().replace(/\n\/\s*$/, "");
1134
- if (PLSQL.test(trimmed)) return trimmed.endsWith(";") ? trimmed : `${trimmed};`;
1135
- return trimmed.replace(/;\s*$/, "");
1050
+ function hostPatternMatches(patterns, host) {
1051
+ let matched = false;
1052
+ for (const raw of patterns) {
1053
+ const negated = raw.startsWith("!");
1054
+ const pattern = negated ? raw.slice(1) : raw;
1055
+ if (!globToRegExp(pattern).test(host)) continue;
1056
+ if (negated) return false;
1057
+ matched = true;
1058
+ }
1059
+ return matched;
1136
1060
  }
1137
- var SET_SCHEMA = /^\s*ALTER\s+SESSION\s+SET\s+CURRENT_SCHEMA\b/i;
1138
- var START_TX = /^\s*SET\s+TRANSACTION\b/i;
1139
- var END_TX = /^\s*(COMMIT|ROLLBACK)\b/i;
1140
- function singleSourceTable(sql2) {
1141
- const m = /\bFROM\s+("?[\w$#]+"?)(?:\s*\.\s*("?[\w$#]+"?))?([\s\S]*)$/i.exec(sql2);
1142
- if (!m) return null;
1143
- 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, "");
1144
- if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
1145
- const unquote = (v) => v.startsWith('"') ? v.slice(1, -1).replaceAll('""', '"') : v.toUpperCase();
1146
- return m[2] ? { schema: unquote(m[1]), table: unquote(m[2]) } : { table: unquote(m[1]) };
1061
+ function tokenize(line) {
1062
+ const tokens = [];
1063
+ const re = /"([^"]*)"|(\S+)/g;
1064
+ let m;
1065
+ while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
1066
+ return tokens;
1147
1067
  }
1148
- var OracleDriver = class {
1149
- constructor(conn, config) {
1150
- this.conn = conn;
1151
- this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
1152
- this.currentSchema = config.user.toUpperCase();
1153
- }
1154
- conn;
1155
- type = "oracle";
1156
- label;
1157
- currentSchema;
1158
- manualTx = false;
1159
- /** Column names per `OWNER.TABLE`, for result-source resolution. */
1160
- columns = /* @__PURE__ */ new Map();
1161
- async init() {
1162
- for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
1068
+ function parseLine(line) {
1069
+ const trimmed = line.trim();
1070
+ if (!trimmed || trimmed.startsWith("#")) return void 0;
1071
+ const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
1072
+ const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
1073
+ return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
1074
+ }
1075
+ async function expandGlob(path) {
1076
+ const base = basename2(path);
1077
+ if (!/[*?]/.test(base)) return [path];
1078
+ const dir = dirname2(path);
1079
+ try {
1080
+ const re = globToRegExp(base);
1081
+ return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join2(dir, name));
1082
+ } catch {
1083
+ return [];
1163
1084
  }
1164
- async tableColumns(schema, table) {
1165
- const key = `${schema}.${table}`;
1166
- const cached = this.columns.get(key);
1167
- if (cached !== void 0) return cached;
1168
- let out = null;
1169
- try {
1170
- const res = await this.conn.execute(
1171
- "SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
1172
- { o: schema, t: table },
1173
- { outFormat: oracledb.OUT_FORMAT_ARRAY }
1174
- );
1175
- out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
1176
- } catch {
1177
- out = null;
1178
- }
1179
- if (this.columns.size > 200) this.columns.clear();
1180
- this.columns.set(key, out);
1181
- return out;
1085
+ }
1086
+ async function loadLines(file, seen) {
1087
+ if (seen.has(file)) return [];
1088
+ seen.add(file);
1089
+ try {
1090
+ return (await readFile(file, "utf8")).split(/\r?\n/);
1091
+ } catch {
1092
+ return [];
1182
1093
  }
1183
- async query(sql2, database) {
1184
- if (database && database !== this.currentSchema) {
1185
- await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
1186
- this.currentSchema = database;
1187
- }
1188
- const text = normalizeStatement(sql2);
1189
- if (START_TX.test(text)) this.manualTx = true;
1190
- const res = await this.conn.execute(text, [], {
1191
- outFormat: oracledb.OUT_FORMAT_ARRAY,
1192
- fetchTypeHandler,
1193
- autoCommit: !this.manualTx
1194
- });
1195
- if (SET_SCHEMA.test(text)) this.currentSchema = null;
1196
- if (END_TX.test(text)) this.manualTx = false;
1197
- if (res.metaData && res.metaData.length > 0) {
1198
- const source = singleSourceTable(text);
1199
- const schema = source?.schema ?? this.currentSchema ?? "";
1200
- const known = source && schema ? await this.tableColumns(schema, source.table) : null;
1201
- const fields = res.metaData.map((col) => {
1202
- const fromTable = known?.has(col.name) ?? false;
1203
- return {
1204
- name: col.name,
1205
- orgName: col.name,
1206
- type: (col.dbTypeName ?? "").toLowerCase(),
1207
- table: fromTable ? source.table : "",
1208
- orgTable: fromTable ? source.table : "",
1209
- db: fromTable ? schema : "",
1210
- maxLength: col.byteSize ?? 0
1211
- };
1212
- });
1213
- const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
1214
- return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
1094
+ }
1095
+ async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
1096
+ const result = { identityFiles: [] };
1097
+ if (!file) return result;
1098
+ const seen = /* @__PURE__ */ new Set();
1099
+ async function walk(path, active) {
1100
+ let blockActive = active;
1101
+ for (const line of await loadLines(path, seen)) {
1102
+ const d = parseLine(line);
1103
+ if (!d) continue;
1104
+ switch (d.keyword) {
1105
+ case "host":
1106
+ blockActive = hostPatternMatches(d.args, host);
1107
+ continue;
1108
+ case "match":
1109
+ blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
1110
+ continue;
1111
+ case "include":
1112
+ for (const inc of d.args) {
1113
+ const target = expandHome2(inc);
1114
+ const abs = isAbsolute(target) ? target : join2(homedir2(), ".ssh", target);
1115
+ for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
1116
+ }
1117
+ continue;
1118
+ }
1119
+ if (!blockActive) continue;
1120
+ const value = d.args[0];
1121
+ if (value === void 0) continue;
1122
+ switch (d.keyword) {
1123
+ case "hostname":
1124
+ result.hostName ??= value.replace(/%h/g, host);
1125
+ break;
1126
+ case "port":
1127
+ result.port ??= Number(value) || void 0;
1128
+ break;
1129
+ case "user":
1130
+ result.user ??= value;
1131
+ break;
1132
+ case "identityfile":
1133
+ for (const f of d.args) {
1134
+ const expanded = expandHome2(f.replace(/%d/g, homedir2()).replace(/%h/g, host));
1135
+ if (expanded.split(/[\\/]/).includes("..")) continue;
1136
+ result.identityFiles.push(expanded);
1137
+ }
1138
+ break;
1139
+ case "identityagent":
1140
+ result.identityAgent ??= value === "none" ? void 0 : expandHome2(value.replace(/%d/g, homedir2()));
1141
+ break;
1142
+ case "proxyjump":
1143
+ result.proxyJump ??= value;
1144
+ break;
1145
+ }
1215
1146
  }
1216
- return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
1217
- }
1218
- async close() {
1219
- await this.conn.close();
1220
1147
  }
1221
- };
1222
- function connectionAttributes(config) {
1223
- return {
1224
- user: config.user,
1225
- password: config.password,
1226
- connectString: `${config.host}:${config.port}/${config.serviceName}`
1227
- };
1148
+ await walk(file, true);
1149
+ return result;
1228
1150
  }
1229
- var oracleDriverFactory = {
1230
- async open(config) {
1231
- const conn = await oracledb.getConnection(connectionAttributes(config));
1232
- const driver = new OracleDriver(conn, config);
1233
- await driver.init();
1234
- return driver;
1235
- },
1236
- async test(config) {
1237
- const conn = await oracledb.getConnection(connectionAttributes(config));
1238
- await conn.close();
1239
- },
1240
- parseConfig(body) {
1241
- const host = typeof body.host === "string" ? body.host.trim() : "";
1242
- const user = typeof body.user === "string" ? body.user : "";
1243
- if (!host || !user) {
1244
- throw new Error("host and user are required");
1245
- }
1246
- if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
1247
- const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
1248
- if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
1249
- return {
1250
- driver: "oracle",
1251
- host,
1252
- port: Number(body.port) || 1521,
1253
- user,
1254
- password: typeof body.password === "string" ? body.password : "",
1255
- serviceName
1256
- };
1257
- }
1258
- };
1259
1151
 
1260
- // src/drivers/d1.ts
1261
- var API = "https://api.cloudflare.com/client/v4";
1262
- var D1Error = class extends Error {
1152
+ // src/ssh-tunnel.ts
1153
+ var TunnelError = class extends Error {
1263
1154
  constructor(message, status) {
1264
1155
  super(message);
1265
1156
  this.status = status;
1157
+ this.name = "TunnelError";
1266
1158
  }
1267
1159
  status;
1268
1160
  };
1269
- var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
1270
- var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
1271
- var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
1272
- function singleSourceTable2(sql2) {
1273
- const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
1274
- if (!m) return null;
1275
- const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
1276
- if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
1277
- const raw = m[1];
1278
- const first = raw[0];
1279
- if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
1280
- if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
1281
- if (first === "[") return raw.slice(1, -1);
1282
- return raw;
1283
- }
1284
- function normalizeDeclaredType2(declared) {
1285
- if (typeof declared !== "string") return null;
1286
- const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
1287
- return base || null;
1161
+ var HostKeyError = class extends TunnelError {
1162
+ hostKey;
1163
+ constructor(host, port, type, fingerprint2) {
1164
+ super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
1165
+ this.name = "HostKeyError";
1166
+ this.hostKey = { host, port, type, fingerprint: fingerprint2 };
1167
+ }
1168
+ };
1169
+ var CONNECT_TIMEOUT_MS = 2e4;
1170
+ var PLANCHE_DIR = join3(homedir3(), ".planche");
1171
+ var PLANCHE_KNOWN_HOSTS = join3(PLANCHE_DIR, "known_hosts");
1172
+ var OPENSSH_KNOWN_HOSTS = join3(homedir3(), ".ssh", "known_hosts");
1173
+ function parseSshConfig(value) {
1174
+ if (value === void 0 || value === null) return void 0;
1175
+ if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
1176
+ const body = value;
1177
+ const host = typeof body.host === "string" ? body.host.trim() : "";
1178
+ const user = typeof body.user === "string" ? body.user.trim() : "";
1179
+ const auth = body.auth;
1180
+ if (!host) throw new TunnelError("ssh.host is required", 400);
1181
+ if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
1182
+ if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
1183
+ const port = Math.trunc(Number(body.port)) || 22;
1184
+ if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
1185
+ if (auth !== "password" && auth !== "key" && auth !== "agent") {
1186
+ throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
1187
+ }
1188
+ const str = (v) => typeof v === "string" && v ? v : void 0;
1189
+ const config = {
1190
+ host,
1191
+ port,
1192
+ user,
1193
+ auth,
1194
+ password: auth === "password" ? str(body.password) ?? "" : void 0,
1195
+ privateKey: auth === "key" ? str(body.privateKey) : void 0,
1196
+ passphrase: auth === "key" ? str(body.passphrase) : void 0,
1197
+ acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
1198
+ };
1199
+ return config;
1288
1200
  }
1289
- function inferTypeFromValue2(value) {
1290
- if (value === null || value === void 0) return "null";
1291
- if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
1292
- if (Array.isArray(value)) return "blob";
1293
- return "text";
1201
+ function keyType(blob) {
1202
+ if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
1203
+ const len = blob.readUInt32BE(0);
1204
+ if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
1205
+ const type = blob.subarray(4, 4 + len).toString("ascii");
1206
+ if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
1207
+ return type;
1294
1208
  }
1295
- function firstNonNull2(records, index) {
1296
- for (const row of records) {
1297
- const value = row[index];
1298
- if (value !== null && value !== void 0) return value;
1209
+ function knownHostsEntryMatches(field, patterns) {
1210
+ if (field.startsWith("|1|")) {
1211
+ const [, , salt, hash] = field.split("|");
1212
+ if (!salt || !hash) return false;
1213
+ const key = Buffer.from(salt, "base64");
1214
+ const want = Buffer.from(hash, "base64");
1215
+ return patterns.some((p) => {
1216
+ const got = createHmac("sha1", key).update(p).digest();
1217
+ return got.length === want.length && timingSafeEqual(got, want);
1218
+ });
1299
1219
  }
1300
- return null;
1220
+ const entries = field.toLowerCase().split(",");
1221
+ return patterns.some((p) => hostPatternMatches(entries, p));
1301
1222
  }
1302
- var D1Driver = class {
1303
- type = "d1";
1304
- label;
1305
- url;
1306
- headers;
1307
- /** Declared column types per table, for result-source resolution. */
1308
- columns = /* @__PURE__ */ new Map();
1309
- constructor(config) {
1310
- this.label = `d1:${config.databaseId}`;
1311
- this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
1312
- this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
1223
+ function hostPatterns(host, port) {
1224
+ const h = host.toLowerCase();
1225
+ return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
1226
+ }
1227
+ async function readKnownHosts(file) {
1228
+ try {
1229
+ return (await readFile2(file, "utf8")).split(/\r?\n/);
1230
+ } catch {
1231
+ return [];
1313
1232
  }
1314
- async raw(sql2) {
1315
- let res;
1316
- try {
1317
- res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }), signal: AbortSignal.timeout(6e4) });
1318
- } catch (err) {
1319
- throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
1320
- }
1321
- let body = null;
1322
- try {
1323
- body = await res.json();
1324
- } catch {
1325
- }
1326
- if (!res.ok || !body?.success) {
1327
- const message = (body?.errors?.map((e) => String(e.message)).join("; ") || `Cloudflare API responded ${res.status}`).slice(0, 500);
1328
- throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
1233
+ }
1234
+ async function lookupHostKey(host, port, blob) {
1235
+ const patterns = hostPatterns(host, port);
1236
+ const type = keyType(blob);
1237
+ const encoded = blob.toString("base64");
1238
+ let sawHost = false;
1239
+ const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
1240
+ for (const raw of lines) {
1241
+ const line = raw.trim();
1242
+ if (!line || line.startsWith("#")) continue;
1243
+ const fields = line.split(/\s+/);
1244
+ const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
1245
+ const [hosts, lineType, lineKey] = fields;
1246
+ if (!hosts || !lineType || !lineKey) continue;
1247
+ if (!knownHostsEntryMatches(hosts, patterns)) continue;
1248
+ if (marker === "@revoked") {
1249
+ if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
1250
+ continue;
1329
1251
  }
1330
- const result = body.result?.[0];
1331
- if (!result) throw new D1Error("Cloudflare API returned no result");
1332
- return result;
1252
+ if (marker === "@cert-authority") continue;
1253
+ sawHost = true;
1254
+ if (lineType === type && lineKey === encoded) return "match";
1333
1255
  }
1334
- async tableColumns(table) {
1335
- const cached = this.columns.get(table);
1336
- if (cached !== void 0) return cached;
1337
- let out = null;
1338
- try {
1339
- const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
1340
- const cols = info.results?.columns ?? [];
1341
- const name = cols.indexOf("name");
1342
- const type = cols.indexOf("type");
1343
- const rows = info.results?.rows ?? [];
1344
- if (rows.length > 0 && name >= 0) {
1345
- out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
1346
- }
1347
- } catch {
1348
- out = null;
1349
- }
1350
- if (this.columns.size > 200) this.columns.clear();
1351
- this.columns.set(table, out);
1352
- return out;
1256
+ return sawHost ? "mismatch" : "unknown";
1257
+ }
1258
+ async function rememberHostKey(host, port, blob) {
1259
+ await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
1260
+ const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
1261
+ await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
1262
+ `, { mode: 384 });
1263
+ }
1264
+ function fingerprint(blob) {
1265
+ return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
1266
+ }
1267
+ async function resolveConfig(ssh) {
1268
+ const cfg = await resolveSshHost(ssh.host);
1269
+ if (cfg.proxyJump) {
1270
+ throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
1353
1271
  }
1354
- async query(sql2) {
1355
- if (DATABASE_LIST.test(sql2)) {
1356
- return {
1357
- fields: [
1358
- { name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
1359
- { name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
1360
- { name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
1361
- ],
1362
- records: [[0, "main", ""]],
1363
- affectedRows: 0,
1364
- insertId: 0,
1365
- isResultQuery: true
1366
- };
1272
+ if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
1273
+ throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
1274
+ }
1275
+ const resolved = {
1276
+ ...ssh,
1277
+ host: cfg.hostName ?? ssh.host,
1278
+ port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
1279
+ user: ssh.user || cfg.user || ""
1280
+ };
1281
+ if (!resolved.user) {
1282
+ throw new TunnelError(
1283
+ SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
1284
+ 400
1285
+ );
1286
+ }
1287
+ if (resolved.auth === "key" && !resolved.privateKey) {
1288
+ if (SSH_CONFIG_DISABLED) {
1289
+ throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
1367
1290
  }
1368
- const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
1369
- if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
1370
- throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
1291
+ const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
1292
+ for (const file of candidates) {
1293
+ try {
1294
+ resolved.privateKey = await readFile2(file, "utf8");
1295
+ console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
1296
+ break;
1297
+ } catch {
1298
+ }
1371
1299
  }
1372
- if (this.isDdl(text)) this.columns.clear();
1373
- const result = await this.raw(text);
1374
- const columns = result.results?.columns ?? [];
1375
- const records = result.results?.rows ?? [];
1376
- if (columns.length === 0 && !RESULT_KIND.test(text)) {
1377
- return {
1378
- fields: [],
1379
- records: [],
1380
- affectedRows: result.meta?.changes ?? 0,
1381
- insertId: result.meta?.last_row_id ?? 0,
1382
- isResultQuery: false
1383
- };
1300
+ if (!resolved.privateKey) {
1301
+ throw new TunnelError(
1302
+ `No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename3(f)).join(", ")} in ~/.ssh)`,
1303
+ 400
1304
+ );
1384
1305
  }
1385
- const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
1386
- const known = source ? await this.tableColumns(source) : null;
1387
- const fields = columns.map((name, index) => {
1388
- const fromTable = known?.has(name) ?? false;
1389
- const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
1390
- return {
1391
- name,
1392
- orgName: name,
1393
- type,
1394
- table: fromTable && source ? source : "",
1395
- orgTable: fromTable && source ? source : "",
1396
- db: fromTable ? "main" : "",
1397
- maxLength: 0
1398
- };
1399
- });
1400
- return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
1401
1306
  }
1402
- isDdl(sql2) {
1403
- return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
1307
+ return { ...resolved, identityAgent: cfg.identityAgent };
1308
+ }
1309
+ function toConnectConfig(ssh) {
1310
+ const base = {
1311
+ host: ssh.host,
1312
+ port: ssh.port,
1313
+ username: ssh.user,
1314
+ readyTimeout: CONNECT_TIMEOUT_MS,
1315
+ keepaliveInterval: 3e4
1316
+ };
1317
+ switch (ssh.auth) {
1318
+ case "password":
1319
+ return { ...base, password: ssh.password ?? "", tryKeyboard: true };
1320
+ case "key":
1321
+ return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
1322
+ case "agent": {
1323
+ const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
1324
+ if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
1325
+ return { ...base, agent };
1326
+ }
1404
1327
  }
1405
- async close() {
1406
- this.columns.clear();
1328
+ }
1329
+ function describeSshError(err, ssh) {
1330
+ const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
1331
+ if (err instanceof TunnelError) return err;
1332
+ switch (err.level) {
1333
+ case "client-authentication":
1334
+ return new TunnelError(`SSH authentication failed for ${target}`, 401);
1335
+ case "client-timeout":
1336
+ return new TunnelError(`SSH connection to ${target} timed out`, 400);
1337
+ default:
1338
+ return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
1407
1339
  }
1408
- };
1409
- var d1DriverFactory = {
1410
- async open(config) {
1411
- const driver = new D1Driver(config);
1412
- await driver.raw("SELECT 1");
1413
- return driver;
1414
- },
1415
- async test(config) {
1416
- await new D1Driver(config).raw("SELECT 1");
1417
- },
1418
- parseConfig(body) {
1419
- const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
1420
- const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
1421
- const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
1422
- if (!accountId || !databaseId || !apiToken) {
1423
- throw new Error("accountId, databaseId and apiToken are required");
1340
+ }
1341
+ async function openSshTunnel(requested, target) {
1342
+ const ssh = await resolveConfig(requested);
1343
+ const client = new Client();
1344
+ const connectConfig = toConnectConfig(ssh);
1345
+ let verifyError;
1346
+ connectConfig.hostVerifier = (key, verified) => {
1347
+ lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
1348
+ const fp = fingerprint(key);
1349
+ if (result === "mismatch") {
1350
+ verifyError = new TunnelError(
1351
+ `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.`,
1352
+ 400
1353
+ );
1354
+ verified(false);
1355
+ return;
1356
+ }
1357
+ if (result === "unknown") {
1358
+ if (ssh.acceptHostKey !== fp) {
1359
+ verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
1360
+ verified(false);
1361
+ return;
1362
+ }
1363
+ console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
1364
+ await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
1365
+ }
1366
+ verified(true);
1367
+ }).catch((err) => {
1368
+ verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
1369
+ verified(false);
1370
+ });
1371
+ };
1372
+ await new Promise((resolve3, reject) => {
1373
+ client.once("ready", resolve3);
1374
+ client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
1375
+ try {
1376
+ client.connect(connectConfig);
1377
+ } catch (err) {
1378
+ reject(describeSshError(err, ssh));
1424
1379
  }
1425
- return { driver: "d1", accountId, databaseId, apiToken };
1426
- }
1427
- };
1380
+ });
1381
+ client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
1382
+ const sockets = /* @__PURE__ */ new Set();
1383
+ let forwardError;
1384
+ const server = createServer((socket) => {
1385
+ sockets.add(socket);
1386
+ socket.once("close", () => sockets.delete(socket));
1387
+ socket.on("error", () => socket.destroy());
1388
+ client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
1389
+ if (err) {
1390
+ const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
1391
+ 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?";
1392
+ forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
1393
+ console.error(`[ssh] ${forwardError}`);
1394
+ socket.destroy();
1395
+ return;
1396
+ }
1397
+ stream.on("error", () => socket.destroy());
1398
+ socket.pipe(stream).pipe(socket);
1399
+ stream.once("close", () => socket.destroy());
1400
+ socket.once("close", () => stream.destroy());
1401
+ });
1402
+ });
1403
+ await new Promise((resolve3, reject) => {
1404
+ server.once("error", reject);
1405
+ server.listen(0, "127.0.0.1", () => resolve3());
1406
+ }).catch((err) => {
1407
+ client.end();
1408
+ throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
1409
+ });
1410
+ const { port } = server.address();
1411
+ let closed = false;
1412
+ const close = () => new Promise((resolve3) => {
1413
+ if (closed) return resolve3();
1414
+ closed = true;
1415
+ for (const s of sockets) s.destroy();
1416
+ server.close(() => {
1417
+ client.end();
1418
+ resolve3();
1419
+ });
1420
+ });
1421
+ client.once("close", () => void close());
1422
+ return {
1423
+ host: "127.0.0.1",
1424
+ port,
1425
+ get forwardError() {
1426
+ return forwardError;
1427
+ },
1428
+ close
1429
+ };
1430
+ }
1428
1431
 
1429
1432
  // src/drivers/index.ts
1430
1433
  var factories = {
@@ -1463,6 +1466,7 @@ function tunneled(driver, tunnel, label) {
1463
1466
  type: driver.type,
1464
1467
  label,
1465
1468
  query: (sql2, database) => driver.query(sql2, database),
1469
+ setReadOnly: driver.setReadOnly ? () => driver.setReadOnly() : void 0,
1466
1470
  async close() {
1467
1471
  try {
1468
1472
  await driver.close();
@@ -1522,6 +1526,59 @@ function testDirect(config) {
1522
1526
  }
1523
1527
  }
1524
1528
 
1529
+ // src/gateway.ts
1530
+ function loadGatewayConfig(file) {
1531
+ const path = resolve2(file);
1532
+ let raw;
1533
+ try {
1534
+ raw = JSON.parse(readFileSync(path, "utf8"));
1535
+ } catch (err) {
1536
+ throw new Error(`Cannot read gateway config ${path}: ${err instanceof Error ? err.message : String(err)}`);
1537
+ }
1538
+ const data = raw;
1539
+ if (!data || typeof data !== "object" || !Array.isArray(data.profiles) || data.profiles.length === 0) {
1540
+ throw new Error(`Gateway config ${path} needs a non-empty "profiles" array`);
1541
+ }
1542
+ const names = /* @__PURE__ */ new Set();
1543
+ const profiles = data.profiles.map((entry, i) => {
1544
+ const e = entry ?? {};
1545
+ const name = typeof e.name === "string" ? e.name.trim() : "";
1546
+ if (!name) throw new Error(`Gateway profile #${i + 1} has no "name"`);
1547
+ if (names.has(name)) throw new Error(`Gateway profile name is not unique: ${name}`);
1548
+ names.add(name);
1549
+ const { name: _n, readOnly, ...driver } = e;
1550
+ void _n;
1551
+ let config;
1552
+ try {
1553
+ config = parseDriverConfig(driver);
1554
+ } catch (err) {
1555
+ throw new Error(`Gateway profile "${name}": ${err instanceof Error ? err.message : String(err)}`);
1556
+ }
1557
+ return { name, readOnly: readOnly !== false, config };
1558
+ });
1559
+ return { name: typeof data.name === "string" && data.name.trim() ? data.name.trim() : "Planche gateway", profiles };
1560
+ }
1561
+ function describeProfile(entry) {
1562
+ const c = entry.config;
1563
+ const database = "database" in c && typeof c.database === "string" ? c.database : c.driver === "oracle" ? c.serviceName : void 0;
1564
+ return { name: entry.name, driver: c.driver, readOnly: entry.readOnly, ...database ? { database } : {} };
1565
+ }
1566
+
1567
+ // src/index.ts
1568
+ import { existsSync as existsSync3 } from "fs";
1569
+ import { join as join5 } from "path";
1570
+ import { fileURLToPath } from "url";
1571
+
1572
+ // src/server.ts
1573
+ import express from "express";
1574
+ import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1575
+
1576
+ // ../shared/contract.ts
1577
+ var PROTOCOL_VERSION = 2;
1578
+
1579
+ // src/tunneling.ts
1580
+ import { randomUUID } from "crypto";
1581
+
1525
1582
  // src/drivers/connection-lost.ts
1526
1583
  var ConnectionLostError = class extends Error {
1527
1584
  status = 404;
@@ -1585,12 +1642,105 @@ function isConnectionLost(err) {
1585
1642
  return e.originalError !== void 0 && e.originalError !== err && isConnectionLost(e.originalError);
1586
1643
  }
1587
1644
 
1645
+ // src/read-only.ts
1646
+ var ReadOnlyViolation = class extends Error {
1647
+ status = 403;
1648
+ constructor(message) {
1649
+ super(message);
1650
+ this.name = "ReadOnlyViolation";
1651
+ }
1652
+ };
1653
+ var READ_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "VALUES", "TABLE", "PRAGMA"]);
1654
+ function splitStatements(sql2) {
1655
+ const out = [];
1656
+ let cur = "";
1657
+ let i = 0;
1658
+ const n = sql2.length;
1659
+ while (i < n) {
1660
+ const ch = sql2[i];
1661
+ const next = sql2[i + 1];
1662
+ if (ch === "-" && next === "-") {
1663
+ while (i < n && sql2[i] !== "\n") i++;
1664
+ cur += " ";
1665
+ continue;
1666
+ }
1667
+ if (ch === "#") {
1668
+ while (i < n && sql2[i] !== "\n") i++;
1669
+ cur += " ";
1670
+ continue;
1671
+ }
1672
+ if (ch === "/" && next === "*") {
1673
+ i += 2;
1674
+ while (i < n && !(sql2[i] === "*" && sql2[i + 1] === "/")) i++;
1675
+ i += 2;
1676
+ cur += " ";
1677
+ continue;
1678
+ }
1679
+ if (ch === "'" || ch === '"' || ch === "`" || ch === "[") {
1680
+ const close = ch === "[" ? "]" : ch;
1681
+ i++;
1682
+ while (i < n) {
1683
+ if (sql2[i] === "\\" && close === "'") {
1684
+ i += 2;
1685
+ continue;
1686
+ }
1687
+ if (sql2[i] === close) {
1688
+ if (sql2[i + 1] === close && close !== "]") {
1689
+ i += 2;
1690
+ continue;
1691
+ }
1692
+ break;
1693
+ }
1694
+ i++;
1695
+ }
1696
+ i++;
1697
+ cur += ch === "[" ? " x " : " 'x' ";
1698
+ continue;
1699
+ }
1700
+ if (ch === "$" && next === "$") {
1701
+ const end = sql2.indexOf("$$", i + 2);
1702
+ i = end === -1 ? n : end + 2;
1703
+ cur += " 'x' ";
1704
+ continue;
1705
+ }
1706
+ if (ch === ";") {
1707
+ out.push(cur);
1708
+ cur = "";
1709
+ i++;
1710
+ continue;
1711
+ }
1712
+ cur += ch;
1713
+ i++;
1714
+ }
1715
+ out.push(cur);
1716
+ return out.map((s) => s.trim()).filter((s) => s.length > 0);
1717
+ }
1718
+ function assertReadOnlyStatement(sql2) {
1719
+ const statements = splitStatements(sql2);
1720
+ if (statements.length === 0) return;
1721
+ if (statements.length > 1) throw new ReadOnlyViolation("Read-only profile: one statement per request");
1722
+ const stmt = statements[0];
1723
+ const first = /^[A-Za-z]+/.exec(stmt)?.[0].toUpperCase() ?? "";
1724
+ if (!READ_KEYWORDS.has(first)) throw new ReadOnlyViolation(`Read-only profile: ${first || "this statement"} is not allowed`);
1725
+ if (first === "WITH") {
1726
+ const words = stmt.toUpperCase().match(/\b(INSERT|UPDATE|DELETE|MERGE|REPLACE)\b/);
1727
+ if (words) throw new ReadOnlyViolation(`Read-only profile: ${words[1]} inside WITH is not allowed`);
1728
+ }
1729
+ if (first === "PRAGMA" && /[=(]/.test(stmt)) throw new ReadOnlyViolation("Read-only profile: PRAGMA may only read");
1730
+ if (/\bINTO\s+(OUTFILE|DUMPFILE)\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: INTO OUTFILE is not allowed");
1731
+ 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");
1732
+ if (/\bLOCK\s+IN\s+SHARE\s+MODE\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: locking reads are not allowed");
1733
+ if (first === "SELECT" && /\bINTO\b/i.test(stmt) && !/\bINTO\s+@/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: SELECT INTO is not allowed");
1734
+ }
1735
+
1588
1736
  // src/tunneling.ts
1589
1737
  var IDLE_TIMEOUT_MS = Number(process.env.PLANCHE_IDLE_TIMEOUT_MS) || 30 * 60 * 1e3;
1590
1738
  var MAX_CONNECTIONS = Number(process.env.PLANCHE_MAX_CONNECTIONS) || 64;
1591
1739
  var ConnectionManager = class {
1592
1740
  connections = /* @__PURE__ */ new Map();
1593
1741
  lastUsed = /* @__PURE__ */ new Map();
1742
+ /** Connections opened for a read-only gateway profile: writes are refused before reaching the driver. */
1743
+ readOnly = /* @__PURE__ */ new Set();
1594
1744
  constructor() {
1595
1745
  const timer = setInterval(() => void this.closeIdle(), 6e4);
1596
1746
  timer.unref();
@@ -1608,14 +1758,23 @@ var ConnectionManager = class {
1608
1758
  /**
1609
1759
  * Open a new connection and return a unique connection ID.
1610
1760
  */
1611
- async connect(config) {
1761
+ async connect(config, options = {}) {
1612
1762
  if (this.connections.size >= MAX_CONNECTIONS) {
1613
1763
  throw new Error(`Too many open connections (${MAX_CONNECTIONS}); disconnect some first`);
1614
1764
  }
1615
1765
  const driver = await openDriver(config);
1766
+ if (options.readOnly && driver.setReadOnly) {
1767
+ try {
1768
+ await driver.setReadOnly();
1769
+ } catch (err) {
1770
+ await driver.close().catch(() => void 0);
1771
+ throw err;
1772
+ }
1773
+ }
1616
1774
  const connectionId = randomUUID();
1617
1775
  this.connections.set(connectionId, driver);
1618
1776
  this.lastUsed.set(connectionId, Date.now());
1777
+ if (options.readOnly) this.readOnly.add(connectionId);
1619
1778
  console.log(`[ConnectionManager] New ${driver.type} connection: ${connectionId} -> ${driver.label}`);
1620
1779
  return connectionId;
1621
1780
  }
@@ -1632,6 +1791,7 @@ var ConnectionManager = class {
1632
1791
  throw new Error(`Connection not found: ${connectionId}`);
1633
1792
  }
1634
1793
  this.lastUsed.set(connectionId, Date.now());
1794
+ if (this.readOnly.has(connectionId)) assertReadOnlyStatement(sql2);
1635
1795
  try {
1636
1796
  return await driver.query(sql2, database);
1637
1797
  } catch (err) {
@@ -1639,6 +1799,7 @@ var ConnectionManager = class {
1639
1799
  console.log(`[ConnectionManager] Connection lost, evicting: ${connectionId}`);
1640
1800
  this.connections.delete(connectionId);
1641
1801
  this.lastUsed.delete(connectionId);
1802
+ this.readOnly.delete(connectionId);
1642
1803
  await driver.close().catch(() => void 0);
1643
1804
  throw new ConnectionLostError(`Connection lost: ${err instanceof Error ? err.message : String(err)}`);
1644
1805
  }
@@ -1654,6 +1815,7 @@ var ConnectionManager = class {
1654
1815
  await driver.close();
1655
1816
  this.connections.delete(connectionId);
1656
1817
  this.lastUsed.delete(connectionId);
1818
+ this.readOnly.delete(connectionId);
1657
1819
  console.log(`[ConnectionManager] Disconnected: ${connectionId}`);
1658
1820
  }
1659
1821
  /**
@@ -1685,10 +1847,13 @@ var ConnectionManager = class {
1685
1847
  await Promise.all(promises);
1686
1848
  this.connections.clear();
1687
1849
  this.lastUsed.clear();
1850
+ this.readOnly.clear();
1688
1851
  }
1689
1852
  };
1690
1853
 
1691
1854
  // src/server.ts
1855
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
1856
+ import { join as join4 } from "path";
1692
1857
  function errorStatus(err) {
1693
1858
  const status = err?.status;
1694
1859
  return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
@@ -1810,6 +1975,7 @@ function createAgent(options) {
1810
1975
  next();
1811
1976
  });
1812
1977
  app.use(accessControl(allowedOrigins));
1978
+ if (options.appDir) serveApp(app, options.appDir);
1813
1979
  app.get("/health", (req, res) => {
1814
1980
  const body = {
1815
1981
  success: true,
@@ -1819,6 +1985,7 @@ function createAgent(options) {
1819
1985
  activeConnections: manager.activeConnections,
1820
1986
  uptime: process.uptime()
1821
1987
  };
1988
+ if (options.gateway) body.gateway = { name: options.gateway.name, profiles: options.gateway.profiles.length };
1822
1989
  if (req.headers.authorization !== void 0) {
1823
1990
  if (throttle.locked(peerOf(req))) {
1824
1991
  fail(res, 429, "Too many failed authentication attempts; try again shortly");
@@ -1855,10 +2022,28 @@ function createAgent(options) {
1855
2022
  next();
1856
2023
  });
1857
2024
  app.use(express.json({ limit: options.bodyLimit }));
2025
+ app.get("/profiles", (_req, res) => {
2026
+ if (!options.gateway) {
2027
+ fail(res, 404, "Not a gateway");
2028
+ return;
2029
+ }
2030
+ const body = { success: true, gateway: options.gateway.name, profiles: options.gateway.profiles.map(describeProfile) };
2031
+ res.json(body);
2032
+ });
2033
+ const resolveConnect = (body) => {
2034
+ const b = body ?? {};
2035
+ if (options.gateway) {
2036
+ if (typeof b.profile !== "string") throw Object.assign(new Error("Gateway mode: choose a profile (POST { profile })"), { status: 403 });
2037
+ const entry = options.gateway.profiles.find((p) => p.name === b.profile);
2038
+ if (!entry) throw Object.assign(new Error(`Unknown gateway profile: ${b.profile}`), { status: 404 });
2039
+ return { config: entry.config, readOnly: entry.readOnly };
2040
+ }
2041
+ return { config: parseDriverConfig(b), readOnly: false };
2042
+ };
1858
2043
  app.post("/connect", async (req, res) => {
1859
2044
  try {
1860
- const config = parseDriverConfig(req.body ?? {});
1861
- const connectionId = await manager.connect(config);
2045
+ const { config, readOnly } = resolveConnect(req.body);
2046
+ const connectionId = await manager.connect(config, { readOnly });
1862
2047
  res.json({ success: true, connectionId, message: "Connection Successful" });
1863
2048
  } catch (err) {
1864
2049
  const message = errorMessage(err);
@@ -1920,7 +2105,7 @@ function createAgent(options) {
1920
2105
  });
1921
2106
  app.post("/test-connection", async (req, res) => {
1922
2107
  try {
1923
- await manager.testConnection(parseDriverConfig(req.body ?? {}));
2108
+ await manager.testConnection(resolveConnect(req.body).config);
1924
2109
  res.json({ success: true, message: "Connection test successful" });
1925
2110
  } catch (err) {
1926
2111
  const message = errorMessage(err);
@@ -1938,6 +2123,45 @@ function createAgent(options) {
1938
2123
  });
1939
2124
  return { app, manager };
1940
2125
  }
2126
+ function serveApp(app, appDir) {
2127
+ const csp = [
2128
+ "default-src 'self'",
2129
+ "script-src 'self' 'wasm-unsafe-eval'",
2130
+ "connect-src 'self'",
2131
+ "img-src 'self' data: blob:",
2132
+ "style-src 'self' 'unsafe-inline'",
2133
+ "font-src 'self' data:",
2134
+ "worker-src 'self' blob:",
2135
+ "child-src 'self' blob:",
2136
+ "object-src 'none'",
2137
+ "base-uri 'none'",
2138
+ "form-action 'none'",
2139
+ "frame-ancestors 'none'"
2140
+ ].join("; ");
2141
+ const indexFile = join4(appDir, "index.html");
2142
+ const indexHtml = existsSync2(indexFile) ? readFileSync2(indexFile, "utf8").replace("<head>", '<head>\n <meta name="planche-agent" content="same-origin" />') : null;
2143
+ const headers = (res) => {
2144
+ res.setHeader("Content-Security-Policy", csp);
2145
+ res.setHeader("X-Frame-Options", "DENY");
2146
+ res.setHeader("Referrer-Policy", "no-referrer");
2147
+ res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
2148
+ };
2149
+ app.get(["/", "/index.html"], (_req, res) => {
2150
+ if (!indexHtml) {
2151
+ fail(res, 404, "App not bundled");
2152
+ return;
2153
+ }
2154
+ headers(res);
2155
+ res.type("html").send(indexHtml);
2156
+ });
2157
+ app.use(express.static(appDir, {
2158
+ index: false,
2159
+ setHeaders: (res, path) => {
2160
+ if (path.endsWith(".html")) headers(res);
2161
+ if (/\/assets\//.test(path)) res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
2162
+ }
2163
+ }));
2164
+ }
1941
2165
 
1942
2166
  // src/index.ts
1943
2167
  var { version: VERSION } = createRequire(import.meta.url)("../package.json");
@@ -1962,6 +2186,12 @@ Options:
1962
2186
  --origin <url> Extra browser origin allowed to use the agent (repeatable,
1963
2187
  env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
1964
2188
  --allow-remote Required with a non-loopback --addr (see README: no TLS)
2189
+ --gateway <file> Gateway mode: serve named read-only profiles from a JSON
2190
+ config instead of accepting credentials (env PLANCHE_GATEWAY)
2191
+ --public-url <u> URL users open when the agent serves the app itself
2192
+ (behind your TLS proxy; env PLANCHE_PUBLIC_URL)
2193
+ --no-serve-app Do not serve the bundled web app at / (served by default
2194
+ in gateway mode)
1965
2195
  --no-open Do not open the browser
1966
2196
  --verbose Log every request
1967
2197
  -v, --version Print the version
@@ -2011,6 +2241,9 @@ function main() {
2011
2241
  verbose: { type: "boolean" },
2012
2242
  "allow-remote": { type: "boolean" },
2013
2243
  "print-token": { type: "boolean" },
2244
+ gateway: { type: "string" },
2245
+ "public-url": { type: "string" },
2246
+ "serve-app": { type: "boolean" },
2014
2247
  version: { type: "boolean", short: "v" },
2015
2248
  help: { type: "boolean", short: "h" }
2016
2249
  },
@@ -2035,7 +2268,24 @@ function main() {
2035
2268
  const addr = flags.addr ?? process.env.PLANCHE_ADDR ?? "127.0.0.1";
2036
2269
  const tokenFromEnv = !flags.token && !!process.env.PLANCHE_AUTH_TOKEN;
2037
2270
  const authToken = flags.token ?? process.env.PLANCHE_AUTH_TOKEN ?? randomUUID2();
2038
- const appUrl = (flags.app ?? process.env.PLANCHE_APP_URL ?? DEFAULT_APP_URL).replace(/\/+$/, "");
2271
+ const gatewayFile = flags.gateway ?? process.env.PLANCHE_GATEWAY;
2272
+ let gateway;
2273
+ if (gatewayFile) {
2274
+ try {
2275
+ gateway = loadGatewayConfig(gatewayFile);
2276
+ } catch (err) {
2277
+ console.error(err instanceof Error ? err.message : String(err));
2278
+ process.exit(2);
2279
+ }
2280
+ }
2281
+ const appDir = fileURLToPath(new URL("../app/", import.meta.url));
2282
+ const serveApp2 = flags["serve-app"] ?? Boolean(gateway);
2283
+ if (serveApp2 && !existsSync3(join5(appDir, "index.html"))) {
2284
+ console.error(`The web app is not bundled with this build (${appDir}); run without --serve-app or use the npm package.`);
2285
+ process.exit(2);
2286
+ }
2287
+ const publicUrl = (flags["public-url"] ?? process.env.PLANCHE_PUBLIC_URL ?? "").replace(/\/+$/, "");
2288
+ const appUrl = (serveApp2 ? publicUrl || null : null) ?? (flags.app ?? process.env.PLANCHE_APP_URL ?? DEFAULT_APP_URL).replace(/\/+$/, "");
2039
2289
  const shouldOpen = flags.open ?? true;
2040
2290
  const bodyLimit = process.env.PLANCHE_BODY_LIMIT || "50mb";
2041
2291
  const appOrigin = originOf(appUrl);
@@ -2068,6 +2318,7 @@ function main() {
2068
2318
  }
2069
2319
  const devApp = LOOPBACK_ADDRS.has(new URL(appUrl).hostname);
2070
2320
  const allowedOrigins = [appOrigin, ...devApp ? DEV_ORIGINS : [], ...extraOrigins];
2321
+ if (serveApp2) allowedOrigins.push(`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`);
2071
2322
  const pairingCode = { code: randomUUID2(), expiresAt: Date.now() + 10 * 60 * 1e3 };
2072
2323
  const listenAddr = addr.replace(/^\[(.*)\]$/, "$1");
2073
2324
  const agentUrl = `http://${listenAddr.includes(":") ? `[${listenAddr}]` : listenAddr}:${port}`;
@@ -2079,9 +2330,11 @@ function main() {
2079
2330
  logRequests: flags.verbose ?? false,
2080
2331
  // Browsers omit a default port from `Host`, so the bare names are allowed too.
2081
2332
  allowedHosts: loopback ? ["127.0.0.1", "localhost", "[::1]", `127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] : void 0,
2082
- pairingCode
2333
+ pairingCode,
2334
+ gateway,
2335
+ appDir: serveApp2 ? appDir : void 0
2083
2336
  });
2084
- const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
2337
+ const pairUrl = serveApp2 ? `${publicUrl || agentUrl}/#pair=${encodeURIComponent(pairingCode.code)}` : `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
2085
2338
  const server = app.listen(port, listenAddr, () => {
2086
2339
  const line = "-".repeat(72);
2087
2340
  console.log(line);