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