railcode 0.1.9 → 0.1.10

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,6 +20,7 @@ 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
26
  - **`login`** prompts for the server URL (default `http://api.127.0.0.1.nip.io`),
@@ -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,92 @@
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
+ // Render the connector list as a table. The `description` column is only added
9
+ // when at least one connector actually has one, so the common (description-less)
10
+ // case stays narrow.
11
+ export function connectorTable(connectors) {
12
+ const hasDescription = connectors.some((c) => (c.description ?? "").length > 0);
13
+ const columns = hasDescription
14
+ ? ["name", "auth_type", "allowed_methods", "description"]
15
+ : ["name", "auth_type", "allowed_methods"];
16
+ const rows = connectors.map((c) => {
17
+ const base = [c.name, c.auth_type, (c.allowed_methods ?? []).join(", ")];
18
+ return hasDescription ? [...base, c.description ?? ""] : base;
19
+ });
20
+ return formatTable(columns, rows);
21
+ }
22
+ // `--connector <name>` is required for `fetch`. Reject a missing value or a bare
23
+ // flag with a usage message.
24
+ export function parseConnectorOption(raw) {
25
+ if (raw === undefined) {
26
+ throw new ConnectorError("--connector <name> is required for `railcode connector fetch`.");
27
+ }
28
+ if (typeof raw !== "string")
29
+ throw new ConnectorError("--connector requires a value.");
30
+ const name = raw.trim();
31
+ if (!name)
32
+ throw new ConnectorError("--connector requires a non-empty connector name.");
33
+ return name;
34
+ }
35
+ // `--method <verb>` defaults to GET and is normalized to upper-case. The server
36
+ // is the authority on which methods a connector allows (405 otherwise) — here we
37
+ // only reject a bare flag or a non-token value.
38
+ export function parseMethodOption(raw) {
39
+ if (raw === undefined)
40
+ return "GET";
41
+ if (typeof raw !== "string")
42
+ throw new ConnectorError("--method requires a value (e.g. GET, POST).");
43
+ const method = raw.trim().toUpperCase();
44
+ if (!/^[A-Z]+$/.test(method)) {
45
+ throw new ConnectorError(`Invalid --method "${raw}". Use an HTTP verb like GET or POST.`);
46
+ }
47
+ return method;
48
+ }
49
+ // `--body <string>` and `--file <path>` are mutually exclusive; both are optional
50
+ // (a GET needs no body).
51
+ export function pickConnectorBody(bodyOption, fileOption) {
52
+ if (bodyOption === true)
53
+ throw new ConnectorError("--body requires a value.");
54
+ if (fileOption === true)
55
+ throw new ConnectorError("--file requires a path.");
56
+ const hasBody = typeof bodyOption === "string";
57
+ const hasFile = typeof fileOption === "string" && fileOption.length > 0;
58
+ if (hasBody && hasFile) {
59
+ throw new ConnectorError("Pass a request body either with --body or --file, not both.");
60
+ }
61
+ if (hasFile)
62
+ return { kind: "file", path: fileOption };
63
+ if (hasBody)
64
+ return { kind: "inline", body: bodyOption };
65
+ return { kind: "none" };
66
+ }
67
+ // Assemble + validate the proxy request payload. `path` comes from the positional
68
+ // argument; `body` is the already-resolved string (or null for no body).
69
+ export function buildConnectorRequest(input) {
70
+ if (!input.path) {
71
+ throw new ConnectorError('Provide a path: railcode connector fetch --connector <name> "/v1/charges".');
72
+ }
73
+ return {
74
+ connector: input.connector,
75
+ method: input.method,
76
+ path: input.path,
77
+ body: input.body,
78
+ };
79
+ }
80
+ // A non-2xx upstream status is the connector's own response (not a CLI failure),
81
+ // but `ok: false` still sets a non-zero exit code.
82
+ export function proxyExitCode(envelope) {
83
+ return envelope.ok ? 0 : 1;
84
+ }
85
+ // Human output for one proxied call: the upstream status line then the body.
86
+ export function renderProxyResponse(envelope) {
87
+ const lines = [`HTTP ${envelope.status}`, "", envelope.body];
88
+ if (envelope.truncated) {
89
+ lines.push("", "Note: the response body was truncated by the server.");
90
+ }
91
+ return lines.join("\n");
92
+ }
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, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderProxyResponse, } from "./connectors.js";
14
16
  // ---------------------------------------------------------------------------
15
17
  // Constants
16
18
  // ---------------------------------------------------------------------------
