railcode 0.1.9 → 0.1.11

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
@@ -20,9 +20,10 @@ on your `PATH`.
20
20
  railcode login [--api-url <url>] Sign in and mint a personal API token
21
21
  railcode init <app> [--template static|react]
22
22
  railcode deploy Build (if configured) and deploy the app here
23
+ railcode db <list|query> ... List data connectors / run read-only SQL
23
24
  ```
24
25
 
25
- - **`login`** prompts for the server URL (default `http://api.127.0.0.1.nip.io`),
26
+ - **`login`** prompts for the server URL (default `https://api.railcode.app`),
26
27
  email, and password. It exchanges credentials for a short-lived JWT, mints a
27
28
  personal API token, resolves your organization, and saves everything to
28
29
  `~/.railcode/config.json` (dir `0700`, file `0600`). The JWT is used once and
@@ -34,6 +35,17 @@ railcode deploy Build (if configured) and deploy the app here
34
35
  - **`deploy`** reads `railcode.json` (`{ app, build?, dist? }`), runs the resolved
35
36
  build command when needed, then uploads the output dir. The app is
36
37
  created-or-resolved by slug in your saved org, and the live URL is printed.
38
+ - **`db`** inspects the org's **data connectors** (per-org Postgres) and runs
39
+ ad-hoc read-only SQL. Run from an app dir for an app you've already deployed;
40
+ it resolves the existing app by slug (it won't create one) and uses the same
41
+ bearer, app-scoped routes the `dev` proxy forwards to.
42
+ `railcode db list` (aliases `ls`, `connections`) prints each connector's
43
+ `name` + `engine` (`--json` for the raw array). `railcode db query "<sql>"`
44
+ (alias `sql`) runs SQL against `--connection` (default `default`) and prints a
45
+ table + row count; `--engine <postgres|mysql>` is inferred from the connector
46
+ list when omitted, `--params '<json-array>'` binds `$1, $2, …` placeholders
47
+ (SQL is never interpolated), `--file <path>` reads SQL from a file, and
48
+ `--json` prints the raw `{ columns, rows, rowcount, truncated }` envelope.
37
49
 
38
50
  ## Configuration
39
51
 
@@ -0,0 +1,146 @@
1
+ // Pure helpers behind `railcode connector list` / `railcode connector fetch`.
2
+ // Kept free of any I/O or process state so they can be unit-tested directly (see
3
+ // test/connectors.test.mjs); the command layer in index.ts does the HTTP, file
4
+ // reads, and printing. Mirrors db.ts.
5
+ import { formatTable } from "./db.js";
6
+ export class ConnectorError extends Error {
7
+ }
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.
21
+ export function connectorTable(connectors) {
22
+ const hasDescription = connectors.some((c) => (c.description ?? "").length > 0);
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");
29
+ const rows = connectors.map((c) => {
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;
36
+ });
37
+ return formatTable(columns, rows);
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
+ }
76
+ // `--connector <name>` is required for `fetch`. Reject a missing value or a bare
77
+ // flag with a usage message.
78
+ export function parseConnectorOption(raw) {
79
+ if (raw === undefined) {
80
+ throw new ConnectorError("--connector <name> is required for `railcode connector fetch`.");
81
+ }
82
+ if (typeof raw !== "string")
83
+ throw new ConnectorError("--connector requires a value.");
84
+ const name = raw.trim();
85
+ if (!name)
86
+ throw new ConnectorError("--connector requires a non-empty connector name.");
87
+ return name;
88
+ }
89
+ // `--method <verb>` defaults to GET and is normalized to upper-case. The server
90
+ // is the authority on which methods a connector allows (405 otherwise) — here we
91
+ // only reject a bare flag or a non-token value.
92
+ export function parseMethodOption(raw) {
93
+ if (raw === undefined)
94
+ return "GET";
95
+ if (typeof raw !== "string")
96
+ throw new ConnectorError("--method requires a value (e.g. GET, POST).");
97
+ const method = raw.trim().toUpperCase();
98
+ if (!/^[A-Z]+$/.test(method)) {
99
+ throw new ConnectorError(`Invalid --method "${raw}". Use an HTTP verb like GET or POST.`);
100
+ }
101
+ return method;
102
+ }
103
+ // `--body <string>` and `--file <path>` are mutually exclusive; both are optional
104
+ // (a GET needs no body).
105
+ export function pickConnectorBody(bodyOption, fileOption) {
106
+ if (bodyOption === true)
107
+ throw new ConnectorError("--body requires a value.");
108
+ if (fileOption === true)
109
+ throw new ConnectorError("--file requires a path.");
110
+ const hasBody = typeof bodyOption === "string";
111
+ const hasFile = typeof fileOption === "string" && fileOption.length > 0;
112
+ if (hasBody && hasFile) {
113
+ throw new ConnectorError("Pass a request body either with --body or --file, not both.");
114
+ }
115
+ if (hasFile)
116
+ return { kind: "file", path: fileOption };
117
+ if (hasBody)
118
+ return { kind: "inline", body: bodyOption };
119
+ return { kind: "none" };
120
+ }
121
+ // Assemble + validate the proxy request payload. `path` comes from the positional
122
+ // argument; `body` is the already-resolved string (or null for no body).
123
+ export function buildConnectorRequest(input) {
124
+ if (!input.path) {
125
+ throw new ConnectorError('Provide a path: railcode connector fetch --connector <name> "/v1/charges".');
126
+ }
127
+ return {
128
+ connector: input.connector,
129
+ method: input.method,
130
+ path: input.path,
131
+ body: input.body,
132
+ };
133
+ }
134
+ // A non-2xx upstream status is the connector's own response (not a CLI failure),
135
+ // but `ok: false` still sets a non-zero exit code.
136
+ export function proxyExitCode(envelope) {
137
+ return envelope.ok ? 0 : 1;
138
+ }
139
+ // Human output for one proxied call: the upstream status line then the body.
140
+ export function renderProxyResponse(envelope) {
141
+ const lines = [`HTTP ${envelope.status}`, "", envelope.body];
142
+ if (envelope.truncated) {
143
+ lines.push("", "Note: the response body was truncated by the server.");
144
+ }
145
+ return lines.join("\n");
146
+ }
package/dist/db.js ADDED
@@ -0,0 +1,78 @@
1
+ // Pure helpers behind `railcode db list` / `railcode db query`. Kept free of any
2
+ // I/O or process state so they can be unit-tested directly (see test/db.test.mjs);
3
+ // the command layer in index.ts does the HTTP, file reads, and printing.
4
+ export class DbError extends Error {
5
+ }
6
+ export const DB_ENGINES = ["postgres", "mysql"];
7
+ // `--params '<json-array>'` → the positional $1,$2,… values. Empty when omitted.
8
+ export function parseSqlParams(raw) {
9
+ if (raw === undefined)
10
+ return [];
11
+ let parsed;
12
+ try {
13
+ parsed = JSON.parse(raw);
14
+ }
15
+ catch {
16
+ throw new DbError(`--params must be a JSON array (invalid JSON): ${raw}`);
17
+ }
18
+ if (!Array.isArray(parsed)) {
19
+ throw new DbError(`--params must be a JSON array, e.g. --params '[1, "abc"]'.`);
20
+ }
21
+ return parsed;
22
+ }
23
+ // Validate the optional --engine override. undefined means "infer from --connection".
24
+ export function parseEngine(raw) {
25
+ if (raw === undefined)
26
+ return undefined;
27
+ if (typeof raw !== "string")
28
+ throw new DbError("--engine requires a value (postgres or mysql).");
29
+ if (DB_ENGINES.includes(raw))
30
+ return raw;
31
+ throw new DbError(`Unknown --engine "${raw}". Use one of: ${DB_ENGINES.join(", ")}.`);
32
+ }
33
+ // SQL comes from the positional arg OR --file, never both, never neither.
34
+ export function pickSqlSource(positional, fileOption) {
35
+ const hasPositional = typeof positional === "string" && positional.length > 0;
36
+ if (fileOption === true)
37
+ throw new DbError("--file requires a path.");
38
+ const hasFile = typeof fileOption === "string" && fileOption.length > 0;
39
+ if (hasPositional && hasFile) {
40
+ throw new DbError("Pass SQL either as the argument or with --file, not both.");
41
+ }
42
+ if (!hasPositional && !hasFile) {
43
+ throw new DbError('Provide SQL: railcode db query "select 1" (or --file query.sql).');
44
+ }
45
+ return hasFile
46
+ ? { kind: "file", path: fileOption }
47
+ : { kind: "inline", sql: positional };
48
+ }
49
+ // When --engine is omitted, infer it from the connector list by matching the
50
+ // chosen --connection name. Errors with the configured names if there's no match.
51
+ export function inferEngine(connections, connectionName) {
52
+ const match = connections.find((c) => c.name === connectionName);
53
+ if (!match) {
54
+ const names = connections.map((c) => c.name);
55
+ const configured = names.length > 0 ? names.join(", ") : "(none configured)";
56
+ throw new DbError(`No connection named "${connectionName}". Configured: ${configured}. ` +
57
+ "Pass --engine to skip the lookup.");
58
+ }
59
+ return parseEngine(match.engine) ?? "postgres";
60
+ }
61
+ function renderCell(value) {
62
+ if (value === null || value === undefined)
63
+ return "NULL";
64
+ if (typeof value === "object")
65
+ return JSON.stringify(value);
66
+ return String(value);
67
+ }
68
+ // A minimal left-aligned, two-space-gutter table. Trailing padding is trimmed so
69
+ // the output diffs cleanly. Works with zero rows (header + rule only).
70
+ export function formatTable(columns, rows) {
71
+ const cells = rows.map((row) => columns.map((_, i) => renderCell(row[i])));
72
+ const widths = columns.map((col, i) => Math.max(col.length, ...cells.map((row) => row[i].length), 0));
73
+ const line = (values) => values.map((value, i) => value.padEnd(widths[i])).join(" ").replace(/\s+$/, "");
74
+ const out = [line(columns), line(widths.map((width) => "-".repeat(width)))];
75
+ for (const row of cells)
76
+ out.push(line(row));
77
+ return out.join("\n");
78
+ }
package/dist/index.js CHANGED
@@ -11,6 +11,8 @@ import process from "node:process";
11
11
  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
+ import { DbError, formatTable, inferEngine, parseEngine, parseSqlParams, pickSqlSource, } from "./db.js";
15
+ import { ConnectorError, buildConnectorRequest, connectorOpenapiOutput, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderConnectorDocs, renderProxyResponse, } from "./connectors.js";
14
16
  // ---------------------------------------------------------------------------
15
17
  // Constants
16
18
  // ---------------------------------------------------------------------------
@@ -18,7 +20,7 @@ const RAILCODE_HOME = process.env.RAILCODE_HOME || join(homedir(), ".railcode");
18
20
  const CONFIG_PATH = join(RAILCODE_HOME, "config.json");
19
21
  const CLI_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
20
22
  const VERSION = readCliPackageVersion();
21
- const DEFAULT_API_URL = "http://api.127.0.0.1.nip.io";
23
+ const DEFAULT_API_URL = "https://api.railcode.app";
22
24
  const MANIFEST_NAME = "railcode.json";
23
25
  const CLI_AUTH_CALLBACK_PATH = "/railcode-cli/callback";
24
26
  const CLI_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
@@ -32,6 +34,8 @@ Usage:
32
34
  railcode dev [--port <n>] [--reset] Run the app locally against an emulated /_api
33
35
  railcode init <app> [--template static|react]
34
36
  railcode design-system Print your org's design-system guidance (markdown)
37
+ railcode db <list|query> ... List data connectors / run read-only SQL
38
+ railcode connector <list|docs|fetch> ... List connectors / show API docs / proxy a call
35
39
  railcode --version
36
40
  railcode --help
37
41
 
@@ -69,6 +73,13 @@ async function main() {
69
73
  case "design-system":
70
74
  await commandDesignSystem(args);
71
75
  return;
76
+ case "db":
77
+ await commandDb(args);
78
+ return;
79
+ case "connector":
80
+ case "connectors":
81
+ await commandConnector(args);
82
+ return;
72
83
  default:
73
84
  throw new CliError(`Unknown command: ${args.command}\n\n${HELP}`, 2);
74
85
  }
@@ -342,6 +353,17 @@ async function resolveOrCreateApp(config, slug) {
342
353
  });
