railcode 0.1.11 → 0.1.13
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 +4 -4
- package/dist/db.js +4 -3
- package/dist/index.js +232 -80
- package/package.json +1 -1
- package/static/sdk.js +18 -10
package/README.md
CHANGED
|
@@ -36,13 +36,13 @@ railcode db <list|query> ... List data connectors / run read-only SQL
|
|
|
36
36
|
build command when needed, then uploads the output dir. The app is
|
|
37
37
|
created-or-resolved by slug in your saved org, and the live URL is printed.
|
|
38
38
|
- **`db`** inspects the org's **data connectors** (per-org Postgres) and runs
|
|
39
|
-
ad-hoc read-only SQL.
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
ad-hoc read-only SQL. It works straight after `railcode login` — **no app and no
|
|
40
|
+
`railcode.json` required** — since connectors are org-scoped; it hits the app-less
|
|
41
|
+
`/api/organizations/{org}/data/*` plane with your login token.
|
|
42
42
|
`railcode db list` (aliases `ls`, `connections`) prints each connector's
|
|
43
43
|
`name` + `engine` (`--json` for the raw array). `railcode db query "<sql>"`
|
|
44
44
|
(alias `sql`) runs SQL against `--connection` (default `default`) and prints a
|
|
45
|
-
table + row count; `--engine <postgres|
|
|
45
|
+
table + row count; `--engine <postgres|bigquery|snowflake>` is inferred from the connector
|
|
46
46
|
list when omitted, `--params '<json-array>'` binds `$1, $2, …` placeholders
|
|
47
47
|
(SQL is never interpolated), `--file <path>` reads SQL from a file, and
|
|
48
48
|
`--json` prints the raw `{ columns, rows, rowcount, truncated }` envelope.
|
package/dist/db.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the command layer in index.ts does the HTTP, file reads, and printing.
|
|
4
4
|
export class DbError extends Error {
|
|
5
5
|
}
|
|
6
|
-
export const DB_ENGINES = ["postgres", "
|
|
6
|
+
export const DB_ENGINES = ["postgres", "bigquery", "snowflake"];
|
|
7
7
|
// `--params '<json-array>'` → the positional $1,$2,… values. Empty when omitted.
|
|
8
8
|
export function parseSqlParams(raw) {
|
|
9
9
|
if (raw === undefined)
|
|
@@ -24,8 +24,9 @@ export function parseSqlParams(raw) {
|
|
|
24
24
|
export function parseEngine(raw) {
|
|
25
25
|
if (raw === undefined)
|
|
26
26
|
return undefined;
|
|
27
|
-
if (typeof raw !== "string")
|
|
28
|
-
throw new DbError(
|
|
27
|
+
if (typeof raw !== "string") {
|
|
28
|
+
throw new DbError(`--engine requires a value (one of: ${DB_ENGINES.join(", ")}).`);
|
|
29
|
+
}
|
|
29
30
|
if (DB_ENGINES.includes(raw))
|
|
30
31
|
return raw;
|
|
31
32
|
throw new DbError(`Unknown --engine "${raw}". Use one of: ${DB_ENGINES.join(", ")}.`);
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { createServer, request as httpRequest, } from "node:http";
|
|
6
|
-
import { connect as netConnect } from "node:net";
|
|
6
|
+
import { createServer as createNetServer, connect as netConnect } from "node:net";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { Readable } from "node:stream";
|
|
9
9
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
@@ -353,17 +353,6 @@ async function resolveOrCreateApp(config, slug) {
|
|
|
353
353
|
});
|
|
354
354
|
return (await readJsonOrThrow(created, "Create app"));
|
|
355
355
|
}
|
|
356
|
-
// Resolve an existing app by slug WITHOUT creating it — for read-only commands
|
|
357
|
-
// (e.g. `railcode db`) that must not spawn a phantom app as a side effect.
|
|
358
|
-
async function resolveExistingApp(config, slug) {
|
|
359
|
-
const orgPath = `/api/organizations/${config.orgUuid}/apps`;
|
|
360
|
-
const list = (await readJsonOrThrow(await authedFetch(config, orgPath), "List apps"));
|
|
361
|
-
const existing = list.find((app) => app.app_slug === slug);
|
|
362
|
-
if (!existing) {
|
|
363
|
-
throw new CliError(`App "${slug}" hasn't been created in your org yet. Run \`railcode deploy\` first.`, 1);
|
|
364
|
-
}
|
|
365
|
-
return existing;
|
|
366
|
-
}
|
|
367
356
|
async function postDeploy(config, appUuid, files) {
|
|
368
357
|
// The deploy API is multipart: one part named "files" per file, with the
|
|
369
358
|
// relative path carried as the part filename.
|
|
@@ -815,7 +804,7 @@ Query options:
|
|
|
815
804
|
--json Print the raw { columns, rows, rowcount, truncated } envelope
|
|
816
805
|
|
|
817
806
|
SQL is sent read-only with bound parameters — values are never interpolated into
|
|
818
|
-
the query.
|
|
807
|
+
the query. Works right after \`railcode login\` — no app or ${MANIFEST_NAME} required.
|
|
819
808
|
`;
|
|
820
809
|
async function commandDb(args) {
|
|
821
810
|
try {
|
|
@@ -851,8 +840,7 @@ async function commandDbList(args) {
|
|
|
851
840
|
throw new CliError(`railcode db list takes no arguments (got "${args.rest[1]}").`, 2);
|
|
852
841
|
}
|
|
853
842
|
const config = requireLogin(args);
|
|
854
|
-
const
|
|
855
|
-
const connections = await fetchDbConnections(config, app.uuid);
|
|
843
|
+
const connections = await fetchDbConnections(config);
|
|
856
844
|
if (args.options.json) {
|
|
857
845
|
console.log(JSON.stringify(connections, null, 2));
|
|
858
846
|
return;
|
|
@@ -874,10 +862,9 @@ async function commandDbQuery(args) {
|
|
|
874
862
|
const connection = optionString(args, "connection") ?? "default";
|
|
875
863
|
const query = source.kind === "file" ? readSqlFile(source.path) : source.sql;
|
|
876
864
|
const config = requireLogin(args);
|
|
877
|
-
const app = await resolveExistingApp(config, requireAppSlug());
|
|
878
865
|
// --engine wins; otherwise look the engine up from the connector list.
|
|
879
|
-
const engine = engineOption ?? inferEngine(await fetchDbConnections(config
|
|
880
|
-
const result = await runDbQuery(config,
|
|
866
|
+
const engine = engineOption ?? inferEngine(await fetchDbConnections(config), connection);
|
|
867
|
+
const result = await runDbQuery(config, { engine, connection, query, params });
|
|
881
868
|
if (args.options.json) {
|
|
882
869
|
console.log(JSON.stringify(result, null, 2));
|
|
883
870
|
return;
|
|
@@ -889,22 +876,14 @@ async function commandDbQuery(args) {
|
|
|
889
876
|
console.log("Note: results were truncated by the server.");
|
|
890
877
|
}
|
|
891
878
|
}
|
|
892
|
-
function
|
|
893
|
-
const
|
|
894
|
-
if (!manifest?.app) {
|
|
895
|
-
throw new CliError(`No ${MANIFEST_NAME} with an "app" slug here. Run \`railcode init <app>\` first, or add ${MANIFEST_NAME}.`, 2);
|
|
896
|
-
}
|
|
897
|
-
assertAppName(manifest.app);
|
|
898
|
-
return manifest.app;
|
|
899
|
-
}
|
|
900
|
-
async function fetchDbConnections(config, appUuid) {
|
|
901
|
-
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/connections`);
|
|
879
|
+
async function fetchDbConnections(config) {
|
|
880
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/connections`);
|
|
902
881
|
if (resp.status === 401)
|
|
903
882
|
await reLoginOrThrow();
|
|
904
883
|
return (await readJsonOrThrow(resp, "List connections"));
|
|
905
884
|
}
|
|
906
|
-
async function runDbQuery(config,
|
|
907
|
-
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/
|
|
885
|
+
async function runDbQuery(config, body) {
|
|
886
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/sql`, {
|
|
908
887
|
method: "POST",
|
|
909
888
|
headers: { "Content-Type": "application/json" },
|
|
910
889
|
body: JSON.stringify(body),
|
|
@@ -943,9 +922,8 @@ function camelToKebab(value) {
|
|
|
943
922
|
// (`railcode connector list|fetch`)
|
|
944
923
|
//
|
|
945
924
|
// Service connectors are org-configured HTTP proxies to downstream SaaS APIs.
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
// `railcode dev` forwards to.
|
|
925
|
+
// Like `railcode db`, these one-shot commands hit the org-scoped `/data/*` plane
|
|
926
|
+
// with the login bearer token — no app (and no railcode.json) required.
|
|
949
927
|
// ---------------------------------------------------------------------------
|
|
950
928
|
const CONNECTOR_HELP = `railcode connector — service connectors
|
|
951
929
|
|
|
@@ -972,8 +950,8 @@ Fetch options:
|
|
|
972
950
|
--json Print the raw { status, ok, headers, body, truncated } envelope
|
|
973
951
|
|
|
974
952
|
The connector holds the credential; you control only method/path/body. A non-2xx
|
|
975
|
-
upstream status is still printed but exits non-zero.
|
|
976
|
-
${MANIFEST_NAME}
|
|
953
|
+
upstream status is still printed but exits non-zero. Works right after
|
|
954
|
+
\`railcode login\` — no app or ${MANIFEST_NAME} required.
|
|
977
955
|
`;
|
|
978
956
|
async function commandConnector(args) {
|
|
979
957
|
try {
|
|
@@ -1013,8 +991,7 @@ async function commandConnectorList(args) {
|
|
|
1013
991
|
throw new CliError(`railcode connector list takes no arguments (got "${args.rest[1]}").`, 2);
|
|
1014
992
|
}
|
|
1015
993
|
const config = requireLogin(args);
|
|
1016
|
-
const
|
|
1017
|
-
const connectors = await fetchServiceConnectors(config, app.uuid);
|
|
994
|
+
const connectors = await fetchServiceConnectors(config);
|
|
1018
995
|
if (args.options.json) {
|
|
1019
996
|
console.log(JSON.stringify(connectors, null, 2));
|
|
1020
997
|
return;
|
|
@@ -1040,8 +1017,7 @@ async function commandConnectorFetch(args) {
|
|
|
1040
1017
|
: null;
|
|
1041
1018
|
const payload = buildConnectorRequest({ connector, method, path: args.rest[1] ?? "", body });
|
|
1042
1019
|
const config = requireLogin(args);
|
|
1043
|
-
const
|
|
1044
|
-
const envelope = await runConnectorRequest(config, app.uuid, payload);
|
|
1020
|
+
const envelope = await runConnectorRequest(config, payload);
|
|
1045
1021
|
if (args.options.json) {
|
|
1046
1022
|
console.log(JSON.stringify(envelope, null, 2));
|
|
1047
1023
|
}
|
|
@@ -1065,8 +1041,7 @@ async function commandConnectorDocs(args) {
|
|
|
1065
1041
|
throw new CliError("Pass either --json or --openapi, not both.", 2);
|
|
1066
1042
|
}
|
|
1067
1043
|
const config = requireLogin(args);
|
|
1068
|
-
const
|
|
1069
|
-
const docs = await fetchServiceConnectorDocs(config, app.uuid, name);
|
|
1044
|
+
const docs = await fetchServiceConnectorDocs(config, name);
|
|
1070
1045
|
if (args.options.json) {
|
|
1071
1046
|
console.log(JSON.stringify(docs, null, 2));
|
|
1072
1047
|
return;
|
|
@@ -1081,14 +1056,14 @@ async function commandConnectorDocs(args) {
|
|
|
1081
1056
|
}
|
|
1082
1057
|
console.log(renderConnectorDocs(docs));
|
|
1083
1058
|
}
|
|
1084
|
-
async function fetchServiceConnectors(config
|
|
1085
|
-
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/
|
|
1059
|
+
async function fetchServiceConnectors(config) {
|
|
1060
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/service-connectors`);
|
|
1086
1061
|
if (resp.status === 401)
|
|
1087
1062
|
await reLoginOrThrow();
|
|
1088
1063
|
return (await readJsonOrThrow(resp, "List service connectors"));
|
|
1089
1064
|
}
|
|
1090
|
-
async function fetchServiceConnectorDocs(config,
|
|
1091
|
-
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/
|
|
1065
|
+
async function fetchServiceConnectorDocs(config, name) {
|
|
1066
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/service-connectors/${encodeURIComponent(name)}`);
|
|
1092
1067
|
if (resp.status === 401)
|
|
1093
1068
|
await reLoginOrThrow();
|
|
1094
1069
|
if (resp.status === 404) {
|
|
@@ -1096,8 +1071,8 @@ async function fetchServiceConnectorDocs(config, appUuid, name) {
|
|
|
1096
1071
|
}
|
|
1097
1072
|
return (await readJsonOrThrow(resp, "Connector docs"));
|
|
1098
1073
|
}
|
|
1099
|
-
async function runConnectorRequest(config,
|
|
1100
|
-
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/
|
|
1074
|
+
async function runConnectorRequest(config, body) {
|
|
1075
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/service-connectors/request`, {
|
|
1101
1076
|
method: "POST",
|
|
1102
1077
|
headers: { "Content-Type": "application/json" },
|
|
1103
1078
|
body: JSON.stringify(body),
|
|
@@ -1371,33 +1346,62 @@ async function commandDev(args) {
|
|
|
1371
1346
|
port: requestedPort,
|
|
1372
1347
|
config,
|
|
1373
1348
|
};
|
|
1349
|
+
// Asset mode needs its deps before we bind anything; the check is cheap and
|
|
1350
|
+
// failing early avoids leaving the proxy port bound on a misconfigured app.
|
|
1351
|
+
if (plan.mode === "asset")
|
|
1352
|
+
ensureAppDeps(cwd);
|
|
1353
|
+
const server = createServer((req, res) => {
|
|
1354
|
+
handleDevRequest(ctx, req, res).catch((err) => {
|
|
1355
|
+
sendJson(res, 500, { detail: err instanceof Error ? err.message : String(err) });
|
|
1356
|
+
});
|
|
1357
|
+
});
|
|
1358
|
+
server.on("upgrade", (req, socket, head) => proxyUpgrade(ctx, req, socket, head));
|
|
1359
|
+
// Bind the main proxy port FIRST, so the asset (Vite) child receives the real
|
|
1360
|
+
// RAILCODE_DEV_PORT even when the requested proxy port was busy.
|
|
1361
|
+
const actualPort = await listenLoopback(server, requestedPort);
|
|
1362
|
+
ctx.port = actualPort;
|
|
1363
|
+
if (actualPort !== requestedPort) {
|
|
1364
|
+
console.log(`Port ${requestedPort} was busy; using ${actualPort}.`);
|
|
1365
|
+
}
|
|
1374
1366
|
// Asset mode: start the app's own dev server (Vite) and reverse-proxy to it,
|
|
1375
1367
|
// tunnelling the HMR WebSocket. Static mode serves files straight from disk.
|
|
1376
1368
|
if (plan.mode === "asset") {
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1369
|
+
const startPort = assetServerPort(args, manifest);
|
|
1370
|
+
let asset;
|
|
1371
|
+
try {
|
|
1372
|
+
asset = await startAssetServer(plan, cwd, startPort, actualPort);
|
|
1373
|
+
}
|
|
1374
|
+
catch (err) {
|
|
1375
|
+
server.close();
|
|
1376
|
+
throw err;
|
|
1377
|
+
}
|
|
1378
|
+
const { child, reservation } = asset;
|
|
1379
|
+
if (reservation.port !== startPort) {
|
|
1380
|
+
console.log(`Port ${startPort} was busy; using ${reservation.port}.`);
|
|
1381
|
+
}
|
|
1380
1382
|
process.on("SIGINT", () => {
|
|
1381
1383
|
child.kill();
|
|
1384
|
+
reservation.release();
|
|
1382
1385
|
process.exit(0);
|
|
1383
1386
|
});
|
|
1384
1387
|
child.on("exit", (code) => {
|
|
1388
|
+
reservation.release();
|
|
1385
1389
|
if (code && code !== 0) {
|
|
1386
1390
|
console.error(`\nAsset dev server exited (code ${code}).`);
|
|
1387
1391
|
process.exit(code);
|
|
1388
1392
|
}
|
|
1389
1393
|
});
|
|
1390
|
-
|
|
1391
|
-
|
|
1394
|
+
try {
|
|
1395
|
+
await waitForPort(reservation.port, 30_000);
|
|
1396
|
+
}
|
|
1397
|
+
catch (err) {
|
|
1398
|
+
child.kill();
|
|
1399
|
+
reservation.release();
|
|
1400
|
+
server.close();
|
|
1401
|
+
throw err;
|
|
1402
|
+
}
|
|
1403
|
+
ctx.assetPort = reservation.port;
|
|
1392
1404
|
}
|
|
1393
|
-
const server = createServer((req, res) => {
|
|
1394
|
-
handleDevRequest(ctx, req, res).catch((err) => {
|
|
1395
|
-
sendJson(res, 500, { detail: err instanceof Error ? err.message : String(err) });
|
|
1396
|
-
});
|
|
1397
|
-
});
|
|
1398
|
-
server.on("upgrade", (req, socket, head) => proxyUpgrade(ctx, req, socket, head));
|
|
1399
|
-
const actualPort = await listenLoopback(server, requestedPort);
|
|
1400
|
-
ctx.port = actualPort;
|
|
1401
1405
|
printDevBanner(ctx, config);
|
|
1402
1406
|
}
|
|
1403
1407
|
function devPort(args) {
|
|
@@ -1410,6 +1414,126 @@ function devPort(args) {
|
|
|
1410
1414
|
}
|
|
1411
1415
|
return DEFAULT_DEV_PORT;
|
|
1412
1416
|
}
|
|
1417
|
+
// ---------------------------------------------------------------------------
|
|
1418
|
+
// Port reservation (asset / Vite dev server)
|
|
1419
|
+
//
|
|
1420
|
+
// Unlike the main proxy port — which the CLI binds itself, so a plain
|
|
1421
|
+
// EADDRINUSE retry loop (`listenLoopback`) suffices — the asset port is handed
|
|
1422
|
+
// to a *child* (Vite, `--strictPort`). Two concurrent `railcode dev` sessions
|
|
1423
|
+
// could each probe the same free port and then both tell Vite to bind it, and
|
|
1424
|
+
// the loser dies. To prevent that we take a cross-process filesystem lock under
|
|
1425
|
+
// `~/.railcode/dev/.ports/<port>` (reusing RAILCODE_HOME) *before* probing, and
|
|
1426
|
+
// hold it for the lifetime of the asset child. Ported from railcode-core.
|
|
1427
|
+
// ---------------------------------------------------------------------------
|
|
1428
|
+
const PORT_SEARCH_LIMIT = 100;
|
|
1429
|
+
const PORTS_DIR = join(RAILCODE_HOME, "dev", ".ports");
|
|
1430
|
+
function errorCode(err) {
|
|
1431
|
+
return typeof err === "object" && err !== null && "code" in err
|
|
1432
|
+
? String(err.code)
|
|
1433
|
+
: undefined;
|
|
1434
|
+
}
|
|
1435
|
+
function nowIso() {
|
|
1436
|
+
return new Date().toISOString();
|
|
1437
|
+
}
|
|
1438
|
+
function readJsonFile(path) {
|
|
1439
|
+
try {
|
|
1440
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1441
|
+
}
|
|
1442
|
+
catch {
|
|
1443
|
+
return null;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
function writeJsonFile(path, value) {
|
|
1447
|
+
writeFileSync(path, JSON.stringify(value), { mode: 0o600 });
|
|
1448
|
+
}
|
|
1449
|
+
// `process.kill(pid, 0)` sends no signal but performs the existence/permission
|
|
1450
|
+
// check: ESRCH => gone, EPERM => alive but owned by someone else (still running).
|
|
1451
|
+
function processIsRunning(pid) {
|
|
1452
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
1453
|
+
return false;
|
|
1454
|
+
try {
|
|
1455
|
+
process.kill(pid, 0);
|
|
1456
|
+
return true;
|
|
1457
|
+
}
|
|
1458
|
+
catch (err) {
|
|
1459
|
+
return errorCode(err) === "EPERM";
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
// Drop a lock dir whose owner process is no longer alive, so a crashed session
|
|
1463
|
+
// doesn't wedge a port forever. No-op if the owner is still running.
|
|
1464
|
+
function removeStalePortLock(lockDir) {
|
|
1465
|
+
const owner = readJsonFile(join(lockDir, "owner.json"));
|
|
1466
|
+
if (owner && typeof owner.pid === "number" && processIsRunning(owner.pid)) {
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
1470
|
+
}
|
|
1471
|
+
// Atomically claim `<portsDir>/<port>` via a non-recursive mkdir (fails EEXIST
|
|
1472
|
+
// if already held). Reclaims a stale lock once, then retries. Returns null if a
|
|
1473
|
+
// live session already holds it; otherwise a reservation whose release() frees
|
|
1474
|
+
// the lock.
|
|
1475
|
+
export function reserveRailcodePort(port, portsDir = PORTS_DIR) {
|
|
1476
|
+
const lockDir = join(portsDir, String(port));
|
|
1477
|
+
const claim = () => {
|
|
1478
|
+
try {
|
|
1479
|
+
mkdirSync(lockDir);
|
|
1480
|
+
return true;
|
|
1481
|
+
}
|
|
1482
|
+
catch (err) {
|
|
1483
|
+
if (errorCode(err) !== "EEXIST")
|
|
1484
|
+
throw err;
|
|
1485
|
+
return false;
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
if (!claim()) {
|
|
1489
|
+
removeStalePortLock(lockDir);
|
|
1490
|
+
if (!claim())
|
|
1491
|
+
return null;
|
|
1492
|
+
}
|
|
1493
|
+
writeJsonFile(join(lockDir, "owner.json"), { pid: process.pid, createdAt: nowIso() });
|
|
1494
|
+
let released = false;
|
|
1495
|
+
return {
|
|
1496
|
+
port,
|
|
1497
|
+
release: () => {
|
|
1498
|
+
if (released)
|
|
1499
|
+
return;
|
|
1500
|
+
released = true;
|
|
1501
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
1502
|
+
},
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
// Real OS probe: can we bind 127.0.0.1:<port> right now? Binds and immediately
|
|
1506
|
+
// closes. Complements the filesystem lock (which only guards against other
|
|
1507
|
+
// railcode sessions) by catching unrelated processes already on the port.
|
|
1508
|
+
export function canListenOnPort(port) {
|
|
1509
|
+
return new Promise((resolvePromise) => {
|
|
1510
|
+
const probe = createNetServer();
|
|
1511
|
+
probe.once("error", () => {
|
|
1512
|
+
probe.close();
|
|
1513
|
+
resolvePromise(false);
|
|
1514
|
+
});
|
|
1515
|
+
probe.listen(port, "127.0.0.1", () => {
|
|
1516
|
+
probe.close(() => resolvePromise(true));
|
|
1517
|
+
});
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
// Walk up from `startPort` (capped at PORT_SEARCH_LIMIT) and return the first
|
|
1521
|
+
// port that is both lockable (cross-process) and OS-bindable. The lock is taken
|
|
1522
|
+
// before the bind probe so two sessions can't both pass the probe on the same
|
|
1523
|
+
// port; if the lock succeeds but the OS port is busy we release and move on.
|
|
1524
|
+
export async function reserveAvailablePort(startPort, label, portsDir = PORTS_DIR) {
|
|
1525
|
+
mkdirSync(portsDir, { recursive: true });
|
|
1526
|
+
const end = Math.min(startPort + PORT_SEARCH_LIMIT, 65536);
|
|
1527
|
+
for (let port = startPort; port < end; port++) {
|
|
1528
|
+
const reservation = reserveRailcodePort(port, portsDir);
|
|
1529
|
+
if (!reservation)
|
|
1530
|
+
continue;
|
|
1531
|
+
if (await canListenOnPort(port))
|
|
1532
|
+
return reservation;
|
|
1533
|
+
reservation.release();
|
|
1534
|
+
}
|
|
1535
|
+
throw new CliError(`No free ${label} port found in range ${startPort}-${end - 1}.`, 1);
|
|
1536
|
+
}
|
|
1413
1537
|
function assetServerPort(args, manifest) {
|
|
1414
1538
|
const opt = args.options.assetPort;
|
|
1415
1539
|
if (typeof opt === "string") {
|
|
@@ -1428,7 +1552,12 @@ function ensureAppDeps(cwd) {
|
|
|
1428
1552
|
throw new CliError("Dependencies are not installed. Run `pnpm install` here, then `railcode dev` again.", 1);
|
|
1429
1553
|
}
|
|
1430
1554
|
}
|
|
1431
|
-
function startAssetServer(plan, cwd,
|
|
1555
|
+
async function startAssetServer(plan, cwd, startPort, devProxyPort) {
|
|
1556
|
+
// Reserve the first free port from `startPort` (cross-process lock + OS probe)
|
|
1557
|
+
// so a busy asset port auto-increments instead of dead-ending on Vite's
|
|
1558
|
+
// `--strictPort`. The reservation is held for the asset child's lifetime.
|
|
1559
|
+
const reservation = await reserveAvailablePort(startPort, "asset");
|
|
1560
|
+
const assetPort = reservation.port;
|
|
1432
1561
|
// For the detected Vite case, run the local vite binary directly with pinned
|
|
1433
1562
|
// host/port so the proxy target is known. (Going through `pnpm dev -- …` would
|
|
1434
1563
|
// forward the `--` separator to vite, which then ignores the flags.) A custom
|
|
@@ -1437,17 +1566,24 @@ function startAssetServer(plan, cwd, assetPort, devProxyPort) {
|
|
|
1437
1566
|
? `pnpm exec vite --host 127.0.0.1 --port ${assetPort} --strictPort --clearScreen false`
|
|
1438
1567
|
: plan.command;
|
|
1439
1568
|
console.log(` asset server: ${command}`);
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1569
|
+
try {
|
|
1570
|
+
const child = spawn(command, {
|
|
1571
|
+
cwd,
|
|
1572
|
+
shell: true,
|
|
1573
|
+
stdio: "inherit",
|
|
1574
|
+
env: {
|
|
1575
|
+
...process.env,
|
|
1576
|
+
PORT: String(assetPort),
|
|
1577
|
+
RAILCODE_DEV_PORT: String(devProxyPort),
|
|
1578
|
+
RAILCODE_ASSET_PORT: String(assetPort),
|
|
1579
|
+
},
|
|
1580
|
+
});
|
|
1581
|
+
return { child, reservation };
|
|
1582
|
+
}
|
|
1583
|
+
catch (err) {
|
|
1584
|
+
reservation.release();
|
|
1585
|
+
throw err;
|
|
1586
|
+
}
|
|
1451
1587
|
}
|
|
1452
1588
|
function waitForPort(port, timeoutMs) {
|
|
1453
1589
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -2198,11 +2334,27 @@ function printDevBanner(ctx, config) {
|
|
|
2198
2334
|
console.log("");
|
|
2199
2335
|
console.log(" Ctrl-C to stop.");
|
|
2200
2336
|
}
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2337
|
+
// Run the CLI only when invoked as a script (the `railcode` bin), not when this
|
|
2338
|
+
// module is imported — e.g. by unit tests that exercise the exported helpers.
|
|
2339
|
+
// realpath both sides so a bin symlink (global install) still matches.
|
|
2340
|
+
function invokedAsScript() {
|
|
2341
|
+
const arg = process.argv[1];
|
|
2342
|
+
if (!arg)
|
|
2343
|
+
return false;
|
|
2344
|
+
try {
|
|
2345
|
+
return realpathSync(arg) === realpathSync(fileURLToPath(import.meta.url));
|
|
2205
2346
|
}
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
}
|
|
2347
|
+
catch {
|
|
2348
|
+
return false;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
if (invokedAsScript()) {
|
|
2352
|
+
main().catch((error) => {
|
|
2353
|
+
if (error instanceof CliError) {
|
|
2354
|
+
console.error(error.message);
|
|
2355
|
+
process.exit(error.exitCode);
|
|
2356
|
+
}
|
|
2357
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2358
|
+
process.exit(1);
|
|
2359
|
+
});
|
|
2360
|
+
}
|
package/package.json
CHANGED
package/static/sdk.js
CHANGED
|
@@ -234,12 +234,14 @@
|
|
|
234
234
|
};
|
|
235
235
|
var runSQL = (engine, connection, query, params) => {
|
|
236
236
|
const trimmed = shorten(query.replace(/\s+/g, " ").trim(), QUERY_LABEL_MAX);
|
|
237
|
-
const label2 = `${engine}(${q(connection)}).runSQL(${q(trimmed)}${params && params.length ? `, ${shorten(JSON.stringify(params), PARAMS_LABEL_MAX)}` : ""})`;
|
|
237
|
+
const label2 = `${engine ?? "data"}(${q(connection)}).runSQL(${q(trimmed)}${params && params.length ? `, ${shorten(JSON.stringify(params), PARAMS_LABEL_MAX)}` : ""})`;
|
|
238
238
|
return track(
|
|
239
239
|
"sql",
|
|
240
240
|
label2,
|
|
241
241
|
() => call("POST", "/sql", {
|
|
242
|
-
|
|
242
|
+
// engine is advisory; omit it for the generic data() namespace so the backend
|
|
243
|
+
// dispatches on the connection's stored kind. postgres() pins engine="postgres".
|
|
244
|
+
body: { ...engine ? { engine } : {}, connection, query, params: params || [] }
|
|
243
245
|
}).then(toSqlRows)
|
|
244
246
|
);
|
|
245
247
|
};
|
|
@@ -251,7 +253,10 @@
|
|
|
251
253
|
ns.runSQL = (query, params) => runSQL(engine, "default", query, params);
|
|
252
254
|
return ns;
|
|
253
255
|
};
|
|
256
|
+
var data = namespace();
|
|
254
257
|
var postgres = namespace("postgres");
|
|
258
|
+
var bigquery = namespace("bigquery");
|
|
259
|
+
var snowflake = namespace("snowflake");
|
|
255
260
|
var dataConnectors = () => track("connections", "dataConnectors()", () => call("GET", "/connections"));
|
|
256
261
|
|
|
257
262
|
// ../sdk/src/util.ts
|
|
@@ -383,16 +388,16 @@
|
|
|
383
388
|
});
|
|
384
389
|
|
|
385
390
|
// ../sdk/src/files.ts
|
|
386
|
-
function contentTypeOf(
|
|
391
|
+
function contentTypeOf(data2, explicit) {
|
|
387
392
|
if (explicit) return explicit;
|
|
388
|
-
if (
|
|
393
|
+
if (data2 instanceof Blob && data2.type) return data2.type;
|
|
389
394
|
return "application/octet-stream";
|
|
390
395
|
}
|
|
391
|
-
function upload(name,
|
|
392
|
-
return track("files", `files.upload(${q(name)}, <blob>)`, () => doUpload(name,
|
|
396
|
+
function upload(name, data2, contentType) {
|
|
397
|
+
return track("files", `files.upload(${q(name)}, <blob>)`, () => doUpload(name, data2, contentType));
|
|
393
398
|
}
|
|
394
|
-
async function doUpload(name,
|
|
395
|
-
const ct = contentTypeOf(
|
|
399
|
+
async function doUpload(name, data2, contentType) {
|
|
400
|
+
const ct = contentTypeOf(data2, contentType);
|
|
396
401
|
const target2 = await call("POST", "/files", {
|
|
397
402
|
body: { name, content_type: ct }
|
|
398
403
|
});
|
|
@@ -400,14 +405,14 @@
|
|
|
400
405
|
if (target2.mode === "proxy") {
|
|
401
406
|
const resp2 = await send(new URL(target2.url, location.origin).toString(), {
|
|
402
407
|
method: target2.method,
|
|
403
|
-
body:
|
|
408
|
+
body: data2,
|
|
404
409
|
headers,
|
|
405
410
|
credentials: "include"
|
|
406
411
|
});
|
|
407
412
|
if (!resp2.ok) throw new Error(`upload failed: ${resp2.status} ${await resp2.text()}`);
|
|
408
413
|
return await resp2.json();
|
|
409
414
|
}
|
|
410
|
-
const resp = await fetch(target2.url, { method: target2.method, body:
|
|
415
|
+
const resp = await fetch(target2.url, { method: target2.method, body: data2, headers });
|
|
411
416
|
if (!resp.ok) throw new Error(`upload failed: ${resp.status}`);
|
|
412
417
|
return call("POST", "/files/finalize", { body: { name } });
|
|
413
418
|
}
|
|
@@ -535,7 +540,10 @@
|
|
|
535
540
|
target.appUsers = appUsers;
|
|
536
541
|
target.designSystem = designSystem;
|
|
537
542
|
target.llm = llm;
|
|
543
|
+
target.data = data;
|
|
538
544
|
target.postgres = postgres;
|
|
545
|
+
target.bigquery = bigquery;
|
|
546
|
+
target.snowflake = snowflake;
|
|
539
547
|
target.dataConnectors = dataConnectors;
|
|
540
548
|
target.connector = connector;
|
|
541
549
|
target.serviceConnectors = serviceConnectors;
|