railcode 0.1.11 → 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 CHANGED
@@ -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|mysql>` is inferred from the connector
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", "mysql"];
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("--engine requires a value (postgres or mysql).");
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";
@@ -1371,33 +1371,62 @@ async function commandDev(args) {
1371
1371
  port: requestedPort,
1372
1372
  config,
1373
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
+ }
1374
1391
  // Asset mode: start the app's own dev server (Vite) and reverse-proxy to it,
1375
1392
  // tunnelling the HMR WebSocket. Static mode serves files straight from disk.
1376
1393
  if (plan.mode === "asset") {
1377
- ensureAppDeps(cwd);
1378
- const assetPort = assetServerPort(args, manifest);
1379
- const child = startAssetServer(plan, cwd, assetPort, requestedPort);
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
+ }
1380
1407
  process.on("SIGINT", () => {
1381
1408
  child.kill();
1409
+ reservation.release();
1382
1410
  process.exit(0);
1383
1411
  });
1384
1412
  child.on("exit", (code) => {
1413
+ reservation.release();
1385
1414
  if (code && code !== 0) {
1386
1415
  console.error(`\nAsset dev server exited (code ${code}).`);
1387
1416
  process.exit(code);
1388
1417
  }
1389
1418
  });
1390
- await waitForPort(assetPort, 30_000);
1391
- ctx.assetPort = assetPort;
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;
1392
1429
  }
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
1430
  printDevBanner(ctx, config);
1402
1431
  }
1403
1432
  function devPort(args) {
@@ -1410,6 +1439,126 @@ function devPort(args) {
1410
1439
  }
1411
1440
  return DEFAULT_DEV_PORT;
1412
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
+ }
1413
1562
  function assetServerPort(args, manifest) {
1414
1563
  const opt = args.options.assetPort;
1415
1564
  if (typeof opt === "string") {
@@ -1428,7 +1577,12 @@ function ensureAppDeps(cwd) {
1428
1577
  throw new CliError("Dependencies are not installed. Run `pnpm install` here, then `railcode dev` again.", 1);
1429
1578
  }
1430
1579
  }
1431
- function startAssetServer(plan, cwd, assetPort, devProxyPort) {
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;
1432
1586
  // For the detected Vite case, run the local vite binary directly with pinned
1433
1587
  // host/port so the proxy target is known. (Going through `pnpm dev -- …` would
1434
1588
  // forward the `--` separator to vite, which then ignores the flags.) A custom
@@ -1437,17 +1591,24 @@ function startAssetServer(plan, cwd, assetPort, devProxyPort) {
1437
1591
  ? `pnpm exec vite --host 127.0.0.1 --port ${assetPort} --strictPort --clearScreen false`
1438
1592
  : plan.command;
1439
1593
  console.log(` asset server: ${command}`);
1440
- return spawn(command, {
1441
- cwd,
1442
- shell: true,
1443
- stdio: "inherit",
1444
- env: {
1445
- ...process.env,
1446
- PORT: String(assetPort),
1447
- RAILCODE_DEV_PORT: String(devProxyPort),
1448
- RAILCODE_ASSET_PORT: String(assetPort),
1449
- },
1450
- });
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
+ }
1451
1612
  }
1452
1613
  function waitForPort(port, timeoutMs) {
1453
1614
  const deadline = Date.now() + timeoutMs;
@@ -2198,11 +2359,27 @@ function printDevBanner(ctx, config) {
2198
2359
  console.log("");
2199
2360
  console.log(" Ctrl-C to stop.");
2200
2361
  }
2201
- main().catch((error) => {
2202
- if (error instanceof CliError) {
2203
- console.error(error.message);
2204
- process.exit(error.exitCode);
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));
2205
2371
  }
2206
- console.error(error instanceof Error ? error.message : String(error));
2207
- process.exit(1);
2208
- });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "railcode",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Developer CLI for the multi-tenant Railcode platform: log in, scaffold, and deploy static apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- body: { engine, connection, query, params: params || [] }
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(data, explicit) {
391
+ function contentTypeOf(data2, explicit) {
387
392
  if (explicit) return explicit;
388
- if (data instanceof Blob && data.type) return data.type;
393
+ if (data2 instanceof Blob && data2.type) return data2.type;
389
394
  return "application/octet-stream";
390
395
  }
391
- function upload(name, data, contentType) {
392
- return track("files", `files.upload(${q(name)}, <blob>)`, () => doUpload(name, data, contentType));
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, data, contentType) {
395
- const ct = contentTypeOf(data, contentType);
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: data,
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: data, headers });
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;