343
354
  return (await readJsonOrThrow(created, "Create app"));
344
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
+ }
345
367
  async function postDeploy(config, appUuid, files) {
346
368
  // The deploy API is multipart: one part named "files" per file, with the
347
369
  // relative path carried as the part filename.
@@ -767,6 +789,342 @@ async function commandDesignSystem(args) {
767
789
  // Raw markdown straight to stdout so it pipes/feeds cleanly into an agent.
768
790
  process.stdout.write(markdown.endsWith("\n") || markdown === "" ? markdown : `${markdown}\n`);
769
791
  }
792
+ // ---------------------------------------------------------------------------
793
+ // db — list data connectors + run read-only SQL (`railcode db list|query`)
794
+ //
795
+ // Data connectors are org-wide; the app is only the scoping/attribution context
796
+ // for the SQL route, so we reuse resolveOrCreateApp (same as deploy/dev) and the
797
+ // bearer, app-scoped endpoints `railcode dev` already forwards to.
798
+ // ---------------------------------------------------------------------------
799
+ const DB_HELP = `railcode db — data connectors
800
+
801
+ Usage:
802
+ railcode db list List this org's data connectors
803
+ railcode db query "<sql>" Run read-only SQL against a connector
804
+
805
+ Aliases: list = ls, connections; query = sql.
806
+
807
+ List options:
808
+ --json Print the raw connector array
809
+
810
+ Query options:
811
+ --connection <name> Connector to query (default: default)
812
+ --engine <postgres|mysql> Engine override (inferred from --connection otherwise)
813
+ --params '<json-array>' Values for $1, $2, … placeholders
814
+ --file <path> Read SQL from a file instead of the argument
815
+ --json Print the raw { columns, rows, rowcount, truncated } envelope
816
+
817
+ SQL is sent read-only with bound parameters — values are never interpolated into
818
+ the query. Run from an app directory (a ${MANIFEST_NAME} with an "app" slug).
819
+ `;
820
+ async function commandDb(args) {
821
+ try {
822
+ const sub = args.rest[0] ?? "";
823
+ switch (sub) {
824
+ case "list":
825
+ case "ls":
826
+ case "connections":
827
+ await commandDbList(args);
828
+ return;
829
+ case "query":
830
+ case "sql":
831
+ await commandDbQuery(args);
832
+ return;
833
+ case "":
834
+ case "help":
835
+ console.log(DB_HELP);
836
+ return;
837
+ default:
838
+ throw new CliError(`Unknown db command: ${sub}\n\n${DB_HELP}`, 2);
839
+ }
840
+ }
841
+ catch (error) {
842
+ // Validation helpers throw DbError; surface them as usage errors (exit 2).
843
+ if (error instanceof DbError)
844
+ throw new CliError(error.message, 2);
845
+ throw error;
846
+ }
847
+ }
848
+ async function commandDbList(args) {
849
+ assertKnownDbOptions(args, ["json", "apiUrl"]);
850
+ if (args.rest.length > 1) {
851
+ throw new CliError(`railcode db list takes no arguments (got "${args.rest[1]}").`, 2);
852
+ }
853
+ const config = requireLogin(args);
854
+ const app = await resolveExistingApp(config, requireAppSlug());
855
+ const connections = await fetchDbConnections(config, app.uuid);
856
+ if (args.options.json) {
857
+ console.log(JSON.stringify(connections, null, 2));
858
+ return;
859
+ }
860
+ if (connections.length === 0) {
861
+ console.log("No data connectors configured for this organization.");
862
+ return;
863
+ }
864
+ console.log(formatTable(["name", "engine"], connections.map((connection) => [connection.name, connection.engine])));
865
+ }
866
+ async function commandDbQuery(args) {
867
+ assertKnownDbOptions(args, ["connection", "engine", "params", "file", "json", "apiUrl"]);
868
+ if (args.rest.length > 2) {
869
+ throw new CliError('railcode db query takes a single SQL string — quote it: railcode db query "select 1".', 2);
870
+ }
871
+ const source = pickSqlSource(args.rest[1], args.options.file);
872
+ const params = parseSqlParams(optionString(args, "params"));
873
+ const engineOption = parseEngine(args.options.engine);
874
+ const connection = optionString(args, "connection") ?? "default";
875
+ const query = source.kind === "file" ? readSqlFile(source.path) : source.sql;
876
+ const config = requireLogin(args);
877
+ const app = await resolveExistingApp(config, requireAppSlug());
878
+ // --engine wins; otherwise look the engine up from the connector list.
879
+ const engine = engineOption ?? inferEngine(await fetchDbConnections(config, app.uuid), connection);
880
+ const result = await runDbQuery(config, app.uuid, { engine, connection, query, params });
881
+ if (args.options.json) {
882
+ console.log(JSON.stringify(result, null, 2));
883
+ return;
884
+ }
885
+ console.log(formatTable(result.columns, result.rows));
886
+ console.log("");
887
+ console.log(`(${result.rowcount} row${result.rowcount === 1 ? "" : "s"})`);
888
+ if (result.truncated) {
889
+ console.log("Note: results were truncated by the server.");
890
+ }
891
+ }
892
+ function requireAppSlug() {
893
+ const manifest = readManifest(process.cwd());
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`);
902
+ if (resp.status === 401)
903
+ await reLoginOrThrow();
904
+ return (await readJsonOrThrow(resp, "List connections"));
905
+ }
906
+ async function runDbQuery(config, appUuid, body) {
907
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/sql`, {
908
+ method: "POST",
909
+ headers: { "Content-Type": "application/json" },
910
+ body: JSON.stringify(body),
911
+ });
912
+ if (resp.status === 401)
913
+ await reLoginOrThrow();
914
+ return (await readJsonOrThrow(resp, "Run query"));
915
+ }
916
+ function readSqlFile(path) {
917
+ const resolved = resolve(process.cwd(), path);
918
+ if (!existsSync(resolved))
919
+ throw new CliError(`SQL file not found: ${path}`, 2);
920
+ const sql = readFileSync(resolved, "utf8").trim();
921
+ if (!sql)
922
+ throw new CliError(`SQL file is empty: ${path}`, 2);
923
+ return sql;
924
+ }
925
+ function optionString(args, key) {
926
+ const value = args.options[key];
927
+ if (value === true)
928
+ throw new CliError(`--${camelToKebab(key)} requires a value.`, 2);
929
+ return typeof value === "string" ? value : undefined;
930
+ }
931
+ function assertKnownDbOptions(args, allowed) {
932
+ for (const key of Object.keys(args.options)) {
933
+ if (!allowed.includes(key)) {
934
+ throw new CliError(`Unknown option --${camelToKebab(key)} for \`railcode db\`.\n\n${DB_HELP}`, 2);
935
+ }
936
+ }
937
+ }
938
+ function camelToKebab(value) {
939
+ return value.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
940
+ }
941
+ // ---------------------------------------------------------------------------
942
+ // connector — list service connectors + proxy one call
943
+ // (`railcode connector list|fetch`)
944
+ //
945
+ // Service connectors are org-configured HTTP proxies to downstream SaaS APIs.
946
+ // The app is the scoping/attribution context, so — like `railcode db` — we
947
+ // resolve (never create) the app by slug and use the bearer, app-scoped routes
948
+ // `railcode dev` forwards to.
949
+ // ---------------------------------------------------------------------------
950
+ const CONNECTOR_HELP = `railcode connector — service connectors
951
+
952
+ Usage:
953
+ railcode connector list List this org's service connectors
954
+ railcode connector docs <name> [opts] Show how to call a connector's API
955
+ railcode connector fetch <path> --connector <name> [opts]
956
+ Proxy one HTTP call through a connector
957
+
958
+ Aliases: connector = connectors; list = ls, connectors; docs = doc; fetch = request.
959
+
960
+ List options:
961
+ --json Print the raw connector array
962
+
963
+ Docs options:
964
+ --json Print the raw docs object
965
+ --openapi Print only the OpenAPI spec (inline text, else its URL)
966
+
967
+ Fetch options:
968
+ --connector <name> Connector to call (required)
969
+ --method <verb> HTTP method (default GET; must be allowed by the connector)
970
+ --body <string> Request body (mutually exclusive with --file)
971
+ --file <path> Read the request body from a file
972
+ --json Print the raw { status, ok, headers, body, truncated } envelope
973
+
974
+ The connector holds the credential; you control only method/path/body. A non-2xx
975
+ upstream status is still printed but exits non-zero. Run from an app directory (a
976
+ ${MANIFEST_NAME} with an "app" slug).
977
+ `;
978
+ async function commandConnector(args) {
979
+ try {
980
+ const sub = args.rest[0] ?? "";
981
+ switch (sub) {
982
+ case "list":
983
+ case "ls":
984
+ case "connectors":
985
+ await commandConnectorList(args);
986
+ return;
987
+ case "docs":
988
+ case "doc":
989
+ await commandConnectorDocs(args);
990
+ return;
991
+ case "fetch":
992
+ case "request":
993
+ await commandConnectorFetch(args);
994
+ return;
995
+ case "":
996
+ case "help":
997
+ console.log(CONNECTOR_HELP);
998
+ return;
999
+ default:
1000
+ throw new CliError(`Unknown connector command: ${sub}\n\n${CONNECTOR_HELP}`, 2);
1001
+ }
1002
+ }
1003
+ catch (error) {
1004
+ // Validation helpers throw ConnectorError; surface them as usage errors (exit 2).
1005
+ if (error instanceof ConnectorError)
1006
+ throw new CliError(error.message, 2);
1007
+ throw error;
1008
+ }
1009
+ }
1010
+ async function commandConnectorList(args) {
1011
+ assertKnownConnectorOptions(args, ["json", "apiUrl"]);
1012
+ if (args.rest.length > 1) {
1013
+ throw new CliError(`railcode connector list takes no arguments (got "${args.rest[1]}").`, 2);
1014
+ }
1015
+ const config = requireLogin(args);
1016
+ const app = await resolveExistingApp(config, requireAppSlug());
1017
+ const connectors = await fetchServiceConnectors(config, app.uuid);
1018
+ if (args.options.json) {
1019
+ console.log(JSON.stringify(connectors, null, 2));
1020
+ return;
1021
+ }
1022
+ if (connectors.length === 0) {
1023
+ console.log("No service connectors configured for this organization.");
1024
+ return;
1025
+ }
1026
+ console.log(connectorTable(connectors));
1027
+ }
1028
+ async function commandConnectorFetch(args) {
1029
+ assertKnownConnectorOptions(args, ["connector", "method", "body", "file", "json", "apiUrl"]);
1030
+ if (args.rest.length > 2) {
1031
+ throw new CliError('railcode connector fetch takes a single path — quote it: railcode connector fetch "/v1/charges".', 2);
1032
+ }
1033
+ const connector = parseConnectorOption(args.options.connector);
1034
+ const method = parseMethodOption(args.options.method);
1035
+ const bodySource = pickConnectorBody(args.options.body, args.options.file);
1036
+ const body = bodySource.kind === "file"
1037
+ ? readConnectorBodyFile(bodySource.path)
1038
+ : bodySource.kind === "inline"
1039
+ ? bodySource.body
1040
+ : null;
1041
+ const payload = buildConnectorRequest({ connector, method, path: args.rest[1] ?? "", body });
1042
+ const config = requireLogin(args);
1043
+ const app = await resolveExistingApp(config, requireAppSlug());
1044
+ const envelope = await runConnectorRequest(config, app.uuid, payload);
1045
+ if (args.options.json) {
1046
+ console.log(JSON.stringify(envelope, null, 2));
1047
+ }
1048
+ else {
1049
+ console.log(renderProxyResponse(envelope));
1050
+ }
1051
+ // A non-2xx upstream status is the connector's response, not a CLI error — print
1052
+ // it, but exit non-zero so scripts can tell success from failure.
1053
+ process.exitCode = proxyExitCode(envelope);
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
+ }
1084
+ async function fetchServiceConnectors(config, appUuid) {
1085
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/service-connectors`);
1086
+ if (resp.status === 401)
1087
+ await reLoginOrThrow();
1088
+ return (await readJsonOrThrow(resp, "List service connectors"));
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
+ }
1099
+ async function runConnectorRequest(config, appUuid, body) {
1100
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/service-connectors/request`, {
1101
+ method: "POST",
1102
+ headers: { "Content-Type": "application/json" },
1103
+ body: JSON.stringify(body),
1104
+ });
1105
+ if (resp.status === 401)
1106
+ await reLoginOrThrow();
1107
+ if (resp.status === 405) {
1108
+ // The connector config didn't allow this method (server-side check).
1109
+ throw new CliError(`Connector "${body.connector}" rejected ${body.method}: ${await errorDetail(resp)}`, 1);
1110
+ }
1111
+ // The proxy envelope is the 200 body; any other non-2xx is a real CLI failure
1112
+ // (bad connector name, auth, misconfig) — surface the server detail.
1113
+ return (await readJsonOrThrow(resp, "Connector request"));
1114
+ }
1115
+ function readConnectorBodyFile(path) {
1116
+ const resolved = resolve(process.cwd(), path);
1117
+ if (!existsSync(resolved))
1118
+ throw new CliError(`Request body file not found: ${path}`, 2);
1119
+ return readFileSync(resolved, "utf8");
1120
+ }
1121
+ function assertKnownConnectorOptions(args, allowed) {
1122
+ for (const key of Object.keys(args.options)) {
1123
+ if (!allowed.includes(key)) {
1124
+ throw new CliError(`Unknown option --${camelToKebab(key)} for \`railcode connector\`.\n\n${CONNECTOR_HELP}`, 2);
1125
+ }
1126
+ }
1127
+ }
770
1128
  async function readJsonOrThrow(response, action) {
771
1129
  if (response.status === 401) {
772
1130
  throw new CliError(`${action} was rejected (401). Run \`railcode login\` again.`, 1);
@@ -1332,7 +1690,9 @@ async function handleLocalApi(ctx, req, res, url) {
1332
1690
  if (path === "/connections" ||
1333
1691
  path === "/sql" ||
1334
1692
  path === "/llm/generate" ||
1335
- path === "/llm/stream") {
1693
+ path === "/llm/stream" ||
1694
+ path === "/service-connectors" ||
1695
+ path === "/service-connectors/request") {
1336
1696
  await forwardCompute(ctx, req, res, path, url);
1337
1697
  return;
1338
1698
  }
@@ -1524,8 +1884,9 @@ function devCreds(config) {
1524
1884
  }
1525
1885
  // Resolve the server-side app for proxying — lazily and memoized, so we don't
1526
1886
  // pollute org state on a typo'd `railcode dev`. The app is **created only on the
1527
- // first llm/sql call** (allowCreate); a `GET /connections` (which can fire on page
1528
- // load) resolves an existing app but never creates one.
1887
+ // first actual call** (llm/sql/service-connectors request, allowCreate); a
1888
+ // load-time list (`GET /connections`, `/service-connectors`) resolves an existing
1889
+ // app but never creates one.
1529
1890
  function resolveDevAppUuid(ctx, creds, allowCreate) {
1530
1891
  const base = `${creds.apiUrl}/api/organizations/${creds.orgUuid}/apps`;
1531
1892
  const authed = { Authorization: `Bearer ${creds.token}` };
@@ -1557,7 +1918,7 @@ function resolveDevAppUuid(ctx, creds, allowCreate) {
1557
1918
  if (!created.ok)
1558
1919
  return null;
1559
1920
  const app = (await created.json());
1560
- console.log(` created app "${ctx.app}" on ${creds.apiUrl} (first llm/sql call)`);
1921
+ console.log(` created app "${ctx.app}" on ${creds.apiUrl} (first llm/sql/connector call)`);
1561
1922
  ctx.appUuidPromise = Promise.resolve(app.uuid); // later resolves skip the list
1562
1923
  return app.uuid;
1563
1924
  }
@@ -1571,8 +1932,10 @@ function resolveDevAppUuid(ctx, creds, allowCreate) {
1571
1932
  }
1572
1933
  async function forwardCompute(ctx, req, res, path, url) {
1573
1934
  // Never reply 401 to the browser — the SDK reloads the page on 401 (loop).
1574
- // connections degrades to [] so dataConnectors() at load doesn't break.
1575
- const degradeOk = path === "/connections";
1935
+ // Load-time list calls degrade to [] so dataConnectors()/serviceConnectors() at
1936
+ // load don't break; the actual call surfaces (/sql, /llm, /service-connectors/request)
1937
+ // return 503 instead.
1938
+ const degradeOk = path === "/connections" || path === "/service-connectors";
1576
1939
  const creds = devCreds(ctx.config);
1577
1940
  if (!creds) {
1578
1941
  if (degradeOk)
@@ -1582,7 +1945,8 @@ async function forwardCompute(ctx, req, res, path, url) {
1582
1945
  message: "Run `railcode login` to use LLM and connectors in dev.",
1583
1946
  });
1584
1947
  }
1585
- // Only llm/sql may create the app server-side; a load-time GET /connections won't.
1948
+ // Only the actual call surfaces (llm/sql/service-connectors/request) may create
1949
+ // the app server-side; a load-time list (GET /connections, /service-connectors) won't.
1586
1950
  const appUuid = await resolveDevAppUuid(ctx, creds, !degradeOk);
1587
1951
  if (!appUuid) {
1588
1952
  if (degradeOk)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "railcode",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",