planche 0.1.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/README.md +130 -0
- package/dist/index.js +882 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# planche
|
|
2
|
+
|
|
3
|
+
Local agent for [Planche](https://planche.dev), a browser-based SQL client for
|
|
4
|
+
MySQL, MariaDB, PostgreSQL and SQLite.
|
|
5
|
+
|
|
6
|
+
Browsers cannot open TCP connections to a database, so Planche runs this small
|
|
7
|
+
agent on your machine. The app in your browser talks to the agent over
|
|
8
|
+
`127.0.0.1`, and the agent talks to your databases. Credentials and query
|
|
9
|
+
results stay on your computer — nothing is sent to planche.dev.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
Requires Node.js 22 or newer.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx planche
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The agent starts on port 8888, prints a pairing link and opens it in your
|
|
20
|
+
default browser:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
https://planche.dev/#agent=http://127.0.0.1:8888&token=…
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Opening that link pairs the app with the agent. From then on you can add
|
|
27
|
+
connections in the app as usual; every connection is opened by the agent on
|
|
28
|
+
your machine.
|
|
29
|
+
|
|
30
|
+
The token is random on every start. If you restart the agent, the app shows
|
|
31
|
+
"agent restarted" and asks you to open the new link (or set a fixed token, see
|
|
32
|
+
below).
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
npx planche [options]
|
|
38
|
+
|
|
39
|
+
-p, --port <n> Port to listen on (default: 8888)
|
|
40
|
+
--addr <host> Address to bind (default: 127.0.0.1)
|
|
41
|
+
--token <str> Auth token (default: random on each start)
|
|
42
|
+
--app <url> Web app to open (default: https://planche.dev)
|
|
43
|
+
--origin <url> Extra browser origin allowed to use the agent (repeatable)
|
|
44
|
+
--no-open Print the pairing link without opening a browser
|
|
45
|
+
--verbose Log every request
|
|
46
|
+
-h, --help Show help
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Every option can also be set through an environment variable:
|
|
50
|
+
|
|
51
|
+
| Variable | Option | Notes |
|
|
52
|
+
| ------------------------- | ---------- | --------------------------------------------------- |
|
|
53
|
+
| `PLANCHE_PORT` | `--port` | |
|
|
54
|
+
| `PLANCHE_ADDR` | `--addr` | |
|
|
55
|
+
| `PLANCHE_AUTH_TOKEN` | `--token` | Fixed token; the pairing link stays valid across restarts |
|
|
56
|
+
| `PLANCHE_APP_URL` | `--app` | Use a self-hosted build of the app |
|
|
57
|
+
| `PLANCHE_ALLOWED_ORIGINS` | `--origin` | Comma-separated list |
|
|
58
|
+
| `PLANCHE_BODY_LIMIT` | | Max request body, default `50mb` (large SQL scripts) |
|
|
59
|
+
| `PLANCHE_SQLITE_DIR` | | Only SQLite files inside this directory may be opened |
|
|
60
|
+
|
|
61
|
+
### Examples
|
|
62
|
+
|
|
63
|
+
Keep the same token between restarts so the app never needs re-pairing:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
PLANCHE_AUTH_TOKEN=my-secret npx planche
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Run on another port and print the link only:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx planche --port 9000 --no-open
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use with the app served from your own host:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx planche --app https://planche.example.com
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Restrict SQLite access to one folder:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
PLANCHE_SQLITE_DIR=~/databases npx planche
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## How it works
|
|
88
|
+
|
|
89
|
+
The agent is a small HTTP server with a handful of JSON endpoints
|
|
90
|
+
(`/connect`, `/query`, `/disconnect`, `/test-connection`, `/health`). The app
|
|
91
|
+
calls them with the bearer token from the pairing link; the agent keeps the
|
|
92
|
+
database connections open and forwards queries with the matching driver
|
|
93
|
+
(`mysql2`, `pg`, `better-sqlite3`).
|
|
94
|
+
|
|
95
|
+
- **MySQL / MariaDB** — switching databases uses `USE`, so open transactions
|
|
96
|
+
and session variables survive.
|
|
97
|
+
- **PostgreSQL** — one connection is bound to one database; the "database"
|
|
98
|
+
you pick in the app is a schema of it and is applied with `SET search_path`.
|
|
99
|
+
- **SQLite** — opens a file on your disk by path. Set `PLANCHE_SQLITE_DIR` to
|
|
100
|
+
limit which files can be opened. (Small SQLite files can also be opened
|
|
101
|
+
directly in the browser without the agent.)
|
|
102
|
+
|
|
103
|
+
## Security
|
|
104
|
+
|
|
105
|
+
- Every request except `GET /health` requires the bearer token.
|
|
106
|
+
- Browser requests are accepted only from the Planche app origin and local
|
|
107
|
+
dev servers. Any other `Origin` is refused with `403`, even with a valid
|
|
108
|
+
token. Add your own with `--origin`.
|
|
109
|
+
- The agent binds to `127.0.0.1` by default, so only the browser on the same
|
|
110
|
+
machine can reach it. Binding to another address (`--addr 0.0.0.0`) exposes
|
|
111
|
+
your databases to the network — only do that behind a tunnel or firewall you
|
|
112
|
+
trust.
|
|
113
|
+
- The agent never stores credentials. They are passed per connection by the
|
|
114
|
+
app and live in memory only while the connection is open.
|
|
115
|
+
|
|
116
|
+
## Troubleshooting
|
|
117
|
+
|
|
118
|
+
- **The app says the agent is offline** — make sure `npx planche` is still
|
|
119
|
+
running and that the port in the pairing link matches. Corporate proxies or
|
|
120
|
+
browser extensions that block `localhost` requests can also cause this.
|
|
121
|
+
- **"Agent restarted, re-pair"** — the agent generated a new token. Open the
|
|
122
|
+
new link it printed, or start it with a fixed `--token`.
|
|
123
|
+
- **`403 Forbidden`** — the request came from an origin that is not
|
|
124
|
+
allow-listed. Pass `--origin https://your-app-host`.
|
|
125
|
+
- **Wrong Node version** — the SQLite driver ships prebuilt binaries for
|
|
126
|
+
Node 22+. Check with `node -v`, or run with `npx -p node@22 planche`.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import { parseArgs } from "util";
|
|
8
|
+
|
|
9
|
+
// src/server.ts
|
|
10
|
+
import express from "express";
|
|
11
|
+
import { timingSafeEqual } from "crypto";
|
|
12
|
+
|
|
13
|
+
// ../shared/contract.ts
|
|
14
|
+
var PROTOCOL_VERSION = 2;
|
|
15
|
+
|
|
16
|
+
// src/tunneling.ts
|
|
17
|
+
import { randomUUID } from "crypto";
|
|
18
|
+
|
|
19
|
+
// src/drivers/mysql.ts
|
|
20
|
+
import mysql from "mysql2/promise";
|
|
21
|
+
var MYSQL_TYPES = {
|
|
22
|
+
0: "decimal",
|
|
23
|
+
1: "tinyint",
|
|
24
|
+
2: "smallint",
|
|
25
|
+
3: "int",
|
|
26
|
+
4: "float",
|
|
27
|
+
5: "double",
|
|
28
|
+
6: "null",
|
|
29
|
+
7: "timestamp",
|
|
30
|
+
8: "bigint",
|
|
31
|
+
9: "mediumint",
|
|
32
|
+
10: "date",
|
|
33
|
+
11: "time",
|
|
34
|
+
12: "datetime",
|
|
35
|
+
13: "year",
|
|
36
|
+
14: "newdate",
|
|
37
|
+
15: "varchar",
|
|
38
|
+
16: "bit",
|
|
39
|
+
245: "json",
|
|
40
|
+
246: "newdecimal",
|
|
41
|
+
247: "enum",
|
|
42
|
+
248: "set",
|
|
43
|
+
249: "tinyblob",
|
|
44
|
+
250: "mediumblob",
|
|
45
|
+
251: "longblob",
|
|
46
|
+
252: "blob",
|
|
47
|
+
253: "varstring",
|
|
48
|
+
254: "string",
|
|
49
|
+
255: "geometry"
|
|
50
|
+
};
|
|
51
|
+
function getTypeName(typeId) {
|
|
52
|
+
return MYSQL_TYPES[typeId] ?? "unknown";
|
|
53
|
+
}
|
|
54
|
+
function toConnectionOptions(config) {
|
|
55
|
+
return {
|
|
56
|
+
host: config.host,
|
|
57
|
+
port: config.port,
|
|
58
|
+
user: config.user,
|
|
59
|
+
password: config.password,
|
|
60
|
+
database: config.database || void 0,
|
|
61
|
+
dateStrings: true,
|
|
62
|
+
charset: config.charset || "utf8mb4"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
var USE_STATEMENT = /^\s*USE\s/i;
|
|
66
|
+
var MySqlDriver = class {
|
|
67
|
+
constructor(connection, config) {
|
|
68
|
+
this.connection = connection;
|
|
69
|
+
this.label = `${config.user}@${config.host}:${config.port}`;
|
|
70
|
+
this.currentDatabase = config.database ?? null;
|
|
71
|
+
}
|
|
72
|
+
connection;
|
|
73
|
+
type = "mysql";
|
|
74
|
+
label;
|
|
75
|
+
/**
|
|
76
|
+
* Default schema the session is currently in, or null when unknown (after
|
|
77
|
+
* the client ran its own `USE`). Tracked so the schema is switched with a
|
|
78
|
+
* plain `USE` only when it actually changes: `changeUser()` would reset the
|
|
79
|
+
* whole session on every call, silently rolling back open transactions and
|
|
80
|
+
* dropping temporary tables and user variables.
|
|
81
|
+
*/
|
|
82
|
+
currentDatabase;
|
|
83
|
+
async query(sql, database) {
|
|
84
|
+
if (database && database !== this.currentDatabase) {
|
|
85
|
+
await this.connection.query(`USE ${mysql.escapeId(database)}`);
|
|
86
|
+
this.currentDatabase = database;
|
|
87
|
+
}
|
|
88
|
+
const [results, fields] = await this.connection.query(sql);
|
|
89
|
+
if (USE_STATEMENT.test(sql)) this.currentDatabase = null;
|
|
90
|
+
if (Array.isArray(results) && fields) {
|
|
91
|
+
const fieldPackets = fields;
|
|
92
|
+
const mappedFields = fieldPackets.map((field) => ({
|
|
93
|
+
name: field.name,
|
|
94
|
+
orgName: field.orgName ?? field.name,
|
|
95
|
+
type: getTypeName(field.type ?? 253),
|
|
96
|
+
table: field.table ?? "",
|
|
97
|
+
orgTable: field.orgTable ?? "",
|
|
98
|
+
db: field.db ?? "",
|
|
99
|
+
maxLength: field.length ?? 0
|
|
100
|
+
}));
|
|
101
|
+
const records = results.map(
|
|
102
|
+
(row) => fieldPackets.map((field) => row[field.name])
|
|
103
|
+
);
|
|
104
|
+
return { fields: mappedFields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
105
|
+
}
|
|
106
|
+
const header = results;
|
|
107
|
+
return {
|
|
108
|
+
fields: [],
|
|
109
|
+
records: [],
|
|
110
|
+
affectedRows: header.affectedRows ?? 0,
|
|
111
|
+
insertId: header.insertId ?? 0,
|
|
112
|
+
isResultQuery: false
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async close() {
|
|
116
|
+
await this.connection.end();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
var mysqlDriverFactory = {
|
|
120
|
+
async open(config) {
|
|
121
|
+
const connection = await mysql.createConnection(toConnectionOptions(config));
|
|
122
|
+
return new MySqlDriver(connection, config);
|
|
123
|
+
},
|
|
124
|
+
async test(config) {
|
|
125
|
+
const connection = await mysql.createConnection({
|
|
126
|
+
...toConnectionOptions(config),
|
|
127
|
+
database: void 0
|
|
128
|
+
});
|
|
129
|
+
await connection.end();
|
|
130
|
+
},
|
|
131
|
+
parseConfig(body) {
|
|
132
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
133
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
134
|
+
if (!host || !user) {
|
|
135
|
+
throw new Error("host and user are required");
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
driver: "mysql",
|
|
139
|
+
host,
|
|
140
|
+
port: Number(body.port) || 3306,
|
|
141
|
+
user,
|
|
142
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
143
|
+
database: typeof body.database === "string" && body.database ? body.database : void 0,
|
|
144
|
+
charset: typeof body.charset === "string" && body.charset ? body.charset : void 0
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// src/drivers/postgres.ts
|
|
150
|
+
import pg from "pg";
|
|
151
|
+
var TEXT_OIDS = [
|
|
152
|
+
20,
|
|
153
|
+
// int8
|
|
154
|
+
1700,
|
|
155
|
+
// numeric
|
|
156
|
+
1082,
|
|
157
|
+
// date
|
|
158
|
+
1114,
|
|
159
|
+
// timestamp
|
|
160
|
+
1184,
|
|
161
|
+
// timestamptz
|
|
162
|
+
1083,
|
|
163
|
+
// time
|
|
164
|
+
1266,
|
|
165
|
+
// timetz
|
|
166
|
+
1186
|
|
167
|
+
// interval
|
|
168
|
+
];
|
|
169
|
+
var types = new pg.TypeOverrides();
|
|
170
|
+
for (const oid of TEXT_OIDS) types.setTypeParser(oid, (value) => value);
|
|
171
|
+
var BUILTIN_TYPES = {
|
|
172
|
+
16: "bool",
|
|
173
|
+
17: "bytea",
|
|
174
|
+
18: "char",
|
|
175
|
+
19: "name",
|
|
176
|
+
20: "int8",
|
|
177
|
+
21: "int2",
|
|
178
|
+
23: "int4",
|
|
179
|
+
25: "text",
|
|
180
|
+
26: "oid",
|
|
181
|
+
114: "json",
|
|
182
|
+
142: "xml",
|
|
183
|
+
700: "float4",
|
|
184
|
+
701: "float8",
|
|
185
|
+
1042: "bpchar",
|
|
186
|
+
1043: "varchar",
|
|
187
|
+
1082: "date",
|
|
188
|
+
1083: "time",
|
|
189
|
+
1114: "timestamp",
|
|
190
|
+
1184: "timestamptz",
|
|
191
|
+
1186: "interval",
|
|
192
|
+
1266: "timetz",
|
|
193
|
+
1700: "numeric",
|
|
194
|
+
2950: "uuid",
|
|
195
|
+
3802: "jsonb",
|
|
196
|
+
1560: "bit",
|
|
197
|
+
1562: "varbit",
|
|
198
|
+
600: "point",
|
|
199
|
+
869: "inet",
|
|
200
|
+
650: "cidr",
|
|
201
|
+
829: "macaddr",
|
|
202
|
+
1007: "_int4",
|
|
203
|
+
1009: "_text"
|
|
204
|
+
};
|
|
205
|
+
function toClientConfig(config) {
|
|
206
|
+
return {
|
|
207
|
+
host: config.host,
|
|
208
|
+
port: config.port,
|
|
209
|
+
user: config.user,
|
|
210
|
+
password: config.password,
|
|
211
|
+
database: config.database,
|
|
212
|
+
ssl: config.ssl ? { rejectUnauthorized: false } : void 0,
|
|
213
|
+
types
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
var SEARCH_PATH_STATEMENT = /^\s*SET\s+(SESSION\s+|LOCAL\s+)?search_path\b/i;
|
|
217
|
+
var quoteIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
218
|
+
var PostgresDriver = class {
|
|
219
|
+
constructor(client, config) {
|
|
220
|
+
this.client = client;
|
|
221
|
+
this.label = `${config.user}@${config.host}:${config.port}/${config.database}`;
|
|
222
|
+
}
|
|
223
|
+
client;
|
|
224
|
+
type = "postgres";
|
|
225
|
+
label;
|
|
226
|
+
/**
|
|
227
|
+
* Schema currently first in `search_path`, or null when unknown (after the
|
|
228
|
+
* client set it itself). The app's "database" for a Postgres connection is
|
|
229
|
+
* a schema of the bound database, switched with `SET search_path`.
|
|
230
|
+
*/
|
|
231
|
+
currentSchema = null;
|
|
232
|
+
typeNames = { ...BUILTIN_TYPES };
|
|
233
|
+
/**
|
|
234
|
+
* Result fields name their source only by OID (`tableID` / `columnID`);
|
|
235
|
+
* the frontend needs schema/table/column names to decide whether a grid is
|
|
236
|
+
* editable and to build UPDATEs, so they are resolved here and cached.
|
|
237
|
+
*/
|
|
238
|
+
relations = /* @__PURE__ */ new Map();
|
|
239
|
+
async loadTypeNames() {
|
|
240
|
+
try {
|
|
241
|
+
const res = await this.client.query(
|
|
242
|
+
"SELECT oid, typname FROM pg_type WHERE typtype IN ('b', 'e', 'd', 'r')"
|
|
243
|
+
);
|
|
244
|
+
for (const row of res.rows) this.typeNames[Number(row.oid)] = row.typname;
|
|
245
|
+
} catch {
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async resolveRelations(tableIds) {
|
|
249
|
+
const missing = [...new Set(tableIds)].filter((id) => id > 0 && !this.relations.has(id));
|
|
250
|
+
if (missing.length === 0) return;
|
|
251
|
+
try {
|
|
252
|
+
const res = await this.client.query(
|
|
253
|
+
`SELECT c.oid, n.nspname, c.relname, a.attnum, a.attname
|
|
254
|
+
FROM pg_class c
|
|
255
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
256
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
257
|
+
WHERE c.oid = ANY($1::oid[])`,
|
|
258
|
+
[missing]
|
|
259
|
+
);
|
|
260
|
+
for (const row of res.rows) {
|
|
261
|
+
const oid = Number(row.oid);
|
|
262
|
+
let rel = this.relations.get(oid);
|
|
263
|
+
if (!rel) {
|
|
264
|
+
rel = { schema: row.nspname, table: row.relname, columns: /* @__PURE__ */ new Map() };
|
|
265
|
+
this.relations.set(oid, rel);
|
|
266
|
+
}
|
|
267
|
+
rel.columns.set(Number(row.attnum), row.attname);
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async query(sql, database) {
|
|
273
|
+
if (database && database !== this.currentSchema) {
|
|
274
|
+
await this.client.query(`SET search_path TO ${quoteIdent(database)}, public`);
|
|
275
|
+
this.currentSchema = database;
|
|
276
|
+
}
|
|
277
|
+
const result = await this.client.query({ text: sql, rowMode: "array" });
|
|
278
|
+
if (SEARCH_PATH_STATEMENT.test(sql)) this.currentSchema = null;
|
|
279
|
+
const results = Array.isArray(result) ? result : [result];
|
|
280
|
+
const last = [...results].reverse().find((r) => r.fields.length > 0) ?? results[results.length - 1];
|
|
281
|
+
if (last.fields.length > 0) {
|
|
282
|
+
await this.resolveRelations(last.fields.map((f) => f.tableID));
|
|
283
|
+
const fields = last.fields.map((field) => {
|
|
284
|
+
const rel = this.relations.get(field.tableID);
|
|
285
|
+
return {
|
|
286
|
+
name: field.name,
|
|
287
|
+
orgName: rel?.columns.get(field.columnID) ?? field.name,
|
|
288
|
+
type: this.typeNames[field.dataTypeID] ?? `oid:${field.dataTypeID}`,
|
|
289
|
+
table: rel?.table ?? "",
|
|
290
|
+
orgTable: rel?.table ?? "",
|
|
291
|
+
db: rel?.schema ?? "",
|
|
292
|
+
maxLength: field.dataTypeSize > 0 ? field.dataTypeSize : 0
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
return { fields, records: last.rows, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
fields: [],
|
|
299
|
+
records: [],
|
|
300
|
+
affectedRows: results.reduce((sum, r) => sum + (r.rowCount ?? 0), 0),
|
|
301
|
+
insertId: 0,
|
|
302
|
+
isResultQuery: false
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async close() {
|
|
306
|
+
await this.client.end();
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
var postgresDriverFactory = {
|
|
310
|
+
async open(config) {
|
|
311
|
+
const client = new pg.Client(toClientConfig(config));
|
|
312
|
+
await client.connect();
|
|
313
|
+
const driver = new PostgresDriver(client, config);
|
|
314
|
+
await driver.loadTypeNames();
|
|
315
|
+
return driver;
|
|
316
|
+
},
|
|
317
|
+
async test(config) {
|
|
318
|
+
const client = new pg.Client(toClientConfig(config));
|
|
319
|
+
await client.connect();
|
|
320
|
+
await client.end();
|
|
321
|
+
},
|
|
322
|
+
parseConfig(body) {
|
|
323
|
+
const host = typeof body.host === "string" ? body.host.trim() : "";
|
|
324
|
+
const user = typeof body.user === "string" ? body.user : "";
|
|
325
|
+
if (!host || !user) {
|
|
326
|
+
throw new Error("host and user are required");
|
|
327
|
+
}
|
|
328
|
+
return {
|
|
329
|
+
driver: "postgres",
|
|
330
|
+
host,
|
|
331
|
+
port: Number(body.port) || 5432,
|
|
332
|
+
user,
|
|
333
|
+
password: typeof body.password === "string" ? body.password : "",
|
|
334
|
+
database: typeof body.database === "string" && body.database ? body.database : "postgres",
|
|
335
|
+
ssl: body.ssl === true
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// src/drivers/sqlite.ts
|
|
341
|
+
import Database from "better-sqlite3";
|
|
342
|
+
import { existsSync } from "fs";
|
|
343
|
+
import { isAbsolute, resolve, sep } from "path";
|
|
344
|
+
function normalizeDeclaredType(declared) {
|
|
345
|
+
if (!declared) return null;
|
|
346
|
+
const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
|
|
347
|
+
return base || null;
|
|
348
|
+
}
|
|
349
|
+
function inferTypeFromValue(value) {
|
|
350
|
+
if (value === null || value === void 0) return "null";
|
|
351
|
+
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
|
|
352
|
+
if (typeof value === "bigint") return "bigint";
|
|
353
|
+
if (Buffer.isBuffer(value)) return "blob";
|
|
354
|
+
return "text";
|
|
355
|
+
}
|
|
356
|
+
var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(process.env.PLANCHE_SQLITE_DIR) : null;
|
|
357
|
+
function resolveFile(file) {
|
|
358
|
+
if (file === ":memory:") return file;
|
|
359
|
+
if (file.startsWith("file:")) {
|
|
360
|
+
if (SQLITE_DIR) throw new Error("URI filenames are disabled while PLANCHE_SQLITE_DIR is set");
|
|
361
|
+
return file;
|
|
362
|
+
}
|
|
363
|
+
const resolved = isAbsolute(file) ? file : resolve(SQLITE_DIR ?? process.cwd(), file);
|
|
364
|
+
if (SQLITE_DIR && resolved !== SQLITE_DIR && !resolved.startsWith(SQLITE_DIR + sep)) {
|
|
365
|
+
throw new Error(`SQLite file must be inside ${SQLITE_DIR}`);
|
|
366
|
+
}
|
|
367
|
+
return resolved;
|
|
368
|
+
}
|
|
369
|
+
function openDatabase(config) {
|
|
370
|
+
const file = resolveFile(config.file);
|
|
371
|
+
const isMemory = file === ":memory:";
|
|
372
|
+
if (!isMemory && !config.createIfMissing && !existsSync(file)) {
|
|
373
|
+
throw new Error(`SQLite file not found: ${file}`);
|
|
374
|
+
}
|
|
375
|
+
return new Database(file, {
|
|
376
|
+
readonly: config.readonly ?? false,
|
|
377
|
+
fileMustExist: !isMemory && !config.createIfMissing
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
var SqliteDriver = class {
|
|
381
|
+
constructor(db, config) {
|
|
382
|
+
this.db = db;
|
|
383
|
+
this.label = resolveFile(config.file);
|
|
384
|
+
}
|
|
385
|
+
db;
|
|
386
|
+
type = "sqlite";
|
|
387
|
+
label;
|
|
388
|
+
async query(sql) {
|
|
389
|
+
let statement;
|
|
390
|
+
try {
|
|
391
|
+
statement = this.db.prepare(sql);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (err instanceof RangeError && /more than one statement/i.test(err.message)) {
|
|
394
|
+
this.db.exec(sql);
|
|
395
|
+
return { fields: [], records: [], affectedRows: 0, insertId: 0, isResultQuery: false };
|
|
396
|
+
}
|
|
397
|
+
throw err;
|
|
398
|
+
}
|
|
399
|
+
if (!statement.reader) {
|
|
400
|
+
const info = statement.run();
|
|
401
|
+
return {
|
|
402
|
+
fields: [],
|
|
403
|
+
records: [],
|
|
404
|
+
affectedRows: info.changes,
|
|
405
|
+
insertId: Number(info.lastInsertRowid),
|
|
406
|
+
isResultQuery: false
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const columns = statement.columns();
|
|
410
|
+
const records = statement.raw(true).all();
|
|
411
|
+
const fields = columns.map((col, index) => {
|
|
412
|
+
const declared = normalizeDeclaredType(col.type);
|
|
413
|
+
const type = declared ?? inferTypeFromValue(firstNonNull(records, index));
|
|
414
|
+
return {
|
|
415
|
+
name: col.name,
|
|
416
|
+
orgName: col.column ?? col.name,
|
|
417
|
+
type,
|
|
418
|
+
table: col.table ?? "",
|
|
419
|
+
orgTable: col.table ?? "",
|
|
420
|
+
db: col.database ?? "",
|
|
421
|
+
maxLength: 0
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
|
|
425
|
+
}
|
|
426
|
+
async close() {
|
|
427
|
+
if (this.db.inTransaction) {
|
|
428
|
+
try {
|
|
429
|
+
this.db.exec("ROLLBACK");
|
|
430
|
+
} catch {
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
this.db.close();
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
function firstNonNull(records, index) {
|
|
437
|
+
for (const row of records) {
|
|
438
|
+
const value = row[index];
|
|
439
|
+
if (value !== null && value !== void 0) return value;
|
|
440
|
+
}
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
var sqliteDriverFactory = {
|
|
444
|
+
async open(config) {
|
|
445
|
+
return new SqliteDriver(openDatabase(config), config);
|
|
446
|
+
},
|
|
447
|
+
async test(config) {
|
|
448
|
+
const db = openDatabase(config);
|
|
449
|
+
try {
|
|
450
|
+
db.prepare("SELECT 1").get();
|
|
451
|
+
} finally {
|
|
452
|
+
db.close();
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
parseConfig(body) {
|
|
456
|
+
const file = typeof body.file === "string" ? body.file.trim() : "";
|
|
457
|
+
if (!file) {
|
|
458
|
+
throw new Error("file is required");
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
driver: "sqlite",
|
|
462
|
+
file,
|
|
463
|
+
createIfMissing: body.createIfMissing === true,
|
|
464
|
+
readonly: body.readonly === true
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// src/drivers/index.ts
|
|
470
|
+
var factories = {
|
|
471
|
+
mysql: mysqlDriverFactory,
|
|
472
|
+
postgres: postgresDriverFactory,
|
|
473
|
+
sqlite: sqliteDriverFactory
|
|
474
|
+
};
|
|
475
|
+
function isDriverType(value) {
|
|
476
|
+
return typeof value === "string" && value in factories;
|
|
477
|
+
}
|
|
478
|
+
function parseDriverConfig(body) {
|
|
479
|
+
const driver = body.driver ?? "mysql";
|
|
480
|
+
if (!isDriverType(driver)) {
|
|
481
|
+
throw new Error(`Unsupported driver: ${String(driver)}`);
|
|
482
|
+
}
|
|
483
|
+
return factories[driver].parseConfig(body);
|
|
484
|
+
}
|
|
485
|
+
function openDriver(config) {
|
|
486
|
+
switch (config.driver) {
|
|
487
|
+
case "mysql":
|
|
488
|
+
return factories.mysql.open(config);
|
|
489
|
+
case "postgres":
|
|
490
|
+
return factories.postgres.open(config);
|
|
491
|
+
case "sqlite":
|
|
492
|
+
return factories.sqlite.open(config);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function testDriver(config) {
|
|
496
|
+
switch (config.driver) {
|
|
497
|
+
case "mysql":
|
|
498
|
+
return factories.mysql.test(config);
|
|
499
|
+
case "postgres":
|
|
500
|
+
return factories.postgres.test(config);
|
|
501
|
+
case "sqlite":
|
|
502
|
+
return factories.sqlite.test(config);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/tunneling.ts
|
|
507
|
+
var IDLE_TIMEOUT_MS = Number(process.env.PLANCHE_IDLE_TIMEOUT_MS) || 30 * 60 * 1e3;
|
|
508
|
+
var ConnectionManager = class {
|
|
509
|
+
connections = /* @__PURE__ */ new Map();
|
|
510
|
+
lastUsed = /* @__PURE__ */ new Map();
|
|
511
|
+
constructor() {
|
|
512
|
+
const timer = setInterval(() => void this.closeIdle(), 6e4);
|
|
513
|
+
timer.unref();
|
|
514
|
+
}
|
|
515
|
+
/** Close connections idle for longer than `IDLE_TIMEOUT_MS`. */
|
|
516
|
+
async closeIdle() {
|
|
517
|
+
const cutoff = Date.now() - IDLE_TIMEOUT_MS;
|
|
518
|
+
for (const [id, used] of this.lastUsed) {
|
|
519
|
+
if (used < cutoff) {
|
|
520
|
+
console.log(`[ConnectionManager] Closing idle connection: ${id}`);
|
|
521
|
+
await this.disconnect(id).catch(() => void 0);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Open a new connection and return a unique connection ID.
|
|
527
|
+
*/
|
|
528
|
+
async connect(config) {
|
|
529
|
+
const driver = await openDriver(config);
|
|
530
|
+
const connectionId = randomUUID();
|
|
531
|
+
this.connections.set(connectionId, driver);
|
|
532
|
+
this.lastUsed.set(connectionId, Date.now());
|
|
533
|
+
console.log(`[ConnectionManager] New ${driver.type} connection: ${connectionId} -> ${driver.label}`);
|
|
534
|
+
return connectionId;
|
|
535
|
+
}
|
|
536
|
+
has(connectionId) {
|
|
537
|
+
return this.connections.has(connectionId);
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Execute a SQL query on an existing connection.
|
|
541
|
+
* Optionally switch database before executing (drivers that support it).
|
|
542
|
+
*/
|
|
543
|
+
async query(connectionId, sql, database) {
|
|
544
|
+
const driver = this.connections.get(connectionId);
|
|
545
|
+
if (!driver) {
|
|
546
|
+
throw new Error(`Connection not found: ${connectionId}`);
|
|
547
|
+
}
|
|
548
|
+
this.lastUsed.set(connectionId, Date.now());
|
|
549
|
+
return driver.query(sql, database);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Close and remove a connection.
|
|
553
|
+
*/
|
|
554
|
+
async disconnect(connectionId) {
|
|
555
|
+
const driver = this.connections.get(connectionId);
|
|
556
|
+
if (!driver) {
|
|
557
|
+
throw new Error(`Connection not found: ${connectionId}`);
|
|
558
|
+
}
|
|
559
|
+
await driver.close();
|
|
560
|
+
this.connections.delete(connectionId);
|
|
561
|
+
this.lastUsed.delete(connectionId);
|
|
562
|
+
console.log(`[ConnectionManager] Disconnected: ${connectionId}`);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Validate a config by opening and immediately closing a connection.
|
|
566
|
+
*/
|
|
567
|
+
async testConnection(config) {
|
|
568
|
+
await testDriver(config);
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Get the number of active connections.
|
|
573
|
+
*/
|
|
574
|
+
get activeConnections() {
|
|
575
|
+
return this.connections.size;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Close all connections (for graceful shutdown).
|
|
579
|
+
*/
|
|
580
|
+
async disconnectAll() {
|
|
581
|
+
const promises = Array.from(this.connections.entries()).map(
|
|
582
|
+
async ([id, driver]) => {
|
|
583
|
+
try {
|
|
584
|
+
await driver.close();
|
|
585
|
+
console.log(`[ConnectionManager] Disconnected: ${id}`);
|
|
586
|
+
} catch {
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
);
|
|
590
|
+
await Promise.all(promises);
|
|
591
|
+
this.connections.clear();
|
|
592
|
+
this.lastUsed.clear();
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// src/server.ts
|
|
597
|
+
function fail(res, status, message) {
|
|
598
|
+
res.status(status).json({ success: false, message });
|
|
599
|
+
}
|
|
600
|
+
var errorMessage = (err) => err instanceof Error ? err.message : String(err);
|
|
601
|
+
function tokenMatches(header, expected) {
|
|
602
|
+
if (!header?.startsWith("Bearer ")) return false;
|
|
603
|
+
const given = Buffer.from(header.slice(7));
|
|
604
|
+
const want = Buffer.from(expected);
|
|
605
|
+
return given.length === want.length && timingSafeEqual(given, want);
|
|
606
|
+
}
|
|
607
|
+
function accessControl(allowed) {
|
|
608
|
+
return (req, res, next) => {
|
|
609
|
+
const origin = req.headers.origin;
|
|
610
|
+
if (origin === void 0) {
|
|
611
|
+
next();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (!allowed.has(origin)) {
|
|
615
|
+
fail(res, 403, `Origin not allowed: ${origin}`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
619
|
+
res.setHeader("Vary", "Origin");
|
|
620
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
621
|
+
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
622
|
+
res.setHeader("Access-Control-Max-Age", "600");
|
|
623
|
+
if (req.headers["access-control-request-private-network"] === "true") {
|
|
624
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
625
|
+
}
|
|
626
|
+
if (req.headers["access-control-request-local-network"] === "true") {
|
|
627
|
+
res.setHeader("Access-Control-Allow-Local-Network", "true");
|
|
628
|
+
}
|
|
629
|
+
if (req.method === "OPTIONS") {
|
|
630
|
+
res.sendStatus(204);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
next();
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function createAgent(options) {
|
|
637
|
+
const app = express();
|
|
638
|
+
const manager = new ConnectionManager();
|
|
639
|
+
const allowedOrigins = new Set(options.allowedOrigins.map((o) => o.replace(/\/+$/, "")));
|
|
640
|
+
if (options.logRequests) {
|
|
641
|
+
app.use((req, _res, next) => {
|
|
642
|
+
const hasAuth = !!req.headers.authorization;
|
|
643
|
+
console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${req.method} ${req.url} (auth: ${hasAuth})`);
|
|
644
|
+
next();
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
app.use(accessControl(allowedOrigins));
|
|
648
|
+
app.use(express.json({ limit: options.bodyLimit }));
|
|
649
|
+
app.use((req, res, next) => {
|
|
650
|
+
if (req.path === "/health") {
|
|
651
|
+
next();
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (!tokenMatches(req.headers.authorization, options.authToken)) {
|
|
655
|
+
fail(res, 401, "Unauthorized: invalid or missing auth token");
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
next();
|
|
659
|
+
});
|
|
660
|
+
app.get("/health", (req, res) => {
|
|
661
|
+
const body = {
|
|
662
|
+
success: true,
|
|
663
|
+
name: "planche",
|
|
664
|
+
version: options.version,
|
|
665
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
666
|
+
activeConnections: manager.activeConnections,
|
|
667
|
+
uptime: process.uptime()
|
|
668
|
+
};
|
|
669
|
+
if (req.headers.authorization !== void 0) {
|
|
670
|
+
body.authorized = tokenMatches(req.headers.authorization, options.authToken);
|
|
671
|
+
}
|
|
672
|
+
res.json(body);
|
|
673
|
+
});
|
|
674
|
+
app.post("/connect", async (req, res) => {
|
|
675
|
+
try {
|
|
676
|
+
const config = parseDriverConfig(req.body ?? {});
|
|
677
|
+
const connectionId = await manager.connect(config);
|
|
678
|
+
res.json({ success: true, connectionId, message: "Connection Successful" });
|
|
679
|
+
} catch (err) {
|
|
680
|
+
const message = errorMessage(err);
|
|
681
|
+
console.error("[connect] Error:", message);
|
|
682
|
+
fail(res, 400, message);
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
app.post("/query", async (req, res) => {
|
|
686
|
+
try {
|
|
687
|
+
const { connectionId, sql, database } = req.body;
|
|
688
|
+
if (typeof connectionId !== "string" || typeof sql !== "string" || !connectionId || !sql) {
|
|
689
|
+
fail(res, 400, "connectionId and sql are required");
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (!manager.has(connectionId)) {
|
|
693
|
+
fail(res, 404, `Connection not found: ${connectionId}`);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const start = Date.now();
|
|
697
|
+
const result = await manager.query(connectionId, sql, database);
|
|
698
|
+
const execTime = Date.now() - start;
|
|
699
|
+
res.json({
|
|
700
|
+
success: true,
|
|
701
|
+
fields: result.fields,
|
|
702
|
+
records: result.records,
|
|
703
|
+
affectedRows: result.affectedRows,
|
|
704
|
+
insertId: result.insertId,
|
|
705
|
+
isResultQuery: result.isResultQuery,
|
|
706
|
+
execTime
|
|
707
|
+
});
|
|
708
|
+
} catch (err) {
|
|
709
|
+
const message = errorMessage(err);
|
|
710
|
+
console.error("[query] Error:", message);
|
|
711
|
+
fail(res, 422, message);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
app.post("/disconnect", async (req, res) => {
|
|
715
|
+
try {
|
|
716
|
+
const { connectionId } = req.body;
|
|
717
|
+
if (typeof connectionId !== "string" || !connectionId) {
|
|
718
|
+
fail(res, 400, "connectionId is required");
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (!manager.has(connectionId)) {
|
|
722
|
+
fail(res, 404, `Connection not found: ${connectionId}`);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
await manager.disconnect(connectionId);
|
|
726
|
+
res.json({ success: true, message: "Disconnected" });
|
|
727
|
+
} catch (err) {
|
|
728
|
+
const message = errorMessage(err);
|
|
729
|
+
console.error("[disconnect] Error:", message);
|
|
730
|
+
fail(res, 500, message);
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
app.post("/test-connection", async (req, res) => {
|
|
734
|
+
try {
|
|
735
|
+
await manager.testConnection(parseDriverConfig(req.body ?? {}));
|
|
736
|
+
res.json({ success: true, message: "Connection test successful" });
|
|
737
|
+
} catch (err) {
|
|
738
|
+
const message = errorMessage(err);
|
|
739
|
+
console.error("[test-connection] Error:", message);
|
|
740
|
+
fail(res, 400, message);
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
return { app, manager };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// src/index.ts
|
|
747
|
+
var { version: VERSION } = createRequire(import.meta.url)("../package.json");
|
|
748
|
+
var DEFAULT_APP_URL = "https://planche.dev";
|
|
749
|
+
var DEV_ORIGINS = [
|
|
750
|
+
"http://localhost:5173",
|
|
751
|
+
"http://127.0.0.1:5173",
|
|
752
|
+
"http://localhost:4173",
|
|
753
|
+
"http://127.0.0.1:4173"
|
|
754
|
+
];
|
|
755
|
+
function usage() {
|
|
756
|
+
return `planche ${VERSION} \u2014 local agent for the Planche SQL client
|
|
757
|
+
|
|
758
|
+
Usage: planche [options]
|
|
759
|
+
|
|
760
|
+
Options:
|
|
761
|
+
-p, --port <n> Port to listen on (default: 8888, env PLANCHE_PORT)
|
|
762
|
+
--addr <host> Address to bind (default: 127.0.0.1, env PLANCHE_ADDR)
|
|
763
|
+
--token <str> Auth token (default: random, env PLANCHE_AUTH_TOKEN)
|
|
764
|
+
--app <url> Web app to open (default: ${DEFAULT_APP_URL}, env PLANCHE_APP_URL)
|
|
765
|
+
--origin <url> Extra browser origin allowed to use the agent (repeatable,
|
|
766
|
+
env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
|
|
767
|
+
--no-open Do not open the browser
|
|
768
|
+
--verbose Log every request
|
|
769
|
+
-v, --version Print the version
|
|
770
|
+
-h, --help Show this help
|
|
771
|
+
`;
|
|
772
|
+
}
|
|
773
|
+
function splitList(value) {
|
|
774
|
+
return (value ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
775
|
+
}
|
|
776
|
+
function originOf(url) {
|
|
777
|
+
try {
|
|
778
|
+
return new URL(url).origin;
|
|
779
|
+
} catch {
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function openInBrowser(url) {
|
|
784
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
|
|
785
|
+
try {
|
|
786
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
|
|
787
|
+
} catch {
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
function main() {
|
|
791
|
+
let values;
|
|
792
|
+
try {
|
|
793
|
+
values = parseArgs({
|
|
794
|
+
options: {
|
|
795
|
+
port: { type: "string", short: "p" },
|
|
796
|
+
addr: { type: "string" },
|
|
797
|
+
token: { type: "string" },
|
|
798
|
+
app: { type: "string" },
|
|
799
|
+
origin: { type: "string", multiple: true },
|
|
800
|
+
open: { type: "boolean" },
|
|
801
|
+
verbose: { type: "boolean" },
|
|
802
|
+
version: { type: "boolean", short: "v" },
|
|
803
|
+
help: { type: "boolean", short: "h" }
|
|
804
|
+
},
|
|
805
|
+
allowNegative: true,
|
|
806
|
+
strict: true
|
|
807
|
+
}).values;
|
|
808
|
+
} catch (err) {
|
|
809
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
810
|
+
console.error(usage());
|
|
811
|
+
process.exit(2);
|
|
812
|
+
}
|
|
813
|
+
const flags = values;
|
|
814
|
+
if (flags.help) {
|
|
815
|
+
console.log(usage());
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (flags.version) {
|
|
819
|
+
console.log(VERSION);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const port = Number(flags.port ?? process.env.PLANCHE_PORT) || 8888;
|
|
823
|
+
const addr = flags.addr ?? process.env.PLANCHE_ADDR ?? "127.0.0.1";
|
|
824
|
+
const tokenFromEnv = !flags.token && !!process.env.PLANCHE_AUTH_TOKEN;
|
|
825
|
+
const authToken = flags.token ?? process.env.PLANCHE_AUTH_TOKEN ?? randomUUID2();
|
|
826
|
+
const appUrl = (flags.app ?? process.env.PLANCHE_APP_URL ?? DEFAULT_APP_URL).replace(/\/+$/, "");
|
|
827
|
+
const shouldOpen = flags.open ?? true;
|
|
828
|
+
const bodyLimit = process.env.PLANCHE_BODY_LIMIT || "50mb";
|
|
829
|
+
const appOrigin = originOf(appUrl);
|
|
830
|
+
if (!appOrigin) {
|
|
831
|
+
console.error(`Invalid --app URL: ${appUrl}`);
|
|
832
|
+
process.exit(2);
|
|
833
|
+
}
|
|
834
|
+
const allowedOrigins = [
|
|
835
|
+
appOrigin,
|
|
836
|
+
...DEV_ORIGINS,
|
|
837
|
+
...flags.origin ?? [],
|
|
838
|
+
...splitList(process.env.PLANCHE_ALLOWED_ORIGINS)
|
|
839
|
+
];
|
|
840
|
+
const { app, manager } = createAgent({
|
|
841
|
+
authToken,
|
|
842
|
+
allowedOrigins,
|
|
843
|
+
bodyLimit,
|
|
844
|
+
version: VERSION,
|
|
845
|
+
logRequests: flags.verbose ?? false
|
|
846
|
+
});
|
|
847
|
+
const agentUrl = `http://${addr}:${port}`;
|
|
848
|
+
const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&token=${encodeURIComponent(authToken)}`;
|
|
849
|
+
const server = app.listen(port, addr, () => {
|
|
850
|
+
const line = "-".repeat(72);
|
|
851
|
+
console.log(line);
|
|
852
|
+
console.log(`Planche agent ${VERSION} listening on ${agentUrl}`);
|
|
853
|
+
console.log(line);
|
|
854
|
+
console.log(`Open the app with:
|
|
855
|
+
|
|
856
|
+
${pairUrl}
|
|
857
|
+
`);
|
|
858
|
+
console.log(tokenFromEnv ? "Auth token: (from PLANCHE_AUTH_TOKEN)" : `Auth token: ${authToken}`);
|
|
859
|
+
console.log(`Allowed origins: ${allowedOrigins.join(", ")}`);
|
|
860
|
+
if (!["127.0.0.1", "localhost", "::1"].includes(addr)) {
|
|
861
|
+
console.log(`WARNING: bound to ${addr} \u2014 anyone who can reach this port and knows the token can use your databases.`);
|
|
862
|
+
}
|
|
863
|
+
console.log(line);
|
|
864
|
+
if (shouldOpen) openInBrowser(pairUrl);
|
|
865
|
+
});
|
|
866
|
+
server.on("error", (err) => {
|
|
867
|
+
if (err.code === "EADDRINUSE") {
|
|
868
|
+
console.error(`Port ${port} is already in use. Is another agent running? Use --port to pick another.`);
|
|
869
|
+
} else {
|
|
870
|
+
console.error(err.message);
|
|
871
|
+
}
|
|
872
|
+
process.exit(1);
|
|
873
|
+
});
|
|
874
|
+
const shutdown = async () => {
|
|
875
|
+
console.log("\nShutting down...");
|
|
876
|
+
await manager.disconnectAll();
|
|
877
|
+
process.exit(0);
|
|
878
|
+
};
|
|
879
|
+
process.on("SIGINT", shutdown);
|
|
880
|
+
process.on("SIGTERM", shutdown);
|
|
881
|
+
}
|
|
882
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "planche",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local agent for Planche — lets the browser-based Planche SQL client reach MySQL/MariaDB/PostgreSQL/SQLite databases from your machine.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://planche.dev",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/plancheproject/planche-reboot.git",
|
|
10
|
+
"directory": "server"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": "dist/index.js",
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"planche",
|
|
26
|
+
"mysql",
|
|
27
|
+
"mariadb",
|
|
28
|
+
"postgres",
|
|
29
|
+
"postgresql",
|
|
30
|
+
"sqlite",
|
|
31
|
+
"sql",
|
|
32
|
+
"client",
|
|
33
|
+
"gui"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"dev": "tsx watch --env-file-if-exists=.env src/index.ts --no-open --app http://localhost:5173",
|
|
37
|
+
"start": "tsx --env-file-if-exists=.env src/index.ts",
|
|
38
|
+
"build": "tsup",
|
|
39
|
+
"typecheck": "tsc --noEmit -p .",
|
|
40
|
+
"prepublishOnly": "yarn typecheck && yarn build"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"better-sqlite3": "^12.11.1",
|
|
44
|
+
"express": "^5.1.0",
|
|
45
|
+
"mysql2": "^3.14.1",
|
|
46
|
+
"pg": "^8.23.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
50
|
+
"@types/express": "^5.0.0",
|
|
51
|
+
"@types/node": "^24.0.0",
|
|
52
|
+
"@types/pg": "^8.23.1",
|
|
53
|
+
"tsup": "^8.5.0",
|
|
54
|
+
"tsx": "^4.19.0",
|
|
55
|
+
"typescript": "^5.9.0"
|
|
56
|
+
}
|
|
57
|
+
}
|