@@ -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|fetch> ... List service connectors / proxy one 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,295 @@ 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 fetch <path> --connector <name> [opts]
955
+ Proxy one HTTP call through a connector
956
+
957
+ Aliases: connector = connectors; list = ls, connectors; fetch = request.
958
+
959
+ List options:
960
+ --json Print the raw connector array
961
+
962
+ Fetch options:
963
+ --connector <name> Connector to call (required)
964
+ --method <verb> HTTP method (default GET; must be allowed by the connector)
965
+ --body <string> Request body (mutually exclusive with --file)
966
+ --file <path> Read the request body from a file
967
+ --json Print the raw { status, ok, headers, body, truncated } envelope
968
+
969
+ The connector holds the credential; you control only method/path/body. A non-2xx
970
+ upstream status is still printed but exits non-zero. Run from an app directory (a
971
+ ${MANIFEST_NAME} with an "app" slug).
972
+ `;
973
+ async function commandConnector(args) {
974
+ try {
975
+ const sub = args.rest[0] ?? "";
976
+ switch (sub) {
977
+ case "list":
978
+ case "ls":
979
+ case "connectors":
980
+ await commandConnectorList(args);
981
+ return;
982
+ case "fetch":
983
+ case "request":
984
+ await commandConnectorFetch(args);
985
+ return;
986
+ case "":
987
+ case "help":
988
+ console.log(CONNECTOR_HELP);
989
+ return;
990
+ default:
991
+ throw new CliError(`Unknown connector command: ${sub}\n\n${CONNECTOR_HELP}`, 2);
992
+ }
993
+ }
994
+ catch (error) {
995
+ // Validation helpers throw ConnectorError; surface them as usage errors (exit 2).
996
+ if (error instanceof ConnectorError)
997
+ throw new CliError(error.message, 2);
998
+ throw error;
999
+ }
1000
+ }
1001
+ async function commandConnectorList(args) {
1002
+ assertKnownConnectorOptions(args, ["json", "apiUrl"]);
1003
+ if (args.rest.length > 1) {
1004
+ throw new CliError(`railcode connector list takes no arguments (got "${args.rest[1]}").`, 2);
1005
+ }
1006
+ const config = requireLogin(args);
1007
+ const app = await resolveExistingApp(config, requireAppSlug());
1008
+ const connectors = await fetchServiceConnectors(config, app.uuid);
1009
+ if (args.options.json) {
1010
+ console.log(JSON.stringify(connectors, null, 2));
1011
+ return;
1012
+ }
1013
+ if (connectors.length === 0) {
1014
+ console.log("No service connectors configured for this organization.");
1015
+ return;
1016
+ }
1017
+ console.log(connectorTable(connectors));
1018
+ }
1019
+ async function commandConnectorFetch(args) {
1020
+ assertKnownConnectorOptions(args, ["connector", "method", "body", "file", "json", "apiUrl"]);
1021
+ if (args.rest.length > 2) {
1022
+ throw new CliError('railcode connector fetch takes a single path — quote it: railcode connector fetch "/v1/charges".', 2);
1023
+ }
1024
+ const connector = parseConnectorOption(args.options.connector);
1025
+ const method = parseMethodOption(args.options.method);
1026
+ const bodySource = pickConnectorBody(args.options.body, args.options.file);
1027
+ const body = bodySource.kind === "file"
1028
+ ? readConnectorBodyFile(bodySource.path)
1029
+ : bodySource.kind === "inline"
1030
+ ? bodySource.body
1031
+ : null;
1032
+ const payload = buildConnectorRequest({ connector, method, path: args.rest[1] ?? "", body });
1033
+ const config = requireLogin(args);
1034
+ const app = await resolveExistingApp(config, requireAppSlug());
1035
+ const envelope = await runConnectorRequest(config, app.uuid, payload);
1036
+ if (args.options.json) {
1037
+ console.log(JSON.stringify(envelope, null, 2));
1038
+ }
1039
+ else {
1040
+ console.log(renderProxyResponse(envelope));
1041
+ }
1042
+ // A non-2xx upstream status is the connector's response, not a CLI error — print
1043
+ // it, but exit non-zero so scripts can tell success from failure.
1044
+ process.exitCode = proxyExitCode(envelope);
1045
+ }
1046
+ async function fetchServiceConnectors(config, appUuid) {
1047
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/service-connectors`);
1048
+ if (resp.status === 401)
1049
+ await reLoginOrThrow();
1050
+ return (await readJsonOrThrow(resp, "List service connectors"));
1051
+ }
1052
+ async function runConnectorRequest(config, appUuid, body) {
1053
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/service-connectors/request`, {
1054
+ method: "POST",
1055
+ headers: { "Content-Type": "application/json" },
1056
+ body: JSON.stringify(body),
1057
+ });
1058
+ if (resp.status === 401)
1059
+ await reLoginOrThrow();
1060
+ if (resp.status === 405) {
1061
+ // The connector config didn't allow this method (server-side check).
1062
+ throw new CliError(`Connector "${body.connector}" rejected ${body.method}: ${await errorDetail(resp)}`, 1);
1063
+ }
1064
+ // The proxy envelope is the 200 body; any other non-2xx is a real CLI failure
1065
+ // (bad connector name, auth, misconfig) — surface the server detail.
1066
+ return (await readJsonOrThrow(resp, "Connector request"));
1067
+ }
1068
+ function readConnectorBodyFile(path) {
1069
+ const resolved = resolve(process.cwd(), path);
1070
+ if (!existsSync(resolved))
1071
+ throw new CliError(`Request body file not found: ${path}`, 2);
1072
+ return readFileSync(resolved, "utf8");
1073
+ }
1074
+ function assertKnownConnectorOptions(args, allowed) {
1075
+ for (const key of Object.keys(args.options)) {
1076
+ if (!allowed.includes(key)) {
1077
+ throw new CliError(`Unknown option --${camelToKebab(key)} for \`railcode connector\`.\n\n${CONNECTOR_HELP}`, 2);
1078
+ }
1079
+ }
1080
+ }
770
1081
  async function readJsonOrThrow(response, action) {
771
1082
  if (response.status === 401) {
772
1083
  throw new CliError(`${action} was rejected (401). Run \`railcode login\` again.`, 1);
@@ -1332,7 +1643,9 @@ async function handleLocalApi(ctx, req, res, url) {
1332
1643
  if (path === "/connections" ||
1333
1644
  path === "/sql" ||
1334
1645
  path === "/llm/generate" ||
1335
- path === "/llm/stream") {
1646
+ path === "/llm/stream" ||
1647
+ path === "/service-connectors" ||
1648
+ path === "/service-connectors/request") {
1336
1649
  await forwardCompute(ctx, req, res, path, url);
1337
1650
  return;
1338
1651
  }
@@ -1524,8 +1837,9 @@ function devCreds(config) {
1524
1837
  }
1525
1838
  // Resolve the server-side app for proxying — lazily and memoized, so we don't
1526
1839
  // 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.
1840
+ // first actual call** (llm/sql/service-connectors request, allowCreate); a
1841
+ // load-time list (`GET /connections`, `/service-connectors`) resolves an existing
1842
+ // app but never creates one.
1529
1843
  function resolveDevAppUuid(ctx, creds, allowCreate) {
1530
1844
  const base = `${creds.apiUrl}/api/organizations/${creds.orgUuid}/apps`;
1531
1845
  const authed = { Authorization: `Bearer ${creds.token}` };
@@ -1557,7 +1871,7 @@ function resolveDevAppUuid(ctx, creds, allowCreate) {
1557
1871
  if (!created.ok)
1558
1872
  return null;
1559
1873
  const app = (await created.json());
1560
- console.log(` created app "${ctx.app}" on ${creds.apiUrl} (first llm/sql call)`);
1874
+ console.log(` created app "${ctx.app}" on ${creds.apiUrl} (first llm/sql/connector call)`);
1561
1875
  ctx.appUuidPromise = Promise.resolve(app.uuid); // later resolves skip the list
1562
1876
  return app.uuid;
1563
1877
  }
@@ -1571,8 +1885,10 @@ function resolveDevAppUuid(ctx, creds, allowCreate) {
1571
1885
  }
1572
1886
  async function forwardCompute(ctx, req, res, path, url) {
1573
1887
  // 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";
1888
+ // Load-time list calls degrade to [] so dataConnectors()/serviceConnectors() at
1889
+ // load don't break; the actual call surfaces (/sql, /llm, /service-connectors/request)
1890
+ // return 503 instead.
1891
+ const degradeOk = path === "/connections" || path === "/service-connectors";
1576
1892
  const creds = devCreds(ctx.config);
1577
1893
  if (!creds) {
1578
1894
  if (degradeOk)
@@ -1582,7 +1898,8 @@ async function forwardCompute(ctx, req, res, path, url) {
1582
1898
  message: "Run `railcode login` to use LLM and connectors in dev.",
1583
1899
  });
1584
1900
  }
1585
- // Only llm/sql may create the app server-side; a load-time GET /connections won't.
1901
+ // Only the actual call surfaces (llm/sql/service-connectors/request) may create
1902
+ // the app server-side; a load-time list (GET /connections, /service-connectors) won't.
1586
1903
  const appUuid = await resolveDevAppUuid(ctx, creds, !degradeOk);
1587
1904
  if (!appUuid) {
1588
1905
  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.10",
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",