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 +13 -1
- package/dist/connectors.js +146 -0
- package/dist/db.js +78 -0
- package/dist/index.js +372 -8
- package/package.json +1 -1
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 `
|
|
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 = "
|
|
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
|
|
1528
|
-
// load) resolves an existing
|
|
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
|
-
//
|
|
1575
|
-
|
|
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
|
|
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)
|