planche 0.7.19 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD-PARTY-NOTICES.md +269 -1
- package/app/assets/AboutDialog-CC7jb8_P.js +1 -0
- package/app/assets/AiSettingsDialog-1t0_9610.js +1 -0
- package/app/assets/BigInteger-BlV0QR66.js +1 -0
- package/app/assets/ConnectDialog-C8novVo3.js +1 -0
- package/app/assets/CopyDatabaseDialog-BZaC4BRf.js +1 -0
- package/app/assets/CopyTableDialog-mjYPJxx8.js +1 -0
- package/app/assets/CreateDatabaseDialog-K9LU6SFa.js +1 -0
- package/app/assets/CreateSchemaDialog-IMU6kUwh.js +1 -0
- package/app/assets/DataDiffDialog-DcPx4VLE.js +1 -0
- package/app/assets/{DownloadToCSVDialog-BC6Sq8Ce.js → DownloadToCSVDialog-cWxA73M8.js} +2 -2
- package/app/assets/EditSchemaDialog-UuUlGY4B.js +2 -0
- package/app/assets/FindReplaceDialog-BphY4fYD.js +1 -0
- package/app/assets/{FlushDialog-BX0F3dJQ.js → FlushDialog-C1ahiGEa.js} +1 -1
- package/app/assets/GeneralSettingsDialog-CSoV6bfv.js +1 -0
- package/app/assets/GroupByDialog-CO8in38J.js +1 -0
- package/app/assets/LicenseDialog-d17TldUe.js +1 -0
- package/app/assets/LinkColumnDialog-Dtz38QJy.js +1 -0
- package/app/assets/LocalHistoryDialog-B8WdxxF7.js +1 -0
- package/app/assets/ProcessListDialog-D0hVqOEu.js +1 -0
- package/app/assets/QuerySettingsDialog-C-chLD8U.js +1 -0
- package/app/assets/QuickCommandPalette-BhTxG9s-.js +1 -0
- package/app/assets/RowFormDialog-MZivb9nJ.js +2 -0
- package/app/assets/SafetySettingsDialog-D4p0vAK7.js +1 -0
- package/app/assets/SchemaDiffDialog-BIxIvBGm.js +7 -0
- package/app/assets/{SchemaToHTMLDialog-DjZQwEbM.js → SchemaToHTMLDialog-CalQNcBO.js} +2 -2
- package/app/assets/StatusDialog-C49Nlkmj.js +1 -0
- package/app/assets/SyncDialog-DgjppEJu.js +1 -0
- package/app/assets/{TermsDialog-B53To0oi.js → TermsDialog-CMGa972R.js} +1 -1
- package/app/assets/UserAddDialog-D_Ntb9Oc.js +1 -0
- package/app/assets/UserManagerDialog-BTOhBvd9.js +1 -0
- package/app/assets/VariablesDialog-BTVi_JEh.js +1 -0
- package/app/assets/app-storage-C_1RLCyI.js +1 -0
- package/app/assets/button-B2EsmMJs.js +1 -0
- package/app/assets/chevron-up-CBnVjNE1.js +1 -0
- package/app/assets/connection-store-BnO3WqEO.js +1 -0
- package/app/assets/contract-W8-jEnEI.js +1 -0
- package/app/assets/database-BvWJHB7q.js +1 -0
- package/app/assets/de-DPmJYeTi.js +2 -0
- package/app/assets/dialog-FBpgH0QJ.js +1 -0
- package/app/assets/dist-BUXw8brT.js +1 -0
- package/app/assets/dist-BYhFWvmC.js +41 -0
- package/app/assets/dist-BrzYmjQV.js +1 -0
- package/app/assets/dist-C2vlTGNz.js +1 -0
- package/app/assets/editor-registry-ukUyoekH.js +1 -0
- package/app/assets/{engine-CpwlM_Ww.js → engine-s5WfEr4n.js} +1 -1
- package/app/assets/error-hints-AQZemZKj.js +8 -0
- package/app/assets/fr-BLVbrMir.js +2 -0
- package/app/assets/hmr-singleton-Bit0Kfog.js +1 -0
- package/app/assets/i18n-D0mAAGED.js +2 -0
- package/app/assets/ja-BKUEaPbL.js +2 -0
- package/app/assets/ko-XOd5LtL3.js +2 -0
- package/app/assets/license-store-D_XwbbCs.js +1 -0
- package/app/assets/{main-BLydvBDF.js → main-C-Gt0g0g.js} +214 -196
- package/app/assets/main-C3ej0FPT.css +2 -0
- package/app/assets/mariadb-BT1adDMU.js +17 -0
- package/app/assets/mysql-BEg8tI_S.js +17 -0
- package/app/assets/postgresql-erqhE-5Q.js +19 -0
- package/app/assets/pro-gate-DZATEYdR.js +1 -0
- package/app/assets/pro-upsell-akqEMS8_.js +1 -0
- package/app/assets/pro-usage-C661VD02.js +1 -0
- package/app/assets/query-execution-CZvQYEUq.js +2 -0
- package/app/assets/query-store-dYiA8oM5.js +1 -0
- package/app/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/app/assets/select-DwgCtoqv.js +1 -0
- package/app/assets/settings-controls-DrW8_eiX.js +1 -0
- package/app/assets/settings-store-BBVuUVjA.js +1 -0
- package/app/assets/table-copy-LtbDgIEc.js +1 -0
- package/app/assets/transactsql-BVCsopZt.js +16 -0
- package/app/assets/tunneling-BPoIRxJA.js +1 -0
- package/app/assets/ui-store-5IssrgmG.js +1 -0
- package/app/assets/{user-store-l9ZslFwK.js → user-store-DOy2VMsT.js} +1 -1
- package/app/assets/zh-DNy8t_lA.js +2 -0
- package/app/index.html +29 -20
- package/app/landing.css +96 -0
- package/app/third-party-notices.txt +269 -1
- package/dist/index.js +866 -54
- package/package.json +3 -1
- package/app/assets/AboutDialog-BaQAZjtT.js +0 -1
- package/app/assets/ConnectDialog-BmJvDi1P.js +0 -1
- package/app/assets/CopyDatabaseDialog-BEM-56Og.js +0 -1
- package/app/assets/CopyTableDialog-C2r7l2Ck.js +0 -1
- package/app/assets/CreateDatabaseDialog-CZhIpgiO.js +0 -1
- package/app/assets/CreateSchemaDialog-CNq4nZum.js +0 -1
- package/app/assets/DataDiffDialog-mUg2rIWn.js +0 -1
- package/app/assets/EditSchemaDialog-B0dCI4Lb.js +0 -2
- package/app/assets/FindReplaceDialog-DXJp2TN6.js +0 -1
- package/app/assets/GroupByDialog-BQPTetJF.js +0 -1
- package/app/assets/LinkColumnDialog-BEuhj8o8.js +0 -1
- package/app/assets/LocalHistoryDialog-BGF91HKS.js +0 -1
- package/app/assets/ProcessListDialog-VezdyrHo.js +0 -1
- package/app/assets/QuickCommandPalette-BseiwcpU.js +0 -1
- package/app/assets/RowFormDialog-XRuG3rMt.js +0 -2
- package/app/assets/SafetySettingsDialog-Zh_a4nml.js +0 -1
- package/app/assets/SchemaDiffDialog-BrnruiVP.js +0 -7
- package/app/assets/StatusDialog-a7NhoFvt.js +0 -1
- package/app/assets/UserAddDialog-BBYoKeBv.js +0 -1
- package/app/assets/UserManagerDialog-BPC_IoO6.js +0 -1
- package/app/assets/VariablesDialog-CzHDGRpy.js +0 -1
- package/app/assets/button--s6nUzD8.js +0 -1
- package/app/assets/chevron-up-QmvRX9Fw.js +0 -1
- package/app/assets/connection-store-N-ZwIkVg.js +0 -1
- package/app/assets/database-CpQt6YGv.js +0 -1
- package/app/assets/de-Ci_btQN-.js +0 -2
- package/app/assets/dialog-CJoWJzRo.js +0 -1
- package/app/assets/dist-BAFbxkKh.js +0 -1
- package/app/assets/dist-CBYDYlja.js +0 -41
- package/app/assets/dist-DgJHEXPr.js +0 -1
- package/app/assets/dist-pKt0y9E-.js +0 -1
- package/app/assets/editor-registry-DhF4Rf_I.js +0 -1
- package/app/assets/fr-rLx93rAO.js +0 -2
- package/app/assets/hmr-singleton-DWC1xbJ4.js +0 -1
- package/app/assets/i18n-57fo5jb0.js +0 -2
- package/app/assets/ja-DkiQpTGb.js +0 -2
- package/app/assets/ko-DHI7mstP.js +0 -2
- package/app/assets/main-DX7Tr5cg.css +0 -2
- package/app/assets/pro-usage-VxPxjJqt.js +0 -1
- package/app/assets/query-execution-4-JHu9gp.js +0 -7
- package/app/assets/select-ibmUj7AE.js +0 -1
- package/app/assets/settings-store-WUA0-UcP.js +0 -1
- package/app/assets/table-copy-C1ivtmj8.js +0 -1
- package/app/assets/ui-store-DfspmPOn.js +0 -1
- package/app/assets/use-engine-B9GBHTL_.js +0 -1
- package/app/assets/zh-mgKu2Zwj.js +0 -2
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
5
5
|
import { createRequire as createRequire2 } from "module";
|
|
6
|
-
import { spawn } from "child_process";
|
|
6
|
+
import { spawn as spawn2 } from "child_process";
|
|
7
7
|
import { parseArgs } from "util";
|
|
8
8
|
|
|
9
9
|
// src/gateway.ts
|
|
@@ -234,8 +234,8 @@ function toClientConfig(config) {
|
|
|
234
234
|
var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
|
|
235
235
|
var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
236
236
|
var PostgresDriver = class {
|
|
237
|
-
constructor(
|
|
238
|
-
this.client =
|
|
237
|
+
constructor(client2, config) {
|
|
238
|
+
this.client = client2;
|
|
239
239
|
this.config = config;
|
|
240
240
|
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
241
241
|
}
|
|
@@ -346,17 +346,17 @@ var PostgresDriver = class {
|
|
|
346
346
|
};
|
|
347
347
|
var postgresDriverFactory = {
|
|
348
348
|
async open(config) {
|
|
349
|
-
const
|
|
350
|
-
await
|
|
351
|
-
|
|
352
|
-
const driver = new PostgresDriver(
|
|
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
353
|
await driver.loadTypeNames();
|
|
354
354
|
return driver;
|
|
355
355
|
},
|
|
356
356
|
async test(config) {
|
|
357
|
-
const
|
|
358
|
-
await
|
|
359
|
-
await
|
|
357
|
+
const client2 = new pg.Client(toClientConfig(config));
|
|
358
|
+
await client2.connect();
|
|
359
|
+
await client2.end();
|
|
360
360
|
},
|
|
361
361
|
parseConfig(body) {
|
|
362
362
|
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
@@ -653,8 +653,8 @@ var MssqlDriver = class {
|
|
|
653
653
|
* decide whether it is editable and address rows by primary key.
|
|
654
654
|
*/
|
|
655
655
|
async describe(text) {
|
|
656
|
-
const
|
|
657
|
-
if (
|
|
656
|
+
const cached4 = this.sources.get(text);
|
|
657
|
+
if (cached4 !== void 0) return cached4;
|
|
658
658
|
let result = null;
|
|
659
659
|
try {
|
|
660
660
|
const req = this.pool.request();
|
|
@@ -811,8 +811,8 @@ var OracleDriver = class {
|
|
|
811
811
|
}
|
|
812
812
|
async tableColumns(schema, table) {
|
|
813
813
|
const key = `${schema}.${table}`;
|
|
814
|
-
const
|
|
815
|
-
if (
|
|
814
|
+
const cached4 = this.columns.get(key);
|
|
815
|
+
if (cached4 !== void 0) return cached4;
|
|
816
816
|
let out = null;
|
|
817
817
|
try {
|
|
818
818
|
const res = await this.conn.execute(
|
|
@@ -915,6 +915,23 @@ var oracleDriverFactory = {
|
|
|
915
915
|
|
|
916
916
|
// src/drivers/d1.ts
|
|
917
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
|
+
}
|
|
918
935
|
var D1Error = class extends Error {
|
|
919
936
|
constructor(message, status) {
|
|
920
937
|
super(message);
|
|
@@ -958,6 +975,7 @@ function firstNonNull2(records, index) {
|
|
|
958
975
|
var D1Driver = class {
|
|
959
976
|
type = "d1";
|
|
960
977
|
label;
|
|
978
|
+
/** Not readonly: a local database resolves its id lazily (see `resolveUrl`). */
|
|
961
979
|
url;
|
|
962
980
|
headers;
|
|
963
981
|
/** Declared column types per table, for result-source resolution. */
|
|
@@ -972,18 +990,53 @@ var D1Driver = class {
|
|
|
972
990
|
async cancel() {
|
|
973
991
|
this.inFlight?.abort();
|
|
974
992
|
}
|
|
993
|
+
/** Local databases resolve their id on first use; the remote ones know it up front. */
|
|
994
|
+
local;
|
|
995
|
+
resolving = null;
|
|
975
996
|
constructor(config) {
|
|
976
|
-
this.
|
|
977
|
-
this.
|
|
978
|
-
this.
|
|
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;
|
|
979
1031
|
}
|
|
980
1032
|
async raw(sql2) {
|
|
1033
|
+
const url = this.url || await this.resolveUrl();
|
|
981
1034
|
let res;
|
|
982
1035
|
try {
|
|
983
1036
|
const controller = new AbortController();
|
|
984
1037
|
this.inFlight = controller;
|
|
985
1038
|
try {
|
|
986
|
-
res = await fetch(
|
|
1039
|
+
res = await fetch(url, {
|
|
987
1040
|
method: "POST",
|
|
988
1041
|
headers: this.headers,
|
|
989
1042
|
body: JSON.stringify({ sql: sql2 }),
|
|
@@ -1012,8 +1065,8 @@ var D1Driver = class {
|
|
|
1012
1065
|
return result;
|
|
1013
1066
|
}
|
|
1014
1067
|
async tableColumns(table) {
|
|
1015
|
-
const
|
|
1016
|
-
if (
|
|
1068
|
+
const cached4 = this.columns.get(table);
|
|
1069
|
+
if (cached4 !== void 0) return cached4;
|
|
1017
1070
|
let out = null;
|
|
1018
1071
|
try {
|
|
1019
1072
|
const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
@@ -1064,7 +1117,8 @@ var D1Driver = class {
|
|
|
1064
1117
|
}
|
|
1065
1118
|
const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
|
|
1066
1119
|
const known = source ? await this.tableColumns(source) : null;
|
|
1067
|
-
const
|
|
1120
|
+
const names = columns.length > 0 ? columns : known && SELECT_STAR.test(text) ? [...known.keys()] : [];
|
|
1121
|
+
const fields = names.map((name, index) => {
|
|
1068
1122
|
const fromTable = known?.has(name) ?? false;
|
|
1069
1123
|
const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
|
|
1070
1124
|
return {
|
|
@@ -1099,8 +1153,10 @@ var d1DriverFactory = {
|
|
|
1099
1153
|
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
1100
1154
|
const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
|
|
1101
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 };
|
|
1102
1158
|
if (!accountId || !databaseId || !apiToken) {
|
|
1103
|
-
throw new Error("accountId, databaseId and apiToken are required");
|
|
1159
|
+
throw new Error("accountId, databaseId and apiToken are required (or devUrl for a local wrangler dev database)");
|
|
1104
1160
|
}
|
|
1105
1161
|
return { driver: "d1", accountId, databaseId, apiToken };
|
|
1106
1162
|
}
|
|
@@ -1448,8 +1504,8 @@ var DoDriver = class {
|
|
|
1448
1504
|
* like one and as a name otherwise.
|
|
1449
1505
|
*/
|
|
1450
1506
|
targetFor(database) {
|
|
1451
|
-
const
|
|
1452
|
-
if (
|
|
1507
|
+
const cached4 = this.targets.get(database);
|
|
1508
|
+
if (cached4) return cached4;
|
|
1453
1509
|
const slash = database.indexOf("/");
|
|
1454
1510
|
if (slash <= 0 || slash === database.length - 1) {
|
|
1455
1511
|
throw new DoError(
|
|
@@ -1463,8 +1519,8 @@ var DoDriver = class {
|
|
|
1463
1519
|
}
|
|
1464
1520
|
async tableColumns(database, target, table) {
|
|
1465
1521
|
const key = `${database} ${table}`;
|
|
1466
|
-
const
|
|
1467
|
-
if (
|
|
1522
|
+
const cached4 = this.columns.get(key);
|
|
1523
|
+
if (cached4 !== void 0) return cached4;
|
|
1468
1524
|
let out = null;
|
|
1469
1525
|
try {
|
|
1470
1526
|
const info = await this.transport.run(target, `PRAGMA table_info("${table.replaceAll('"', '""')}")`);
|
|
@@ -1551,8 +1607,8 @@ var DoDriver = class {
|
|
|
1551
1607
|
this.columns.clear();
|
|
1552
1608
|
}
|
|
1553
1609
|
};
|
|
1554
|
-
var
|
|
1555
|
-
function
|
|
1610
|
+
var LOOPBACK2 = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", "0.0.0.0"]);
|
|
1611
|
+
function parseDevUrl2(raw) {
|
|
1556
1612
|
let url;
|
|
1557
1613
|
try {
|
|
1558
1614
|
url = new URL(raw);
|
|
@@ -1562,7 +1618,7 @@ function parseDevUrl(raw) {
|
|
|
1562
1618
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1563
1619
|
throw new Error("devUrl must be an http(s) URL");
|
|
1564
1620
|
}
|
|
1565
|
-
if (!
|
|
1621
|
+
if (!LOOPBACK2.has(url.hostname.toLowerCase())) {
|
|
1566
1622
|
throw new Error(
|
|
1567
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."
|
|
1568
1624
|
);
|
|
@@ -1581,7 +1637,7 @@ var doDriverFactory = {
|
|
|
1581
1637
|
parseConfig(body) {
|
|
1582
1638
|
const devUrl = typeof body.devUrl === "string" ? body.devUrl.trim() : "";
|
|
1583
1639
|
if (!devUrl) throw new Error("devUrl is required");
|
|
1584
|
-
return { driver: "do", devUrl:
|
|
1640
|
+
return { driver: "do", devUrl: parseDevUrl2(devUrl) };
|
|
1585
1641
|
}
|
|
1586
1642
|
};
|
|
1587
1643
|
var doRemoteDriverFactory = {
|
|
@@ -1919,7 +1975,7 @@ function describeSshError(err, ssh) {
|
|
|
1919
1975
|
}
|
|
1920
1976
|
async function openSshTunnel(requested, target) {
|
|
1921
1977
|
const ssh = await resolveConfig(requested);
|
|
1922
|
-
const
|
|
1978
|
+
const client2 = new Client();
|
|
1923
1979
|
const connectConfig = toConnectConfig(ssh);
|
|
1924
1980
|
let verifyError;
|
|
1925
1981
|
connectConfig.hostVerifier = (key, verified) => {
|
|
@@ -1949,22 +2005,22 @@ async function openSshTunnel(requested, target) {
|
|
|
1949
2005
|
});
|
|
1950
2006
|
};
|
|
1951
2007
|
await new Promise((resolve5, reject) => {
|
|
1952
|
-
|
|
1953
|
-
|
|
2008
|
+
client2.once("ready", resolve5);
|
|
2009
|
+
client2.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
|
|
1954
2010
|
try {
|
|
1955
|
-
|
|
2011
|
+
client2.connect(connectConfig);
|
|
1956
2012
|
} catch (err) {
|
|
1957
2013
|
reject(describeSshError(err, ssh));
|
|
1958
2014
|
}
|
|
1959
2015
|
});
|
|
1960
|
-
|
|
2016
|
+
client2.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
|
|
1961
2017
|
const sockets = /* @__PURE__ */ new Set();
|
|
1962
2018
|
let forwardError;
|
|
1963
2019
|
const server = createServer((socket) => {
|
|
1964
2020
|
sockets.add(socket);
|
|
1965
2021
|
socket.once("close", () => sockets.delete(socket));
|
|
1966
2022
|
socket.on("error", () => socket.destroy());
|
|
1967
|
-
|
|
2023
|
+
client2.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
|
|
1968
2024
|
if (err) {
|
|
1969
2025
|
const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
|
|
1970
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?";
|
|
@@ -1983,7 +2039,7 @@ async function openSshTunnel(requested, target) {
|
|
|
1983
2039
|
server.once("error", reject);
|
|
1984
2040
|
server.listen(0, "127.0.0.1", () => resolve5());
|
|
1985
2041
|
}).catch((err) => {
|
|
1986
|
-
|
|
2042
|
+
client2.end();
|
|
1987
2043
|
throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
|
|
1988
2044
|
});
|
|
1989
2045
|
const { port } = server.address();
|
|
@@ -1993,11 +2049,11 @@ async function openSshTunnel(requested, target) {
|
|
|
1993
2049
|
closed = true;
|
|
1994
2050
|
for (const s of sockets) s.destroy();
|
|
1995
2051
|
server.close(() => {
|
|
1996
|
-
|
|
2052
|
+
client2.end();
|
|
1997
2053
|
resolve5();
|
|
1998
2054
|
});
|
|
1999
2055
|
});
|
|
2000
|
-
|
|
2056
|
+
client2.once("close", () => void close());
|
|
2001
2057
|
return {
|
|
2002
2058
|
host: "127.0.0.1",
|
|
2003
2059
|
port,
|
|
@@ -2022,7 +2078,9 @@ var PRO_FEATURES = [
|
|
|
2022
2078
|
"copy_table",
|
|
2023
2079
|
"federated",
|
|
2024
2080
|
"scratchpad",
|
|
2025
|
-
"column_link"
|
|
2081
|
+
"column_link",
|
|
2082
|
+
"sync",
|
|
2083
|
+
"ai_explain"
|
|
2026
2084
|
];
|
|
2027
2085
|
|
|
2028
2086
|
// src/usage.ts
|
|
@@ -2145,12 +2203,12 @@ function parseDriverConfig(body) {
|
|
|
2145
2203
|
const ssh = parseSshConfig(body.ssh);
|
|
2146
2204
|
return ssh ? { ...config, ssh } : config;
|
|
2147
2205
|
}
|
|
2148
|
-
async function withTunnel(config,
|
|
2149
|
-
if (isDirectDriver(config) || !config.ssh) return
|
|
2206
|
+
async function withTunnel(config, run2) {
|
|
2207
|
+
if (isDirectDriver(config) || !config.ssh) return run2(config, void 0);
|
|
2150
2208
|
const tunnel = await openSshTunnel(config.ssh, { host: config.host, port: config.port });
|
|
2151
2209
|
recordUsage("ssh_tunnel");
|
|
2152
2210
|
try {
|
|
2153
|
-
return await
|
|
2211
|
+
return await run2({ ...config, host: tunnel.host, port: tunnel.port, ssh: void 0 }, tunnel);
|
|
2154
2212
|
} catch (err) {
|
|
2155
2213
|
await tunnel.close();
|
|
2156
2214
|
throw tunnel.forwardError ? new TunnelError(tunnel.forwardError, 400) : err;
|
|
@@ -2272,7 +2330,7 @@ function describeProfile(entry) {
|
|
|
2272
2330
|
|
|
2273
2331
|
// src/index.ts
|
|
2274
2332
|
import { existsSync as existsSync4 } from "fs";
|
|
2275
|
-
import { join as
|
|
2333
|
+
import { join as join11, resolve as resolve4 } from "path";
|
|
2276
2334
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2277
2335
|
|
|
2278
2336
|
// src/install.ts
|
|
@@ -2492,10 +2550,610 @@ async function updateNotice(current) {
|
|
|
2492
2550
|
return isNewer(latest, current) ? `planche ${latest} is available (you have ${current}) \u2014 update with: planche upgrade` : null;
|
|
2493
2551
|
}
|
|
2494
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) {
|
|
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
|
+
});
|
|
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 session = null;
|
|
2688
|
+
var cached2;
|
|
2689
|
+
async function fileConfig() {
|
|
2690
|
+
if (cached2 !== void 0) return cached2;
|
|
2691
|
+
try {
|
|
2692
|
+
const raw = JSON.parse(await readFile4(AI_FILE, "utf8"));
|
|
2693
|
+
const provider = raw.provider === "openai" || raw.provider === "gemini" ? raw.provider : "anthropic";
|
|
2694
|
+
cached2 = typeof raw.key === "string" && raw.key ? {
|
|
2695
|
+
provider,
|
|
2696
|
+
model: typeof raw.model === "string" && raw.model ? raw.model : AI_DEFAULT_MODEL[provider],
|
|
2697
|
+
baseUrl: typeof raw.baseUrl === "string" && raw.baseUrl ? raw.baseUrl : void 0,
|
|
2698
|
+
key: raw.key
|
|
2699
|
+
} : null;
|
|
2700
|
+
} catch {
|
|
2701
|
+
cached2 = null;
|
|
2702
|
+
}
|
|
2703
|
+
return cached2;
|
|
2704
|
+
}
|
|
2705
|
+
async function aiConfig() {
|
|
2706
|
+
return envConfig() ?? session ?? await fileConfig();
|
|
2707
|
+
}
|
|
2708
|
+
async function aiConfigView() {
|
|
2709
|
+
const env = envConfig();
|
|
2710
|
+
const file = env ? null : await fileConfig();
|
|
2711
|
+
const config = env ?? session ?? file;
|
|
2712
|
+
const storage = env ? "environment" : session ? "session" : file ? "file" : "none";
|
|
2713
|
+
return {
|
|
2714
|
+
configured: config !== null,
|
|
2715
|
+
provider: config?.provider ?? "anthropic",
|
|
2716
|
+
model: config?.model ?? AI_DEFAULT_MODEL.anthropic,
|
|
2717
|
+
baseUrl: config?.baseUrl,
|
|
2718
|
+
fromEnvironment: env !== null,
|
|
2719
|
+
storage,
|
|
2720
|
+
path: AI_FILE,
|
|
2721
|
+
clis: await installedClis(),
|
|
2722
|
+
anthropicProfile: await anthropicProfileAvailable()
|
|
2723
|
+
};
|
|
2724
|
+
}
|
|
2725
|
+
function defaultModelFor(provider) {
|
|
2726
|
+
return AI_DEFAULT_MODEL[provider];
|
|
2727
|
+
}
|
|
2728
|
+
async function saveAiConfig(config, remember) {
|
|
2729
|
+
if (!remember) {
|
|
2730
|
+
session = config;
|
|
2731
|
+
await forgetStoredConfig();
|
|
2732
|
+
return false;
|
|
2733
|
+
}
|
|
2734
|
+
session = null;
|
|
2735
|
+
await mkdir3(PLANCHE_DIR4, { recursive: true });
|
|
2736
|
+
const temp = `${AI_FILE}.${process.pid}.tmp`;
|
|
2737
|
+
await writeFile2(temp, JSON.stringify(config, null, 2), { mode: 384 });
|
|
2738
|
+
await chmod(temp, 384);
|
|
2739
|
+
await rename2(temp, AI_FILE);
|
|
2740
|
+
cached2 = config;
|
|
2741
|
+
return true;
|
|
2742
|
+
}
|
|
2743
|
+
async function forgetStoredConfig() {
|
|
2744
|
+
cached2 = null;
|
|
2745
|
+
try {
|
|
2746
|
+
await unlink(AI_FILE);
|
|
2747
|
+
} catch {
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
async function forgetAiConfig() {
|
|
2751
|
+
session = null;
|
|
2752
|
+
await forgetStoredConfig();
|
|
2753
|
+
}
|
|
2754
|
+
var aiConfigPath = AI_FILE;
|
|
2755
|
+
|
|
2756
|
+
// src/ai-gemini.ts
|
|
2757
|
+
var DEFAULT_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
2758
|
+
function toContents(turns) {
|
|
2759
|
+
const out = [];
|
|
2760
|
+
for (const turn of turns) {
|
|
2761
|
+
if (turn.role === "user") {
|
|
2762
|
+
out.push({ role: "user", parts: [{ text: turn.content }] });
|
|
2763
|
+
} else if (turn.role === "assistant") {
|
|
2764
|
+
const parts = [];
|
|
2765
|
+
if (turn.content.trim()) parts.push({ text: turn.content });
|
|
2766
|
+
for (const call of turn.toolCalls ?? []) parts.push({ functionCall: { name: call.name, args: call.input } });
|
|
2767
|
+
if (parts.length > 0) out.push({ role: "model", parts });
|
|
2768
|
+
} else {
|
|
2769
|
+
const part = {
|
|
2770
|
+
functionResponse: { name: turn.toolCallId, response: { result: turn.content, ...turn.isError ? { error: true } : {} } }
|
|
2771
|
+
};
|
|
2772
|
+
const last = out[out.length - 1];
|
|
2773
|
+
if (last && last.role === "user" && last.parts.every((p) => p.functionResponse)) last.parts.push(part);
|
|
2774
|
+
else out.push({ role: "user", parts: [part] });
|
|
2775
|
+
}
|
|
2776
|
+
}
|
|
2777
|
+
return out;
|
|
2778
|
+
}
|
|
2779
|
+
async function runGemini(config, req) {
|
|
2780
|
+
const base = (config.baseUrl || DEFAULT_BASE).replace(/\/+$/, "");
|
|
2781
|
+
const url = `${base}/models/${encodeURIComponent(config.model)}:generateContent?key=${encodeURIComponent(config.key)}`;
|
|
2782
|
+
const tools2 = req.tools ?? [];
|
|
2783
|
+
let response;
|
|
2784
|
+
try {
|
|
2785
|
+
response = await fetch(url, {
|
|
2786
|
+
method: "POST",
|
|
2787
|
+
headers: { "content-type": "application/json" },
|
|
2788
|
+
body: JSON.stringify({
|
|
2789
|
+
systemInstruction: { parts: [{ text: req.system }] },
|
|
2790
|
+
contents: toContents(turnsOf(req)),
|
|
2791
|
+
...tools2.length > 0 ? {
|
|
2792
|
+
tools: [
|
|
2793
|
+
{
|
|
2794
|
+
functionDeclarations: tools2.map((tool) => ({
|
|
2795
|
+
name: tool.name,
|
|
2796
|
+
description: tool.description,
|
|
2797
|
+
parameters: tool.inputSchema
|
|
2798
|
+
}))
|
|
2799
|
+
}
|
|
2800
|
+
]
|
|
2801
|
+
} : {},
|
|
2802
|
+
generationConfig: {
|
|
2803
|
+
// A JSON mime type and function calling cannot both be asked for.
|
|
2804
|
+
...tools2.length > 0 ? {} : { responseMimeType: "application/json" },
|
|
2805
|
+
maxOutputTokens: MAX_TOKENS
|
|
2806
|
+
}
|
|
2807
|
+
})
|
|
2808
|
+
});
|
|
2809
|
+
} catch (err) {
|
|
2810
|
+
throw new AiError(502, err instanceof Error ? err.message : String(err));
|
|
2811
|
+
}
|
|
2812
|
+
const data = await response.json().catch(() => null);
|
|
2813
|
+
if (!response.ok) {
|
|
2814
|
+
const message = data?.error?.message ?? `${response.status} ${response.statusText}`;
|
|
2815
|
+
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
2816
|
+
throw new AiError(401, `The AI key was rejected: ${message}`);
|
|
2817
|
+
}
|
|
2818
|
+
if (response.status === 429) throw new AiError(429, "The provider is rate limiting this key.");
|
|
2819
|
+
throw new AiError(502, `The provider answered ${response.status}: ${message}`);
|
|
2820
|
+
}
|
|
2821
|
+
const candidate = data?.candidates?.[0];
|
|
2822
|
+
const parts = candidate?.content?.parts ?? [];
|
|
2823
|
+
const text = parts.map((p) => p.text ?? "").join("");
|
|
2824
|
+
const toolCalls = parts.filter((p) => typeof p.functionCall?.name === "string").map((p) => ({ id: p.functionCall.name, name: p.functionCall.name, input: p.functionCall.args ?? {} }));
|
|
2825
|
+
if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
|
|
2826
|
+
return {
|
|
2827
|
+
text,
|
|
2828
|
+
toolCalls,
|
|
2829
|
+
stop: toolCalls.length > 0 ? "tool_use" : candidate?.finishReason === "MAX_TOKENS" ? "length" : "end"
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
// src/ai-openai.ts
|
|
2834
|
+
import OpenAI from "openai";
|
|
2835
|
+
function toMessages2(system, turns) {
|
|
2836
|
+
const out = [{ role: "system", content: system }];
|
|
2837
|
+
for (const turn of turns) {
|
|
2838
|
+
if (turn.role === "user") {
|
|
2839
|
+
out.push({ role: "user", content: turn.content });
|
|
2840
|
+
} else if (turn.role === "assistant") {
|
|
2841
|
+
const calls = turn.toolCalls ?? [];
|
|
2842
|
+
out.push({
|
|
2843
|
+
role: "assistant",
|
|
2844
|
+
content: turn.content || null,
|
|
2845
|
+
...calls.length > 0 ? {
|
|
2846
|
+
tool_calls: calls.map((call) => ({
|
|
2847
|
+
id: call.id,
|
|
2848
|
+
type: "function",
|
|
2849
|
+
function: { name: call.name, arguments: JSON.stringify(call.input) }
|
|
2850
|
+
}))
|
|
2851
|
+
} : {}
|
|
2852
|
+
});
|
|
2853
|
+
} else {
|
|
2854
|
+
out.push({ role: "tool", tool_call_id: turn.toolCallId, content: turn.content });
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
return out;
|
|
2858
|
+
}
|
|
2859
|
+
function parseArguments(raw) {
|
|
2860
|
+
try {
|
|
2861
|
+
const parsed = JSON.parse(raw || "{}");
|
|
2862
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2863
|
+
} catch {
|
|
2864
|
+
return {};
|
|
2865
|
+
}
|
|
2866
|
+
}
|
|
2867
|
+
async function runOpenAI(config, req) {
|
|
2868
|
+
const client2 = new OpenAI({ apiKey: config.key, ...config.baseUrl ? { baseURL: config.baseUrl } : {} });
|
|
2869
|
+
const tools2 = (req.tools ?? []).map((tool) => ({
|
|
2870
|
+
type: "function",
|
|
2871
|
+
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema }
|
|
2872
|
+
}));
|
|
2873
|
+
try {
|
|
2874
|
+
const response = await client2.chat.completions.create({
|
|
2875
|
+
model: config.model,
|
|
2876
|
+
max_completion_tokens: MAX_TOKENS,
|
|
2877
|
+
// JSON mode and tools are mutually exclusive here: forcing a JSON object
|
|
2878
|
+
// leaves the model no way to answer with a call. The one-shot prompts
|
|
2879
|
+
// ask for JSON, a tool run does not.
|
|
2880
|
+
...tools2.length > 0 ? { tools: tools2 } : { response_format: { type: "json_object" } },
|
|
2881
|
+
messages: toMessages2(req.system, turnsOf(req))
|
|
2882
|
+
});
|
|
2883
|
+
const choice = response.choices[0];
|
|
2884
|
+
const text = choice?.message?.content ?? "";
|
|
2885
|
+
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) }));
|
|
2886
|
+
if (!text && toolCalls.length === 0) throw new AiError(502, "The model returned nothing.");
|
|
2887
|
+
return {
|
|
2888
|
+
text,
|
|
2889
|
+
toolCalls,
|
|
2890
|
+
stop: choice?.finish_reason === "tool_calls" ? "tool_use" : choice?.finish_reason === "length" ? "length" : "end"
|
|
2891
|
+
};
|
|
2892
|
+
} catch (err) {
|
|
2893
|
+
if (err instanceof AiError) throw err;
|
|
2894
|
+
if (err instanceof OpenAI.APIError) {
|
|
2895
|
+
const status = err.status === 401 || err.status === 429 || err.status === 400 ? err.status : 502;
|
|
2896
|
+
throw new AiError(status, err.status === 401 ? "The AI key was rejected." : err.message);
|
|
2897
|
+
}
|
|
2898
|
+
throw new AiError(502, err instanceof Error ? err.message : String(err));
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// src/ai-run.ts
|
|
2903
|
+
var AiError = class extends Error {
|
|
2904
|
+
constructor(status, message) {
|
|
2905
|
+
super(message);
|
|
2906
|
+
this.status = status;
|
|
2907
|
+
this.name = "AiError";
|
|
2908
|
+
}
|
|
2909
|
+
status;
|
|
2910
|
+
};
|
|
2911
|
+
var MAX_TOKENS = 8e3;
|
|
2912
|
+
function turnsOf(req) {
|
|
2913
|
+
if (req.messages && req.messages.length > 0) return req.messages;
|
|
2914
|
+
return [{ role: "user", content: req.user ?? "" }];
|
|
2915
|
+
}
|
|
2916
|
+
async function runPrompt(req) {
|
|
2917
|
+
const config = await aiConfig();
|
|
2918
|
+
if (!config) throw new AiError(501, "No AI key is configured on this agent.");
|
|
2919
|
+
const model = req.model?.trim() || config.model;
|
|
2920
|
+
const run2 = config.provider === "cli" ? runCli : config.provider === "openai" ? runOpenAI : config.provider === "gemini" ? runGemini : runAnthropic;
|
|
2921
|
+
const result = await run2({ ...config, model }, req);
|
|
2922
|
+
return {
|
|
2923
|
+
success: true,
|
|
2924
|
+
text: result.text,
|
|
2925
|
+
model,
|
|
2926
|
+
...result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {},
|
|
2927
|
+
stop: result.stop
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// src/ai-cli.ts
|
|
2932
|
+
var execFile2 = promisify2(execFileCb2);
|
|
2933
|
+
var CLI_COMMANDS = ["claude", "codex"];
|
|
2934
|
+
var TIMEOUT_MS = 18e4;
|
|
2935
|
+
var mcp = null;
|
|
2936
|
+
function configureCliBridge(url, token) {
|
|
2937
|
+
mcp = { url, token };
|
|
2938
|
+
}
|
|
2939
|
+
function mcpUrl() {
|
|
2940
|
+
return mcp?.url ?? null;
|
|
2941
|
+
}
|
|
2942
|
+
function isCliCommand(name) {
|
|
2943
|
+
return CLI_COMMANDS.includes(name);
|
|
2944
|
+
}
|
|
2945
|
+
async function installedClis() {
|
|
2946
|
+
const found = [];
|
|
2947
|
+
for (const name of CLI_COMMANDS) {
|
|
2948
|
+
try {
|
|
2949
|
+
await execFile2(name, ["--version"], { timeout: 1e4 });
|
|
2950
|
+
found.push(name);
|
|
2951
|
+
} catch {
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
return found;
|
|
2955
|
+
}
|
|
2956
|
+
function run(command, args, prompt) {
|
|
2957
|
+
return new Promise((resolve5, reject) => {
|
|
2958
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
2959
|
+
let out = "";
|
|
2960
|
+
let err = "";
|
|
2961
|
+
const timer2 = setTimeout(() => {
|
|
2962
|
+
child.kill("SIGKILL");
|
|
2963
|
+
reject(new AiError(504, `${command} did not answer within ${TIMEOUT_MS / 1e3}s.`));
|
|
2964
|
+
}, TIMEOUT_MS);
|
|
2965
|
+
child.stdout.on("data", (d) => out += d);
|
|
2966
|
+
child.stderr.on("data", (d) => err += d);
|
|
2967
|
+
child.on("error", (e) => {
|
|
2968
|
+
clearTimeout(timer2);
|
|
2969
|
+
reject(new AiError(501, `${command} could not be started: ${e.message}`));
|
|
2970
|
+
});
|
|
2971
|
+
child.on("close", (code) => {
|
|
2972
|
+
clearTimeout(timer2);
|
|
2973
|
+
if (code === 0) resolve5(out);
|
|
2974
|
+
else reject(new AiError(502, `${command} exited ${code}: ${err.trim().slice(0, 400) || "no output"}`));
|
|
2975
|
+
});
|
|
2976
|
+
child.stdin.end(prompt);
|
|
2977
|
+
});
|
|
2978
|
+
}
|
|
2979
|
+
function unwrap(text) {
|
|
2980
|
+
const fenced = text.trim().match(/^```(?:json)?\s*\n([\s\S]*?)\n```$/);
|
|
2981
|
+
return (fenced ? fenced[1] : text).trim();
|
|
2982
|
+
}
|
|
2983
|
+
async function runCli(config, req) {
|
|
2984
|
+
const command = config.model.trim();
|
|
2985
|
+
const wantsTools = (req.tools?.length ?? 0) > 0;
|
|
2986
|
+
if (wantsTools && command !== "claude") {
|
|
2987
|
+
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.`);
|
|
2988
|
+
}
|
|
2989
|
+
if (wantsTools && !mcp) {
|
|
2990
|
+
throw new AiError(501, "This agent cannot offer its tools to a CLI. Restart it, or use a provider with an API key.");
|
|
2991
|
+
}
|
|
2992
|
+
if (!isCliCommand(command)) {
|
|
2993
|
+
throw new AiError(400, `Unknown CLI "${command}". Use one of: ${CLI_COMMANDS.join(", ")}.`);
|
|
2994
|
+
}
|
|
2995
|
+
const turns = turnsOf(req);
|
|
2996
|
+
const prompt = [req.system, "", turns.map((t) => "content" in t ? t.content : "").join("\n\n")].join("\n");
|
|
2997
|
+
let text;
|
|
2998
|
+
if (command === "claude") {
|
|
2999
|
+
if (!wantsTools) {
|
|
3000
|
+
text = await run("claude", ["-p"], prompt);
|
|
3001
|
+
} else {
|
|
3002
|
+
const dir = await mkdtemp(join8(tmpdir(), "planche-mcp-"));
|
|
3003
|
+
const file = join8(dir, "mcp.json");
|
|
3004
|
+
try {
|
|
3005
|
+
await writeFile3(
|
|
3006
|
+
file,
|
|
3007
|
+
JSON.stringify({
|
|
3008
|
+
mcpServers: {
|
|
3009
|
+
planche: { type: "http", url: mcp.url, headers: { Authorization: `Bearer ${mcp.token}` } }
|
|
3010
|
+
}
|
|
3011
|
+
}),
|
|
3012
|
+
{ mode: 384 }
|
|
3013
|
+
);
|
|
3014
|
+
text = await run("claude", ["-p", "--strict-mcp-config", "--mcp-config", file], prompt);
|
|
3015
|
+
} finally {
|
|
3016
|
+
await rm(dir, { recursive: true, force: true });
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
} else {
|
|
3020
|
+
const dir = await mkdtemp(join8(tmpdir(), "planche-cli-"));
|
|
3021
|
+
const file = join8(dir, "answer.txt");
|
|
3022
|
+
try {
|
|
3023
|
+
await run("codex", ["exec", "--skip-git-repo-check", "-o", file, "-"], prompt);
|
|
3024
|
+
text = await readFile5(file, "utf8");
|
|
3025
|
+
} finally {
|
|
3026
|
+
await rm(dir, { recursive: true, force: true });
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
const answer = unwrap(text);
|
|
3030
|
+
if (!answer) throw new AiError(502, `${command} returned nothing.`);
|
|
3031
|
+
return { text: answer, toolCalls: [], stop: "end" };
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
// src/mcp-token.ts
|
|
3035
|
+
import { chmod as chmod2, mkdir as mkdir4, readFile as readFile6, rm as rm2, writeFile as writeFile4 } from "fs/promises";
|
|
3036
|
+
import { randomBytes } from "crypto";
|
|
3037
|
+
import { homedir as homedir8 } from "os";
|
|
3038
|
+
import { join as join9 } from "path";
|
|
3039
|
+
var DIR = join9(homedir8(), ".planche");
|
|
3040
|
+
var FILE = join9(DIR, "mcp-token");
|
|
3041
|
+
var mcpTokenPath = FILE;
|
|
3042
|
+
var cached3 = null;
|
|
3043
|
+
async function readMcpToken() {
|
|
3044
|
+
if (cached3) return cached3;
|
|
3045
|
+
try {
|
|
3046
|
+
const text = (await readFile6(FILE, "utf8")).trim();
|
|
3047
|
+
cached3 = text || null;
|
|
3048
|
+
} catch {
|
|
3049
|
+
cached3 = null;
|
|
3050
|
+
}
|
|
3051
|
+
return cached3;
|
|
3052
|
+
}
|
|
3053
|
+
async function ensureMcpToken() {
|
|
3054
|
+
const existing = await readMcpToken();
|
|
3055
|
+
if (existing) return existing;
|
|
3056
|
+
const token = randomBytes(32).toString("base64url");
|
|
3057
|
+
await mkdir4(DIR, { recursive: true, mode: 448 });
|
|
3058
|
+
await writeFile4(FILE, `${token}
|
|
3059
|
+
`, { mode: 384 });
|
|
3060
|
+
await chmod2(FILE, 384);
|
|
3061
|
+
cached3 = token;
|
|
3062
|
+
return token;
|
|
3063
|
+
}
|
|
3064
|
+
async function forgetMcpToken() {
|
|
3065
|
+
cached3 = null;
|
|
3066
|
+
await rm2(FILE, { force: true });
|
|
3067
|
+
}
|
|
3068
|
+
|
|
2495
3069
|
// src/server.ts
|
|
2496
3070
|
import express from "express";
|
|
2497
3071
|
import { createHash as createHash2, randomUUID as randomUUID2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
2498
3072
|
|
|
3073
|
+
// src/bridge.ts
|
|
3074
|
+
var CALL_TIMEOUT_MS = 12e4;
|
|
3075
|
+
var client = null;
|
|
3076
|
+
var tools = [];
|
|
3077
|
+
var pending = /* @__PURE__ */ new Map();
|
|
3078
|
+
var nextId = 0;
|
|
3079
|
+
function bridgeTools() {
|
|
3080
|
+
return tools;
|
|
3081
|
+
}
|
|
3082
|
+
function attachBridge(res) {
|
|
3083
|
+
client?.end();
|
|
3084
|
+
client = res;
|
|
3085
|
+
res.on("close", () => {
|
|
3086
|
+
if (client !== res) return;
|
|
3087
|
+
client = null;
|
|
3088
|
+
tools = [];
|
|
3089
|
+
});
|
|
3090
|
+
}
|
|
3091
|
+
function setBridgeTools(next) {
|
|
3092
|
+
tools = next;
|
|
3093
|
+
}
|
|
3094
|
+
function callThroughBridge(name, input) {
|
|
3095
|
+
const target = client;
|
|
3096
|
+
if (!target) {
|
|
3097
|
+
return Promise.resolve({ content: "Planche is not open in a browser, so its tools cannot run.", isError: true });
|
|
3098
|
+
}
|
|
3099
|
+
const id = `call-${++nextId}`;
|
|
3100
|
+
return new Promise((resolve5) => {
|
|
3101
|
+
const timer2 = setTimeout(() => {
|
|
3102
|
+
pending.delete(id);
|
|
3103
|
+
resolve5({ content: `The app did not answer "${name}" within ${CALL_TIMEOUT_MS / 1e3}s.`, isError: true });
|
|
3104
|
+
}, CALL_TIMEOUT_MS);
|
|
3105
|
+
pending.set(id, { resolve: resolve5, timer: timer2 });
|
|
3106
|
+
target.write(`data: ${JSON.stringify({ id, name, input })}
|
|
3107
|
+
|
|
3108
|
+
`);
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
function resolveBridgeCall(id, content, isError) {
|
|
3112
|
+
const waiting = pending.get(id);
|
|
3113
|
+
if (!waiting) return;
|
|
3114
|
+
pending.delete(id);
|
|
3115
|
+
clearTimeout(waiting.timer);
|
|
3116
|
+
waiting.resolve({ content, isError });
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
// src/mcp.ts
|
|
3120
|
+
var FALLBACK_PROTOCOL = "2025-06-18";
|
|
3121
|
+
async function handleMcp(req, res) {
|
|
3122
|
+
const msg = req.body ?? {};
|
|
3123
|
+
const reply = (result) => res.json({ jsonrpc: "2.0", id: msg.id, result });
|
|
3124
|
+
switch (msg.method) {
|
|
3125
|
+
case "initialize":
|
|
3126
|
+
reply({
|
|
3127
|
+
protocolVersion: typeof msg.params?.protocolVersion === "string" ? msg.params.protocolVersion : FALLBACK_PROTOCOL,
|
|
3128
|
+
capabilities: { tools: {} },
|
|
3129
|
+
serverInfo: { name: "planche", version: "1" }
|
|
3130
|
+
});
|
|
3131
|
+
return;
|
|
3132
|
+
case "tools/list":
|
|
3133
|
+
reply({
|
|
3134
|
+
tools: bridgeTools().map((tool) => ({
|
|
3135
|
+
name: tool.name,
|
|
3136
|
+
description: tool.description,
|
|
3137
|
+
inputSchema: tool.inputSchema
|
|
3138
|
+
}))
|
|
3139
|
+
});
|
|
3140
|
+
return;
|
|
3141
|
+
case "tools/call": {
|
|
3142
|
+
const name = typeof msg.params?.name === "string" ? msg.params.name : "";
|
|
3143
|
+
const args = msg.params?.arguments ?? {};
|
|
3144
|
+
const { content, isError } = await callThroughBridge(name, args);
|
|
3145
|
+
reply({ content: [{ type: "text", text: content }], isError });
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
default:
|
|
3149
|
+
if (msg.id === void 0) {
|
|
3150
|
+
res.status(202).end();
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
reply({});
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
|
|
2499
3157
|
// src/tunneling.ts
|
|
2500
3158
|
import { randomUUID } from "crypto";
|
|
2501
3159
|
|
|
@@ -2786,7 +3444,7 @@ var ConnectionManager = class {
|
|
|
2786
3444
|
|
|
2787
3445
|
// src/server.ts
|
|
2788
3446
|
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
2789
|
-
import { join as
|
|
3447
|
+
import { join as join10 } from "path";
|
|
2790
3448
|
function errorStatus(err) {
|
|
2791
3449
|
const status = err?.status;
|
|
2792
3450
|
return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
|
|
@@ -2852,7 +3510,7 @@ function accessControl(allowed) {
|
|
|
2852
3510
|
}
|
|
2853
3511
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
2854
3512
|
res.setHeader("Vary", "Origin");
|
|
2855
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
3513
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
2856
3514
|
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
2857
3515
|
res.setHeader("Access-Control-Max-Age", "600");
|
|
2858
3516
|
if (req.headers["access-control-request-private-network"] === "true") {
|
|
@@ -2924,7 +3582,7 @@ function createAgent(options) {
|
|
|
2924
3582
|
options.onUnpaired(pairing, origin);
|
|
2925
3583
|
return true;
|
|
2926
3584
|
}
|
|
2927
|
-
app.get("/health", (req, res) => {
|
|
3585
|
+
app.get("/health", async (req, res) => {
|
|
2928
3586
|
const body = {
|
|
2929
3587
|
success: true,
|
|
2930
3588
|
name: "planche",
|
|
@@ -2933,6 +3591,8 @@ function createAgent(options) {
|
|
|
2933
3591
|
activeConnections: manager.activeConnections,
|
|
2934
3592
|
uptime: process.uptime()
|
|
2935
3593
|
};
|
|
3594
|
+
const ai = await aiConfigView();
|
|
3595
|
+
body.ai = { configured: ai.configured, model: ai.model, tools: true };
|
|
2936
3596
|
if (options.install) body.install = options.install;
|
|
2937
3597
|
if (options.gateway) body.gateway = { name: options.gateway.name, profiles: options.gateway.profiles.length };
|
|
2938
3598
|
if (req.headers.authorization !== void 0) {
|
|
@@ -2957,16 +3617,29 @@ function createAgent(options) {
|
|
|
2957
3617
|
const body = { success: true, token: options.authToken };
|
|
2958
3618
|
res.json(body);
|
|
2959
3619
|
});
|
|
3620
|
+
async function mcpAuthorized(req) {
|
|
3621
|
+
if (req.path !== "/mcp") return false;
|
|
3622
|
+
const given = bearerOf(req.headers.authorization);
|
|
3623
|
+
const expected = await readMcpToken();
|
|
3624
|
+
if (!given || !expected) return false;
|
|
3625
|
+
return secretEquals(given, expected);
|
|
3626
|
+
}
|
|
2960
3627
|
app.use((req, res, next) => {
|
|
2961
3628
|
if (throttle.locked(peerOf(req))) {
|
|
2962
3629
|
fail(res, 429, "Too many failed authentication attempts; try again shortly");
|
|
2963
3630
|
return;
|
|
2964
3631
|
}
|
|
2965
|
-
if (
|
|
2966
|
-
|
|
3632
|
+
if (authorized(req)) {
|
|
3633
|
+
next();
|
|
2967
3634
|
return;
|
|
2968
3635
|
}
|
|
2969
|
-
|
|
3636
|
+
void mcpAuthorized(req).then((ok) => {
|
|
3637
|
+
if (ok) {
|
|
3638
|
+
next();
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
fail(res, 401, "Unauthorized: invalid or missing auth token");
|
|
3642
|
+
});
|
|
2970
3643
|
});
|
|
2971
3644
|
app.use(express.json({ limit: options.bodyLimit }));
|
|
2972
3645
|
app.get("/profiles", (_req, res) => {
|
|
@@ -3040,6 +3713,114 @@ function createAgent(options) {
|
|
|
3040
3713
|
await flushUsage();
|
|
3041
3714
|
res.json(await usageSnapshot());
|
|
3042
3715
|
});
|
|
3716
|
+
app.get("/ai/config", async (_req, res) => {
|
|
3717
|
+
res.json({ success: true, ...await aiConfigView() });
|
|
3718
|
+
});
|
|
3719
|
+
app.put("/ai/config", async (req, res) => {
|
|
3720
|
+
const current = await aiConfigView();
|
|
3721
|
+
if (current.fromEnvironment) {
|
|
3722
|
+
fail(res, 409, "This agent's AI settings come from its environment and cannot be changed from the app.");
|
|
3723
|
+
return;
|
|
3724
|
+
}
|
|
3725
|
+
const body = req.body ?? {};
|
|
3726
|
+
const provider = body.provider === "openai" || body.provider === "gemini" || body.provider === "cli" || body.provider === "anthropic-profile" ? body.provider : "anthropic";
|
|
3727
|
+
const keyless = provider === "cli" || provider === "anthropic-profile";
|
|
3728
|
+
const key = typeof body.key === "string" ? body.key.trim() : "";
|
|
3729
|
+
if (!key && !keyless) {
|
|
3730
|
+
fail(res, 400, "key is required");
|
|
3731
|
+
return;
|
|
3732
|
+
}
|
|
3733
|
+
const model = typeof body.model === "string" && body.model.trim() ? body.model.trim() : defaultModelFor(provider);
|
|
3734
|
+
if (provider === "cli" && !isCliCommand(model)) {
|
|
3735
|
+
fail(res, 400, `model must name an installed CLI: ${CLI_COMMANDS.join(", ")}`);
|
|
3736
|
+
return;
|
|
3737
|
+
}
|
|
3738
|
+
const baseUrl = typeof body.baseUrl === "string" ? body.baseUrl.trim() : "";
|
|
3739
|
+
if (baseUrl && !/^https?:\/\//i.test(baseUrl)) {
|
|
3740
|
+
fail(res, 400, "baseUrl must be an http(s) URL");
|
|
3741
|
+
return;
|
|
3742
|
+
}
|
|
3743
|
+
const written = await saveAiConfig({
|
|
3744
|
+
provider,
|
|
3745
|
+
model,
|
|
3746
|
+
baseUrl: baseUrl || void 0,
|
|
3747
|
+
key
|
|
3748
|
+
}, body.remember === true);
|
|
3749
|
+
console.log(
|
|
3750
|
+
provider === "cli" ? `[agent] AI answers will come from the local "${model}" CLI; no key is held.` : provider === "anthropic-profile" ? "[agent] AI answers will use this machine's own Anthropic credential; no key is held." : 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."
|
|
3751
|
+
);
|
|
3752
|
+
res.json({ success: true, ...await aiConfigView() });
|
|
3753
|
+
});
|
|
3754
|
+
app.delete("/ai/config", async (_req, res) => {
|
|
3755
|
+
await forgetAiConfig();
|
|
3756
|
+
res.json({ success: true, ...await aiConfigView() });
|
|
3757
|
+
});
|
|
3758
|
+
app.post("/ai/run", async (req, res) => {
|
|
3759
|
+
const body = req.body ?? {};
|
|
3760
|
+
const hasTurns = Array.isArray(body.messages) && body.messages.length > 0;
|
|
3761
|
+
const hasUser = typeof body.user === "string" && body.user.trim() !== "";
|
|
3762
|
+
if (typeof body.system !== "string" || !hasTurns && !hasUser) {
|
|
3763
|
+
fail(res, 400, "system and one of user/messages are required");
|
|
3764
|
+
return;
|
|
3765
|
+
}
|
|
3766
|
+
try {
|
|
3767
|
+
res.json(await runPrompt({
|
|
3768
|
+
system: body.system,
|
|
3769
|
+
...hasUser ? { user: body.user } : {},
|
|
3770
|
+
...hasTurns ? { messages: body.messages } : {},
|
|
3771
|
+
...Array.isArray(body.tools) && body.tools.length > 0 ? { tools: body.tools } : {},
|
|
3772
|
+
model: typeof body.model === "string" ? body.model : void 0
|
|
3773
|
+
}));
|
|
3774
|
+
} catch (err) {
|
|
3775
|
+
const e = err instanceof AiError ? err : new AiError(502, String(err));
|
|
3776
|
+
fail(res, e.status, e.message);
|
|
3777
|
+
}
|
|
3778
|
+
});
|
|
3779
|
+
app.post("/mcp", async (req, res) => {
|
|
3780
|
+
await handleMcp(req, res);
|
|
3781
|
+
});
|
|
3782
|
+
app.get("/bridge/events", (req, res) => {
|
|
3783
|
+
res.writeHead(200, {
|
|
3784
|
+
"content-type": "text/event-stream",
|
|
3785
|
+
"cache-control": "no-store",
|
|
3786
|
+
connection: "keep-alive"
|
|
3787
|
+
});
|
|
3788
|
+
res.write(": open\n\n");
|
|
3789
|
+
attachBridge(res);
|
|
3790
|
+
});
|
|
3791
|
+
app.post("/bridge/tools", (req, res) => {
|
|
3792
|
+
const tools2 = req.body?.tools;
|
|
3793
|
+
if (!Array.isArray(tools2)) {
|
|
3794
|
+
fail(res, 400, "tools must be an array");
|
|
3795
|
+
return;
|
|
3796
|
+
}
|
|
3797
|
+
setBridgeTools(tools2);
|
|
3798
|
+
res.json({ success: true });
|
|
3799
|
+
});
|
|
3800
|
+
app.post("/bridge/result", (req, res) => {
|
|
3801
|
+
const { id, content, isError } = req.body ?? {};
|
|
3802
|
+
if (typeof id !== "string") {
|
|
3803
|
+
fail(res, 400, "id is required");
|
|
3804
|
+
return;
|
|
3805
|
+
}
|
|
3806
|
+
resolveBridgeCall(id, typeof content === "string" ? content : "", isError === true);
|
|
3807
|
+
res.json({ success: true });
|
|
3808
|
+
});
|
|
3809
|
+
app.post("/mcp/setup", async (_req, res) => {
|
|
3810
|
+
const url = mcpUrl();
|
|
3811
|
+
if (!url) {
|
|
3812
|
+
fail(res, 503, "The agent is not listening yet.");
|
|
3813
|
+
return;
|
|
3814
|
+
}
|
|
3815
|
+
const body = { success: true, url, token: await ensureMcpToken(), path: mcpTokenPath };
|
|
3816
|
+
console.log(`[agent] MCP token handed to the app; stored at ${mcpTokenPath} (owner-only).`);
|
|
3817
|
+
res.json(body);
|
|
3818
|
+
});
|
|
3819
|
+
app.delete("/mcp/setup", async (_req, res) => {
|
|
3820
|
+
await forgetMcpToken();
|
|
3821
|
+
console.log(`[agent] MCP token revoked; ${mcpTokenPath} removed.`);
|
|
3822
|
+
res.json({ success: true });
|
|
3823
|
+
});
|
|
3043
3824
|
app.post("/cancel", async (req, res) => {
|
|
3044
3825
|
try {
|
|
3045
3826
|
const connectionId = req.body?.connectionId;
|
|
@@ -3113,7 +3894,7 @@ function serveApp(app, appDir) {
|
|
|
3113
3894
|
"form-action 'none'",
|
|
3114
3895
|
"frame-ancestors 'none'"
|
|
3115
3896
|
].join("; ");
|
|
3116
|
-
const indexFile =
|
|
3897
|
+
const indexFile = join10(appDir, "index.html");
|
|
3117
3898
|
const indexHtml = existsSync3(indexFile) ? readFileSync3(indexFile, "utf8").replace("<head>", '<head>\n <meta name="planche-agent" content="same-origin" />') : null;
|
|
3118
3899
|
const headers = (res) => {
|
|
3119
3900
|
res.setHeader("Content-Security-Policy", csp);
|
|
@@ -3149,7 +3930,7 @@ var DEV_ORIGINS = [
|
|
|
3149
3930
|
];
|
|
3150
3931
|
function bundledAppDir() {
|
|
3151
3932
|
const dir = fileURLToPath2(new URL("../app/", import.meta.url));
|
|
3152
|
-
return existsSync4(
|
|
3933
|
+
return existsSync4(join11(dir, "index.html")) ? dir : null;
|
|
3153
3934
|
}
|
|
3154
3935
|
function isLoopbackUrl(url) {
|
|
3155
3936
|
try {
|
|
@@ -3172,7 +3953,7 @@ async function appIsReachable(url, timeoutMs = 2500) {
|
|
|
3172
3953
|
}
|
|
3173
3954
|
function resolveAppDir(explicit) {
|
|
3174
3955
|
const dir = explicit ? resolve4(explicit) : fileURLToPath2(new URL("../app/", import.meta.url));
|
|
3175
|
-
if (!existsSync4(
|
|
3956
|
+
if (!existsSync4(join11(dir, "index.html"))) {
|
|
3176
3957
|
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>.`);
|
|
3177
3958
|
process.exit(2);
|
|
3178
3959
|
}
|
|
@@ -3184,6 +3965,7 @@ function usage() {
|
|
|
3184
3965
|
Usage: planche [options]
|
|
3185
3966
|
planche upgrade [version] Update a standalone install in place
|
|
3186
3967
|
planche uninstall [--yes] Remove it again (settings in ~/.planche are kept)
|
|
3968
|
+
planche mcp [setup|forget] Print the MCP client configuration, or revoke its token
|
|
3187
3969
|
|
|
3188
3970
|
Options:
|
|
3189
3971
|
-p, --port <n> Port to listen on (default: 8888, env PLANCHE_PORT)
|
|
@@ -3226,10 +4008,36 @@ function openInBrowser(url) {
|
|
|
3226
4008
|
try {
|
|
3227
4009
|
const env = { ...process.env };
|
|
3228
4010
|
delete env.PLANCHE_AUTH_TOKEN;
|
|
3229
|
-
|
|
4011
|
+
spawn2(cmd, args, { stdio: "ignore", detached: true, env }).on("error", () => void 0).unref();
|
|
3230
4012
|
} catch {
|
|
3231
4013
|
}
|
|
3232
4014
|
}
|
|
4015
|
+
async function runMcpCommand(action, port) {
|
|
4016
|
+
if (action === "forget") {
|
|
4017
|
+
await forgetMcpToken();
|
|
4018
|
+
console.log(`Removed ${mcpTokenPath}. Any client configured with it now gets 401.`);
|
|
4019
|
+
return 0;
|
|
4020
|
+
}
|
|
4021
|
+
if (action !== void 0 && action !== "setup") {
|
|
4022
|
+
console.error("Usage: planche mcp [setup|forget]");
|
|
4023
|
+
return 1;
|
|
4024
|
+
}
|
|
4025
|
+
const token = await ensureMcpToken();
|
|
4026
|
+
const url = `http://127.0.0.1:${port}/mcp`;
|
|
4027
|
+
console.log("Add Planche to an agent that speaks MCP over HTTP:\n");
|
|
4028
|
+
console.log(` claude mcp add --transport http planche ${url} \\
|
|
4029
|
+
-H "Authorization: Bearer ${token}"
|
|
4030
|
+
`);
|
|
4031
|
+
console.log("Or as JSON:\n");
|
|
4032
|
+
console.log(` ${JSON.stringify({ mcpServers: { planche: { type: "http", url, headers: { Authorization: `Bearer ${token}` } } } })}
|
|
4033
|
+
`);
|
|
4034
|
+
console.log(`The token is stored at ${mcpTokenPath} (owner-only) and survives restarts, unlike the`);
|
|
4035
|
+
console.log("agent's own token. It reaches this one route and nothing else: calls run inside the");
|
|
4036
|
+
console.log("open browser tab, under its guard rails, and every write stops there for approval.");
|
|
4037
|
+
console.log("Planche must be open with the AI panel showing, or there are no tools to call.");
|
|
4038
|
+
console.log(`Revoke it with "planche mcp forget".`);
|
|
4039
|
+
return 0;
|
|
4040
|
+
}
|
|
3233
4041
|
async function main() {
|
|
3234
4042
|
const [subcommand] = process.argv.slice(2);
|
|
3235
4043
|
if (subcommand === "upgrade") {
|
|
@@ -3238,6 +4046,9 @@ async function main() {
|
|
|
3238
4046
|
if (subcommand === "uninstall") {
|
|
3239
4047
|
process.exit(await runUninstall(process.argv.includes("--yes") || process.argv.includes("-y")));
|
|
3240
4048
|
}
|
|
4049
|
+
if (subcommand === "mcp") {
|
|
4050
|
+
process.exit(await runMcpCommand(process.argv[3], Number(process.env.PLANCHE_PORT ?? 8888)));
|
|
4051
|
+
}
|
|
3241
4052
|
const major = Number(process.versions.node.split(".")[0]);
|
|
3242
4053
|
if (major < 22) {
|
|
3243
4054
|
console.error(`planche needs Node.js 22 or newer (you are running ${process.version}).`);
|
|
@@ -3374,6 +4185,7 @@ async function main() {
|
|
|
3374
4185
|
console.log(`${DEFAULT_APP_URL === hostedAppUrl ? "planche.dev" : hostedAppUrl} is unreachable \u2014 serving the app bundled with this agent (v${VERSION}) instead.`);
|
|
3375
4186
|
}
|
|
3376
4187
|
const server = app.listen(port, listenAddr, () => {
|
|
4188
|
+
configureCliBridge(`http://127.0.0.1:${port}/mcp`, authToken);
|
|
3377
4189
|
console.log(divider);
|
|
3378
4190
|
console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
|
|
3379
4191
|
console.log(divider);
|