railcode 0.1.5 → 0.1.6

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.
Files changed (3) hide show
  1. package/README.md +7 -0
  2. package/dist/index.js +269 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -73,6 +73,13 @@ you enter a bare domain.
73
73
  `/v1/config/design-system` using the saved browser-authorized API token or
74
74
  `RAILCODE_API_TOKEN` and prints it to stdout.
75
75
 
76
+ `railcode db list` and `railcode db query "<sql>"` read the admin-configured data
77
+ connectors from the terminal — listing them (`GET /v1/connections`) or running a
78
+ read-only SQL query against one (`POST /v1/sql`). The engine is inferred from the
79
+ named connector (override with `--engine`); use `--connection <name>` to pick a
80
+ connector, `--params '<json-array>'` for `$1, $2, ...` placeholders, and `--json`
81
+ for the raw response. Authenticates with the saved token or `RAILCODE_API_TOKEN`.
82
+
76
83
  `railcode deploy` runs from the current Railcode app directory, builds the app,
77
84
  and publishes the inferred app output over HTTP to the canonical API host. It
78
85
  uses a saved Railcode token, prints a browser authorization link when needed, or
package/dist/index.js CHANGED
@@ -26,6 +26,8 @@ Usage:
26
26
  railcode login [--api-url <url>]
27
27
  railcode deploy [--private | --public]
28
28
  railcode access [public|private|restricted] [--users <a,b>]
29
+ railcode db list
30
+ railcode db query "<sql>" [--connection <name>] [--engine <postgres|mysql>]
29
31
  railcode get design-system
30
32
  railcode init <app>
31
33
  railcode upgrade
