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 +12 -0
- package/dist/connectors.js +92 -0
- package/dist/db.js +78 -0
- package/dist/index.js +324 -7
- package/package.json +1 -1
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
|
|
1528
|
-
// load) resolves an existing
|
|
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
|
-
//
|
|
1575
|
-
|
|
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
|
|
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)
|