railcode 0.1.10 → 0.1.12
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 +2 -2
- package/dist/connectors.js +62 -8
- package/dist/db.js +4 -3
- package/dist/index.js +262 -38
- package/package.json +1 -1
- package/static/sdk.js +18 -10
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ railcode deploy Build (if configured) and deploy the app here
|
|
|
23
23
|
railcode db <list|query> ... List data connectors / run read-only SQL
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
- **`login`** prompts for the server URL (default `
|
|
26
|
+
- **`login`** prompts for the server URL (default `https://api.railcode.app`),
|
|
27
27
|
email, and password. It exchanges credentials for a short-lived JWT, mints a
|
|
28
28
|
personal API token, resolves your organization, and saves everything to
|
|
29
29
|
`~/.railcode/config.json` (dir `0700`, file `0600`). The JWT is used once and
|
|
@@ -42,7 +42,7 @@ railcode db <list|query> ... List data connectors / run read-only SQL
|
|
|
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/connectors.js
CHANGED
|
@@ -5,20 +5,74 @@
|
|
|
5
5
|
import { formatTable } from "./db.js";
|
|
6
6
|
export class ConnectorError extends Error {
|
|
7
7
|
}
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
8
|
+
// What goes in the optional `docs` column: which kinds of documentation a connector
|
|
9
|
+
// exposes (pull the content with `railcode connector docs <name>`).
|
|
10
|
+
function docsCell(c) {
|
|
11
|
+
const parts = [];
|
|
12
|
+
if (c.has_docs)
|
|
13
|
+
parts.push("api");
|
|
14
|
+
if (c.has_openapi)
|
|
15
|
+
parts.push("openapi");
|
|
16
|
+
return parts.join(", ");
|
|
17
|
+
}
|
|
18
|
+
// Render the connector list as a table. The `description` and `docs` columns are each
|
|
19
|
+
// only added when at least one connector actually has that, so the common (bare) case
|
|
20
|
+
// stays narrow.
|
|
11
21
|
export function connectorTable(connectors) {
|
|
12
22
|
const hasDescription = connectors.some((c) => (c.description ?? "").length > 0);
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
const hasDocs = connectors.some((c) => c.has_docs || c.has_openapi);
|
|
24
|
+
const columns = ["name", "auth_type", "allowed_methods"];
|
|
25
|
+
if (hasDescription)
|
|
26
|
+
columns.push("description");
|
|
27
|
+
if (hasDocs)
|
|
28
|
+
columns.push("docs");
|
|
16
29
|
const rows = connectors.map((c) => {
|
|
17
|
-
const
|
|
18
|
-
|
|
30
|
+
const row = [c.name, c.auth_type, (c.allowed_methods ?? []).join(", ")];
|
|
31
|
+
if (hasDescription)
|
|
32
|
+
row.push(c.description ?? "");
|
|
33
|
+
if (hasDocs)
|
|
34
|
+
row.push(docsCell(c));
|
|
35
|
+
return row;
|
|
19
36
|
});
|
|
20
37
|
return formatTable(columns, rows);
|
|
21
38
|
}
|
|
39
|
+
// What `--openapi` emits for one connector: the inline spec verbatim if present,
|
|
40
|
+
// otherwise the spec URL, otherwise null (no spec configured).
|
|
41
|
+
export function connectorOpenapiOutput(docs) {
|
|
42
|
+
if (docs.openapi_spec_inline)
|
|
43
|
+
return docs.openapi_spec_inline;
|
|
44
|
+
if (docs.openapi_spec_link)
|
|
45
|
+
return docs.openapi_spec_link;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
// Human render of a connector's docs bundle: identity line, then usage instructions,
|
|
49
|
+
// API docs (link or inline), and the OpenAPI spec (link or inline) when present.
|
|
50
|
+
export function renderConnectorDocs(docs) {
|
|
51
|
+
const methods = (docs.allowed_methods ?? []).join(", ") || "(all)";
|
|
52
|
+
const sections = [`${docs.name} (${docs.auth_type}) — methods: ${methods}`];
|
|
53
|
+
if (docs.description)
|
|
54
|
+
sections.push(docs.description);
|
|
55
|
+
const hasAny = docs.usage_instructions ||
|
|
56
|
+
docs.api_docs_link ||
|
|
57
|
+
docs.api_docs_inline ||
|
|
58
|
+
docs.openapi_spec_link ||
|
|
59
|
+
docs.openapi_spec_inline;
|
|
60
|
+
if (!hasAny) {
|
|
61
|
+
sections.push(`No documentation is configured for connector "${docs.name}".`);
|
|
62
|
+
return sections.join("\n\n");
|
|
63
|
+
}
|
|
64
|
+
if (docs.usage_instructions)
|
|
65
|
+
sections.push(`Usage instructions:\n${docs.usage_instructions}`);
|
|
66
|
+
if (docs.api_docs_link)
|
|
67
|
+
sections.push(`API docs: ${docs.api_docs_link}`);
|
|
68
|
+
else if (docs.api_docs_inline)
|
|
69
|
+
sections.push(`API docs (inline):\n${docs.api_docs_inline}`);
|
|
70
|
+
if (docs.openapi_spec_link)
|
|
71
|
+
sections.push(`OpenAPI spec: ${docs.openapi_spec_link}`);
|
|
72
|
+
else if (docs.openapi_spec_inline)
|
|
73
|
+
sections.push(`OpenAPI spec (inline):\n${docs.openapi_spec_inline}`);
|
|
74
|
+
return sections.join("\n\n");
|
|
75
|
+
}
|
|
22
76
|
// `--connector <name>` is required for `fetch`. Reject a missing value or a bare
|
|
23
77
|
// flag with a usage message.
|
|
24
78
|
export function parseConnectorOption(raw) {
|
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";
|
|
@@ -12,7 +12,7 @@ import { createInterface } from "node:readline/promises";
|
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { KvQueryError, kvCount, kvQuery, } from "./dev/kv-query.js";
|
|
14
14
|
import { DbError, formatTable, inferEngine, parseEngine, parseSqlParams, pickSqlSource, } from "./db.js";
|
|
15
|
-
import { ConnectorError, buildConnectorRequest, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderProxyResponse, } from "./connectors.js";
|
|
15
|
+
import { ConnectorError, buildConnectorRequest, connectorOpenapiOutput, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderConnectorDocs, renderProxyResponse, } from "./connectors.js";
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
// Constants
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
@@ -20,7 +20,7 @@ const RAILCODE_HOME = process.env.RAILCODE_HOME || join(homedir(), ".railcode");
|
|
|
20
20
|
const CONFIG_PATH = join(RAILCODE_HOME, "config.json");
|
|
21
21
|
const CLI_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
22
22
|
const VERSION = readCliPackageVersion();
|
|
23
|
-
const DEFAULT_API_URL = "
|
|
23
|
+
const DEFAULT_API_URL = "https://api.railcode.app";
|
|
24
24
|
const MANIFEST_NAME = "railcode.json";
|
|
25
25
|
const CLI_AUTH_CALLBACK_PATH = "/railcode-cli/callback";
|
|
26
26
|
const CLI_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -35,7 +35,7 @@ Usage:
|
|
|
35
35
|
railcode init <app> [--template static|react]
|
|
36
36
|
railcode design-system Print your org's design-system guidance (markdown)
|
|
37
37
|
railcode db <list|query> ... List data connectors / run read-only SQL
|
|
38
|
-
railcode connector <list|fetch> ... List
|
|
38
|
+
railcode connector <list|docs|fetch> ... List connectors / show API docs / proxy a call
|
|
39
39
|
railcode --version
|
|
40
40
|
railcode --help
|
|
41
41
|
|
|
@@ -951,14 +951,19 @@ const CONNECTOR_HELP = `railcode connector — service connectors
|
|
|
951
951
|
|
|
952
952
|
Usage:
|
|
953
953
|
railcode connector list List this org's service connectors
|
|
954
|
+
railcode connector docs <name> [opts] Show how to call a connector's API
|
|
954
955
|
railcode connector fetch <path> --connector <name> [opts]
|
|
955
956
|
Proxy one HTTP call through a connector
|
|
956
957
|
|
|
957
|
-
Aliases: connector = connectors; list = ls, connectors; fetch = request.
|
|
958
|
+
Aliases: connector = connectors; list = ls, connectors; docs = doc; fetch = request.
|
|
958
959
|
|
|
959
960
|
List options:
|
|
960
961
|
--json Print the raw connector array
|
|
961
962
|
|
|
963
|
+
Docs options:
|
|
964
|
+
--json Print the raw docs object
|
|
965
|
+
--openapi Print only the OpenAPI spec (inline text, else its URL)
|
|
966
|
+
|
|
962
967
|
Fetch options:
|
|
963
968
|
--connector <name> Connector to call (required)
|
|
964
969
|
--method <verb> HTTP method (default GET; must be allowed by the connector)
|
|
@@ -979,6 +984,10 @@ async function commandConnector(args) {
|
|
|
979
984
|
case "connectors":
|
|
980
985
|
await commandConnectorList(args);
|
|
981
986
|
return;
|
|
987
|
+
case "docs":
|
|
988
|
+
case "doc":
|
|
989
|
+
await commandConnectorDocs(args);
|
|
990
|
+
return;
|
|
982
991
|
case "fetch":
|
|
983
992
|
case "request":
|
|
984
993
|
await commandConnectorFetch(args);
|
|
@@ -1043,12 +1052,50 @@ async function commandConnectorFetch(args) {
|
|
|
1043
1052
|
// it, but exit non-zero so scripts can tell success from failure.
|
|
1044
1053
|
process.exitCode = proxyExitCode(envelope);
|
|
1045
1054
|
}
|
|
1055
|
+
async function commandConnectorDocs(args) {
|
|
1056
|
+
assertKnownConnectorOptions(args, ["json", "openapi", "apiUrl"]);
|
|
1057
|
+
const name = args.rest[1];
|
|
1058
|
+
if (!name) {
|
|
1059
|
+
throw new CliError("Usage: railcode connector docs <name>. Run `railcode connector list` to see names.", 2);
|
|
1060
|
+
}
|
|
1061
|
+
if (args.rest.length > 2) {
|
|
1062
|
+
throw new CliError(`railcode connector docs takes a single connector name (got "${args.rest[2]}").`, 2);
|
|
1063
|
+
}
|
|
1064
|
+
if (args.options.json && args.options.openapi) {
|
|
1065
|
+
throw new CliError("Pass either --json or --openapi, not both.", 2);
|
|
1066
|
+
}
|
|
1067
|
+
const config = requireLogin(args);
|
|
1068
|
+
const app = await resolveExistingApp(config, requireAppSlug());
|
|
1069
|
+
const docs = await fetchServiceConnectorDocs(config, app.uuid, name);
|
|
1070
|
+
if (args.options.json) {
|
|
1071
|
+
console.log(JSON.stringify(docs, null, 2));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (args.options.openapi) {
|
|
1075
|
+
const spec = connectorOpenapiOutput(docs);
|
|
1076
|
+
if (spec === null) {
|
|
1077
|
+
throw new CliError(`Connector "${name}" has no OpenAPI spec.`, 1);
|
|
1078
|
+
}
|
|
1079
|
+
console.log(spec);
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
console.log(renderConnectorDocs(docs));
|
|
1083
|
+
}
|
|
1046
1084
|
async function fetchServiceConnectors(config, appUuid) {
|
|
1047
1085
|
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/service-connectors`);
|
|
1048
1086
|
if (resp.status === 401)
|
|
1049
1087
|
await reLoginOrThrow();
|
|
1050
1088
|
return (await readJsonOrThrow(resp, "List service connectors"));
|
|
1051
1089
|
}
|
|
1090
|
+
async function fetchServiceConnectorDocs(config, appUuid, name) {
|
|
1091
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/service-connectors/${encodeURIComponent(name)}`);
|
|
1092
|
+
if (resp.status === 401)
|
|
1093
|
+
await reLoginOrThrow();
|
|
1094
|
+
if (resp.status === 404) {
|
|
1095
|
+
throw new CliError(`Connector "${name}" not found (or not enabled) in your org. Run \`railcode connector list\`.`, 1);
|
|
1096
|
+
}
|
|
1097
|
+
return (await readJsonOrThrow(resp, "Connector docs"));
|
|
1098
|
+
}
|
|
1052
1099
|
async function runConnectorRequest(config, appUuid, body) {
|
|
1053
1100
|
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/service-connectors/request`, {
|
|
1054
1101
|
method: "POST",
|
|
@@ -1324,33 +1371,62 @@ async function commandDev(args) {
|
|
|
1324
1371
|
port: requestedPort,
|
|
1325
1372
|
config,
|
|
1326
1373
|
};
|
|
1374
|
+
// Asset mode needs its deps before we bind anything; the check is cheap and
|
|
1375
|
+
// failing early avoids leaving the proxy port bound on a misconfigured app.
|
|
1376
|
+
if (plan.mode === "asset")
|
|
1377
|
+
ensureAppDeps(cwd);
|
|
1378
|
+
const server = createServer((req, res) => {
|
|
1379
|
+
handleDevRequest(ctx, req, res).catch((err) => {
|
|
1380
|
+
sendJson(res, 500, { detail: err instanceof Error ? err.message : String(err) });
|
|
1381
|
+
});
|
|
1382
|
+
});
|
|
1383
|
+
server.on("upgrade", (req, socket, head) => proxyUpgrade(ctx, req, socket, head));
|
|
1384
|
+
// Bind the main proxy port FIRST, so the asset (Vite) child receives the real
|
|
1385
|
+
// RAILCODE_DEV_PORT even when the requested proxy port was busy.
|
|
1386
|
+
const actualPort = await listenLoopback(server, requestedPort);
|
|
1387
|
+
ctx.port = actualPort;
|
|
1388
|
+
if (actualPort !== requestedPort) {
|
|
1389
|
+
console.log(`Port ${requestedPort} was busy; using ${actualPort}.`);
|
|
1390
|
+
}
|
|
1327
1391
|
// Asset mode: start the app's own dev server (Vite) and reverse-proxy to it,
|
|
1328
1392
|
// tunnelling the HMR WebSocket. Static mode serves files straight from disk.
|
|
1329
1393
|
if (plan.mode === "asset") {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1394
|
+
const startPort = assetServerPort(args, manifest);
|
|
1395
|
+
let asset;
|
|
1396
|
+
try {
|
|
1397
|
+
asset = await startAssetServer(plan, cwd, startPort, actualPort);
|
|
1398
|
+
}
|
|
1399
|
+
catch (err) {
|
|
1400
|
+
server.close();
|
|
1401
|
+
throw err;
|
|
1402
|
+
}
|
|
1403
|
+
const { child, reservation } = asset;
|
|
1404
|
+
if (reservation.port !== startPort) {
|
|
1405
|
+
console.log(`Port ${startPort} was busy; using ${reservation.port}.`);
|
|
1406
|
+
}
|
|
1333
1407
|
process.on("SIGINT", () => {
|
|
1334
1408
|
child.kill();
|
|
1409
|
+
reservation.release();
|
|
1335
1410
|
process.exit(0);
|
|
1336
1411
|
});
|
|
1337
1412
|
child.on("exit", (code) => {
|
|
1413
|
+
reservation.release();
|
|
1338
1414
|
if (code && code !== 0) {
|
|
1339
1415
|
console.error(`\nAsset dev server exited (code ${code}).`);
|
|
1340
1416
|
process.exit(code);
|
|
1341
1417
|
}
|
|
1342
1418
|
});
|
|
1343
|
-
|
|
1344
|
-
|
|
1419
|
+
try {
|
|
1420
|
+
await waitForPort(reservation.port, 30_000);
|
|
1421
|
+
}
|
|
1422
|
+
catch (err) {
|
|
1423
|
+
child.kill();
|
|
1424
|
+
reservation.release();
|
|
1425
|
+
server.close();
|
|
1426
|
+
throw err;
|
|
1427
|
+
}
|
|
1428
|
+
ctx.assetPort = reservation.port;
|
|
1345
1429
|
}
|
|
1346
|
-
const server = createServer((req, res) => {
|
|
1347
|
-
handleDevRequest(ctx, req, res).catch((err) => {
|
|
1348
|
-
sendJson(res, 500, { detail: err instanceof Error ? err.message : String(err) });
|
|
1349
|
-
});
|
|
1350
|
-
});
|
|
1351
|
-
server.on("upgrade", (req, socket, head) => proxyUpgrade(ctx, req, socket, head));
|
|
1352
|
-
const actualPort = await listenLoopback(server, requestedPort);
|
|
1353
|
-
ctx.port = actualPort;
|
|
1354
1430
|
printDevBanner(ctx, config);
|
|
1355
1431
|
}
|
|
1356
1432
|
function devPort(args) {
|
|
@@ -1363,6 +1439,126 @@ function devPort(args) {
|
|
|
1363
1439
|
}
|
|
1364
1440
|
return DEFAULT_DEV_PORT;
|
|
1365
1441
|
}
|
|
1442
|
+
// ---------------------------------------------------------------------------
|
|
1443
|
+
// Port reservation (asset / Vite dev server)
|
|
1444
|
+
//
|
|
1445
|
+
// Unlike the main proxy port — which the CLI binds itself, so a plain
|
|
1446
|
+
// EADDRINUSE retry loop (`listenLoopback`) suffices — the asset port is handed
|
|
1447
|
+
// to a *child* (Vite, `--strictPort`). Two concurrent `railcode dev` sessions
|
|
1448
|
+
// could each probe the same free port and then both tell Vite to bind it, and
|
|
1449
|
+
// the loser dies. To prevent that we take a cross-process filesystem lock under
|
|
1450
|
+
// `~/.railcode/dev/.ports/<port>` (reusing RAILCODE_HOME) *before* probing, and
|
|
1451
|
+
// hold it for the lifetime of the asset child. Ported from railcode-core.
|
|
1452
|
+
// ---------------------------------------------------------------------------
|
|
1453
|
+
const PORT_SEARCH_LIMIT = 100;
|
|
1454
|
+
const PORTS_DIR = join(RAILCODE_HOME, "dev", ".ports");
|
|
1455
|
+
function errorCode(err) {
|
|
1456
|
+
return typeof err === "object" && err !== null && "code" in err
|
|
1457
|
+
? String(err.code)
|
|
1458
|
+
: undefined;
|
|
1459
|
+
}
|
|
1460
|
+
function nowIso() {
|
|
1461
|
+
return new Date().toISOString();
|
|
1462
|
+
}
|
|
1463
|
+
function readJsonFile(path) {
|
|
1464
|
+
try {
|
|
1465
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
1466
|
+
}
|
|
1467
|
+
catch {
|
|
1468
|
+
return null;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
function writeJsonFile(path, value) {
|
|
1472
|
+
writeFileSync(path, JSON.stringify(value), { mode: 0o600 });
|
|
1473
|
+
}
|
|
1474
|
+
// `process.kill(pid, 0)` sends no signal but performs the existence/permission
|
|
1475
|
+
// check: ESRCH => gone, EPERM => alive but owned by someone else (still running).
|
|
1476
|
+
function processIsRunning(pid) {
|
|
1477
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
1478
|
+
return false;
|
|
1479
|
+
try {
|
|
1480
|
+
process.kill(pid, 0);
|
|
1481
|
+
return true;
|
|
1482
|
+
}
|
|
1483
|
+
catch (err) {
|
|
1484
|
+
return errorCode(err) === "EPERM";
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
// Drop a lock dir whose owner process is no longer alive, so a crashed session
|
|
1488
|
+
// doesn't wedge a port forever. No-op if the owner is still running.
|
|
1489
|
+
function removeStalePortLock(lockDir) {
|
|
1490
|
+
const owner = readJsonFile(join(lockDir, "owner.json"));
|
|
1491
|
+
if (owner && typeof owner.pid === "number" && processIsRunning(owner.pid)) {
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
1495
|
+
}
|
|
1496
|
+
// Atomically claim `<portsDir>/<port>` via a non-recursive mkdir (fails EEXIST
|
|
1497
|
+
// if already held). Reclaims a stale lock once, then retries. Returns null if a
|
|
1498
|
+
// live session already holds it; otherwise a reservation whose release() frees
|
|
1499
|
+
// the lock.
|
|
1500
|
+
export function reserveRailcodePort(port, portsDir = PORTS_DIR) {
|
|
1501
|
+
const lockDir = join(portsDir, String(port));
|
|
1502
|
+
const claim = () => {
|
|
1503
|
+
try {
|
|
1504
|
+
mkdirSync(lockDir);
|
|
1505
|
+
return true;
|
|
1506
|
+
}
|
|
1507
|
+
catch (err) {
|
|
1508
|
+
if (errorCode(err) !== "EEXIST")
|
|
1509
|
+
throw err;
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
};
|
|
1513
|
+
if (!claim()) {
|
|
1514
|
+
removeStalePortLock(lockDir);
|
|
1515
|
+
if (!claim())
|
|
1516
|
+
return null;
|
|
1517
|
+
}
|
|
1518
|
+
writeJsonFile(join(lockDir, "owner.json"), { pid: process.pid, createdAt: nowIso() });
|
|
1519
|
+
let released = false;
|
|
1520
|
+
return {
|
|
1521
|
+
port,
|
|
1522
|
+
release: () => {
|
|
1523
|
+
if (released)
|
|
1524
|
+
return;
|
|
1525
|
+
released = true;
|
|
1526
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
1527
|
+
},
|
|
1528
|
+
};
|
|
1529
|
+
}
|
|
1530
|
+
// Real OS probe: can we bind 127.0.0.1:<port> right now? Binds and immediately
|
|
1531
|
+
// closes. Complements the filesystem lock (which only guards against other
|
|
1532
|
+
// railcode sessions) by catching unrelated processes already on the port.
|
|
1533
|
+
export function canListenOnPort(port) {
|
|
1534
|
+
return new Promise((resolvePromise) => {
|
|
1535
|
+
const probe = createNetServer();
|
|
1536
|
+
probe.once("error", () => {
|
|
1537
|
+
probe.close();
|
|
1538
|
+
resolvePromise(false);
|
|
1539
|
+
});
|
|
1540
|
+
probe.listen(port, "127.0.0.1", () => {
|
|
1541
|
+
probe.close(() => resolvePromise(true));
|
|
1542
|
+
});
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
// Walk up from `startPort` (capped at PORT_SEARCH_LIMIT) and return the first
|
|
1546
|
+
// port that is both lockable (cross-process) and OS-bindable. The lock is taken
|
|
1547
|
+
// before the bind probe so two sessions can't both pass the probe on the same
|
|
1548
|
+
// port; if the lock succeeds but the OS port is busy we release and move on.
|
|
1549
|
+
export async function reserveAvailablePort(startPort, label, portsDir = PORTS_DIR) {
|
|
1550
|
+
mkdirSync(portsDir, { recursive: true });
|
|
1551
|
+
const end = Math.min(startPort + PORT_SEARCH_LIMIT, 65536);
|
|
1552
|
+
for (let port = startPort; port < end; port++) {
|
|
1553
|
+
const reservation = reserveRailcodePort(port, portsDir);
|
|
1554
|
+
if (!reservation)
|
|
1555
|
+
continue;
|
|
1556
|
+
if (await canListenOnPort(port))
|
|
1557
|
+
return reservation;
|
|
1558
|
+
reservation.release();
|
|
1559
|
+
}
|
|
1560
|
+
throw new CliError(`No free ${label} port found in range ${startPort}-${end - 1}.`, 1);
|
|
1561
|
+
}
|
|
1366
1562
|
function assetServerPort(args, manifest) {
|
|
1367
1563
|
const opt = args.options.assetPort;
|
|
1368
1564
|
if (typeof opt === "string") {
|
|
@@ -1381,7 +1577,12 @@ function ensureAppDeps(cwd) {
|
|
|
1381
1577
|
throw new CliError("Dependencies are not installed. Run `pnpm install` here, then `railcode dev` again.", 1);
|
|
1382
1578
|
}
|
|
1383
1579
|
}
|
|
1384
|
-
function startAssetServer(plan, cwd,
|
|
1580
|
+
async function startAssetServer(plan, cwd, startPort, devProxyPort) {
|
|
1581
|
+
// Reserve the first free port from `startPort` (cross-process lock + OS probe)
|
|
1582
|
+
// so a busy asset port auto-increments instead of dead-ending on Vite's
|
|
1583
|
+
// `--strictPort`. The reservation is held for the asset child's lifetime.
|
|
1584
|
+
const reservation = await reserveAvailablePort(startPort, "asset");
|
|
1585
|
+
const assetPort = reservation.port;
|
|
1385
1586
|
// For the detected Vite case, run the local vite binary directly with pinned
|
|
1386
1587
|
// host/port so the proxy target is known. (Going through `pnpm dev -- …` would
|
|
1387
1588
|
// forward the `--` separator to vite, which then ignores the flags.) A custom
|
|
@@ -1390,17 +1591,24 @@ function startAssetServer(plan, cwd, assetPort, devProxyPort) {
|
|
|
1390
1591
|
? `pnpm exec vite --host 127.0.0.1 --port ${assetPort} --strictPort --clearScreen false`
|
|
1391
1592
|
: plan.command;
|
|
1392
1593
|
console.log(` asset server: ${command}`);
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1594
|
+
try {
|
|
1595
|
+
const child = spawn(command, {
|
|
1596
|
+
cwd,
|
|
1597
|
+
shell: true,
|
|
1598
|
+
stdio: "inherit",
|
|
1599
|
+
env: {
|
|
1600
|
+
...process.env,
|
|
1601
|
+
PORT: String(assetPort),
|
|
1602
|
+
RAILCODE_DEV_PORT: String(devProxyPort),
|
|
1603
|
+
RAILCODE_ASSET_PORT: String(assetPort),
|
|
1604
|
+
},
|
|
1605
|
+
});
|
|
1606
|
+
return { child, reservation };
|
|
1607
|
+
}
|
|
1608
|
+
catch (err) {
|
|
1609
|
+
reservation.release();
|
|
1610
|
+
throw err;
|
|
1611
|
+
}
|
|
1404
1612
|
}
|
|
1405
1613
|
function waitForPort(port, timeoutMs) {
|
|
1406
1614
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -2151,11 +2359,27 @@ function printDevBanner(ctx, config) {
|
|
|
2151
2359
|
console.log("");
|
|
2152
2360
|
console.log(" Ctrl-C to stop.");
|
|
2153
2361
|
}
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2362
|
+
// Run the CLI only when invoked as a script (the `railcode` bin), not when this
|
|
2363
|
+
// module is imported — e.g. by unit tests that exercise the exported helpers.
|
|
2364
|
+
// realpath both sides so a bin symlink (global install) still matches.
|
|
2365
|
+
function invokedAsScript() {
|
|
2366
|
+
const arg = process.argv[1];
|
|
2367
|
+
if (!arg)
|
|
2368
|
+
return false;
|
|
2369
|
+
try {
|
|
2370
|
+
return realpathSync(arg) === realpathSync(fileURLToPath(import.meta.url));
|
|
2158
2371
|
}
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
}
|
|
2372
|
+
catch {
|
|
2373
|
+
return false;
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
if (invokedAsScript()) {
|
|
2377
|
+
main().catch((error) => {
|
|
2378
|
+
if (error instanceof CliError) {
|
|
2379
|
+
console.error(error.message);
|
|
2380
|
+
process.exit(error.exitCode);
|
|
2381
|
+
}
|
|
2382
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2383
|
+
process.exit(1);
|
|
2384
|
+
});
|
|
2385
|
+
}
|
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;
|