@@ -59,6 +61,9 @@ async function main() {
59
61
  case "access":
60
62
  await commandAccess(args);
61
63
  return;
64
+ case "db":
65
+ await commandDb(args);
66
+ return;
62
67
  case "get":
63
68
  await commandGet(args);
64
69
  return;
@@ -1594,6 +1599,270 @@ function appUrlFromOrigin(app, origin) {
1594
1599
  url.hash = "";
1595
1600
  return url.toString();
1596
1601
  }
1602
+ const DB_ENGINES = new Set(["postgres", "mysql"]);
1603
+ const DB_HELP = `Query Railcode data connectors.
1604
+
1605
+ Usage:
1606
+ railcode db list
1607
+ railcode db query "<sql>" [options]
1608
+
1609
+ Commands:
1610
+ list List configured data connectors (name + engine).
1611
+ query "<sql>" Run a read-only SQL query against a connector.
1612
+
1613
+ Query options:
1614
+ --connection <name> Connector to query (default: default).
1615
+ --engine <postgres|mysql> Override the engine. Inferred from the connector by default.
1616
+ --params '<json-array>' Params for $1, $2, ... placeholders, e.g. --params '[42,"ok"]'.
1617
+ --file <path> Read SQL from a file instead of an argument.
1618
+ --json Print raw JSON instead of a table.
1619
+
1620
+ Options:
1621
+ --api-url <url> Railcode auth/API URL. Defaults to saved config or railcode.json.
1622
+
1623
+ Examples:
1624
+ railcode db list
1625
+ railcode db query "select id, email from users limit 5"
1626
+ railcode db query "select * from orders where total > $1" --params '[100]'
1627
+ railcode db query "select 1" --connection analytics --engine mysql
1628
+ `;
1629
+ async function commandDb(args) {
1630
+ const subcommand = args.rest[0];
1631
+ const rest = { ...args, rest: args.rest.slice(1) };
1632
+ switch (subcommand) {
1633
+ case "list":
1634
+ case "ls":
1635
+ case "connections":
1636
+ await commandDbList(rest);
1637
+ return;
1638
+ case "query":
1639
+ case "sql":
1640
+ await commandDbQuery(rest);
1641
+ return;
1642
+ case undefined:
1643
+ case "help":
1644
+ console.log(DB_HELP);
1645
+ return;
1646
+ default:
1647
+ if (booleanOption(args, "help")) {
1648
+ console.log(DB_HELP);
1649
+ return;
1650
+ }
1651
+ throw new CliError(`Unknown db command: ${subcommand}\n\n${DB_HELP}`, 2);
1652
+ }
1653
+ }
1654
+ async function commandDbList(args) {
1655
+ if (booleanOption(args, "help") || args.rest[0] === "help") {
1656
+ console.log(DB_HELP);
1657
+ return;
1658
+ }
1659
+ rejectUnknownOptions(args, ["help", "json", "apiUrl"], "db list");
1660
+ if (args.rest.length > 0) {
1661
+ throw new CliError("railcode db list does not accept positional arguments.", 2);
1662
+ }
1663
+ const response = await dbAuthedRequest(args, "Data connectors list", fetchConnections);
1664
+ const connectors = (await readDbResponse(response, "Data connectors list"));
1665
+ printConnectors(connectors, booleanOption(args, "json"));
1666
+ }
1667
+ async function commandDbQuery(args) {
1668
+ if (booleanOption(args, "help") || args.rest[0] === "help") {
1669
+ console.log(DB_HELP);
1670
+ return;
1671
+ }
1672
+ rejectUnknownOptions(args, ["help", "json", "apiUrl", "connection", "engine", "params", "file"], "db query");
1673
+ const query = resolveQuerySql(args);
1674
+ const connection = (stringOption(args, "connection") || "default").trim() || "default";
1675
+ const params = parseSqlParams(args);
1676
+ const engine = parseEngineOption(args) ?? (await resolveConnectionEngine(args, connection));
1677
+ const body = { engine, connection, query, params };
1678
+ const response = await dbAuthedRequest(args, "SQL query", (apiUrl, config) => postSql(apiUrl, config, body));
1679
+ const result = (await readDbResponse(response, "SQL query", "Run `railcode db list` to see configured connectors."));
1680
+ printSqlResult(result, booleanOption(args, "json"));
1681
+ }
1682
+ function resolveQuerySql(args) {
1683
+ const file = stringOption(args, "file");
1684
+ if (file && args.rest.length > 0) {
1685
+ throw new CliError("Pass SQL as an argument or --file, not both.", 2);
1686
+ }
1687
+ if (file) {
1688
+ const path = resolve(file);
1689
+ if (!existsSync(path))
1690
+ throw new CliError(`SQL file not found: ${path}`, 1);
1691
+ const sql = readFileSync(path, "utf8").trim();
1692
+ if (!sql)
1693
+ throw new CliError(`SQL file is empty: ${path}`, 1);
1694
+ return sql;
1695
+ }
1696
+ if (args.rest.length > 1) {
1697
+ throw new CliError('railcode db query takes a single SQL string. Quote it: railcode db query "select 1".', 2);
1698
+ }
1699
+ const positional = args.rest[0];
1700
+ if (!positional || !positional.trim()) {
1701
+ throw new CliError('Missing SQL. Usage: railcode db query "select 1" [--connection <name>].', 2);
1702
+ }
1703
+ return positional.trim();
1704
+ }
1705
+ function parseSqlParams(args) {
1706
+ const raw = stringOption(args, "params");
1707
+ if (raw === undefined)
1708
+ return [];
1709
+ let parsed;
1710
+ try {
1711
+ parsed = JSON.parse(raw);
1712
+ }
1713
+ catch {
1714
+ throw new CliError('--params must be a JSON array, e.g. --params \'[42, "ok"]\'.', 2);
1715
+ }
1716
+ if (!Array.isArray(parsed)) {
1717
+ throw new CliError('--params must be a JSON array, e.g. --params \'[42, "ok"]\'.', 2);
1718
+ }
1719
+ return parsed;
1720
+ }
1721
+ function parseEngineOption(args) {
1722
+ const raw = stringOption(args, "engine");
1723
+ if (raw === undefined)
1724
+ return undefined;
1725
+ const engine = raw.trim().toLowerCase();
1726
+ if (!DB_ENGINES.has(engine)) {
1727
+ throw new CliError(`Unknown engine '${raw}'. Use postgres or mysql.`, 2);
1728
+ }
1729
+ return engine;
1730
+ }
1731
+ // The server requires an engine and rejects a mismatch, so when the caller omits
1732
+ // --engine we look the connection up in the registry and use its engine.
1733
+ async function resolveConnectionEngine(args, connection) {
1734
+ const response = await dbAuthedRequest(args, "Data connectors list", fetchConnections);
1735
+ const connectors = (await readDbResponse(response, "Data connectors list"));
1736
+ const match = connectors.find((c) => c.name === connection);
1737
+ if (!match) {
1738
+ const names = connectors.map((c) => c.name).join(", ") || "none";
1739
+ throw new CliError(`No data connector named '${connection}'. Configured: ${names}. Pass --engine to override.`, 1);
1740
+ }
1741
+ return match.engine;
1742
+ }
1743
+ async function dbAuthedRequest(args, action, send) {
1744
+ const config = loadConfig();
1745
+ const authUrl = await resolveDbAuthOrigin(config, args);
1746
+ const apiUrl = canonicalApiOrigin(authUrl);
1747
+ let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, action);
1748
+ let response = await send(apiUrl, authedConfig);
1749
+ if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
1750
+ console.log("Saved login expired. Log in again to continue.");
1751
+ const retryConfig = { ...authedConfig, apiUrl: authUrl };
1752
+ delete retryConfig.apiToken;
1753
+ delete retryConfig.apiTokenPrefix;
1754
+ delete retryConfig.cookies;
1755
+ saveConfig(retryConfig);
1756
+ authedConfig = await ensureApiToken(retryConfig, authUrl, action);
1757
+ response = await send(apiUrl, authedConfig);
1758
+ }
1759
+ return response;
1760
+ }
1761
+ async function resolveDbAuthOrigin(config, args) {
1762
+ const manifestApiUrl = readRailcodeManifest(process.cwd())?.manifest.deploy?.apiUrl;
1763
+ return resolveRequiredAuthOrigin({
1764
+ config,
1765
+ args,
1766
+ action: "Data connectors",
1767
+ fallbackApiUrl: manifestApiUrl,
1768
+ missingUrlHint: "Pass --api-url <url>, set deploy.apiUrl in railcode.json, or set RAILCODE_API_URL.",
1769
+ });
1770
+ }
1771
+ function fetchConnections(apiUrl, config) {
1772
+ return dbFetch(apiUrl, config, "/v1/connections", { redirect: "manual" });
1773
+ }
1774
+ function postSql(apiUrl, config, body) {
1775
+ return dbFetch(apiUrl, config, "/v1/sql", {
1776
+ method: "POST",
1777
+ headers: { "Content-Type": "application/json" },
1778
+ body: JSON.stringify(body),
1779
+ redirect: "manual",
1780
+ });
1781
+ }
1782
+ async function dbFetch(apiUrl, config, path, init) {
1783
+ if (!config.apiToken) {
1784
+ throw new CliError("Missing Railcode API token. Run from a terminal once, or set RAILCODE_API_TOKEN.", 1);
1785
+ }
1786
+ const headers = {
1787
+ ...(init.headers ?? {}),
1788
+ Authorization: `Bearer ${config.apiToken}`,
1789
+ };
1790
+ try {
1791
+ return await fetch(`${apiUrl}${path}`, { ...init, headers });
1792
+ }
1793
+ catch (error) {
1794
+ throw new CliError(`Could not reach ${apiUrl}. Configure the Railcode URL in railcode.json deploy.apiUrl, RAILCODE_API_URL, or --api-url. ${error instanceof Error ? error.message : String(error)}`, 1);
1795
+ }
1796
+ }
1797
+ async function readDbResponse(response, action, hint) {
1798
+ if (response.ok)
1799
+ return response.json();
1800
+ const detail = errorDetail(await response.text());
1801
+ if (response.status === 401) {
1802
+ throw new CliError(`${action} login was rejected. Run the command again to log in.`, 1);
1803
+ }
1804
+ if (response.status === 403) {
1805
+ throw new CliError(`${action} denied: ${detail}`, 1);
1806
+ }
1807
+ if (response.status === 502) {
1808
+ throw new CliError(detail || "Database unreachable.", 1);
1809
+ }
1810
+ if (response.status === 400 || response.status === 404) {
1811
+ throw new CliError([detail, hint].filter(Boolean).join(" ") || `${action} failed.`, 1);
1812
+ }
1813
+ throw new CliError(`${action} failed (${response.status}): ${detail}`, 1);
1814
+ }
1815
+ function printConnectors(connectors, asJson) {
1816
+ if (asJson) {
1817
+ console.log(JSON.stringify(connectors, null, 2));
1818
+ return;
1819
+ }
1820
+ if (!connectors || connectors.length === 0) {
1821
+ console.log("No data connectors configured.");
1822
+ return;
1823
+ }
1824
+ console.log(formatTable(["NAME", "ENGINE"], connectors.map((c) => [c.name, c.engine])));
1825
+ }
1826
+ function printSqlResult(result, asJson) {
1827
+ if (asJson) {
1828
+ console.log(JSON.stringify(result, null, 2));
1829
+ return;
1830
+ }
1831
+ const columns = result.columns ?? [];
1832
+ const rows = result.rows ?? [];
1833
+ if (columns.length > 0) {
1834
+ console.log(formatTable(columns, rows));
1835
+ }
1836
+ const count = typeof result.rowcount === "number" ? result.rowcount : rows.length;
1837
+ console.log(`${columns.length > 0 ? "\n" : ""}${count} ${count === 1 ? "row" : "rows"}${result.truncated ? " (truncated to first 1000)" : ""}`);
1838
+ }
1839
+ function rejectUnknownOptions(args, allowed, label) {
1840
+ const set = new Set(allowed);
1841
+ const unknown = Object.keys(args.options).filter((option) => !set.has(option));
1842
+ if (unknown.length > 0) {
1843
+ throw new CliError(`Unknown option for ${label}: --${unknown[0]}`, 2);
1844
+ }
1845
+ }
1846
+ function formatTable(columns, rows) {
1847
+ const header = columns.map((c) => c ?? "");
1848
+ const body = rows.map((row) => header.map((_, i) => formatCell(row?.[i])));
1849
+ const widths = header.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length), 0));
1850
+ const renderRow = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").replace(/\s+$/, "");
1851
+ const lines = [renderRow(header), renderRow(widths.map((w) => "-".repeat(w)))];
1852
+ for (const row of body)
1853
+ lines.push(renderRow(row));
1854
+ return lines.join("\n");
1855
+ }
1856
+ function formatCell(value) {
1857
+ if (value === null || value === undefined)
1858
+ return "";
1859
+ const text = typeof value === "string"
1860
+ ? value
1861
+ : typeof value === "object"
1862
+ ? JSON.stringify(value)
1863
+ : String(value);
1864
+ return text.replace(/[\r\n\t]+/g, " ");
1865
+ }
1597
1866
  const DESIGN_SYSTEM_HELP = `Usage:
1598
1867
  railcode get design-system
1599
1868
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "railcode",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "CLI for building, testing, and deploying Railcode apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",