railcode 0.1.12 → 0.1.14

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
@@ -36,9 +36,9 @@ railcode db <list|query> ... List data connectors / run read-only SQL
36
36
  build command when needed, then uploads the output dir. The app is
37
37
  created-or-resolved by slug in your saved org, and the live URL is printed.
38
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.
39
+ ad-hoc read-only SQL. It works straight after `railcode login` **no app and no
40
+ `railcode.json` required** since connectors are org-scoped; it hits the app-less
41
+ `/api/organizations/{org}/data/*` plane with your login token.
42
42
  `railcode db list` (aliases `ls`, `connections`) prints each connector's
43
43
  `name` + `engine` (`--json` for the raw array). `railcode db query "<sql>"`
44
44
  (alias `sql`) runs SQL against `--connection` (default `default`) and prints a
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ 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
14
  import { DbError, formatTable, inferEngine, parseEngine, parseSqlParams, pickSqlSource, } from "./db.js";
15
+ import { QueryCmdError, paramSignature, parseParamDecls, parseRunParams, pickAuthoringSql, } from "./query.js";
15
16
  import { ConnectorError, buildConnectorRequest, connectorOpenapiOutput, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderConnectorDocs, renderProxyResponse, } from "./connectors.js";
16
17
  // ---------------------------------------------------------------------------
17
18
  // Constants
@@ -35,6 +36,7 @@ Usage:
35
36
  railcode init <app> [--template static|react]
36
37
  railcode design-system Print your org's design-system guidance (markdown)
37
38
  railcode db <list|query> ... List data connectors / run read-only SQL
39
+ railcode query <list|run|create|update|delete> ... Saved queries: invoke by name / author (admin)
38
40
  railcode connector <list|docs|fetch> ... List connectors / show API docs / proxy a call
39
41
  railcode --version
40
42
  railcode --help
@@ -76,6 +78,10 @@ async function main() {
76
78
  case "db":
77
79
  await commandDb(args);
78
80
  return;
81
+ case "query":
82
+ case "queries":
83
+ await commandQuery(args);
84
+ return;
79
85
  case "connector":
80
86
  case "connectors":
81
87
  await commandConnector(args);
@@ -101,13 +107,13 @@ function parseArgs(argv) {
101
107
  const withoutPrefix = item.slice(2);
102
108
  const eq = withoutPrefix.indexOf("=");
103
109
  if (eq !== -1) {
104
- options[toCamel(withoutPrefix.slice(0, eq))] = withoutPrefix.slice(eq + 1);
110
+ setOption(options, toCamel(withoutPrefix.slice(0, eq)), withoutPrefix.slice(eq + 1));
105
111
  continue;
106
112
  }
107
113
  const key = toCamel(withoutPrefix);
108
114
  const next = tail[i + 1];
109
115
  if (next && !next.startsWith("--")) {
110
- options[key] = next;
116
+ setOption(options, key, next);
111
117
  i += 1;
112
118
  }
113
119
  else {
@@ -116,6 +122,20 @@ function parseArgs(argv) {
116
122
  }
117
123
  return { command, rest, options };
118
124
  }
125
+ // A string option given twice accumulates into an array (optionString then
126
+ // rejects it with "can only be given once"); a repeated flag just stays true.
127
+ function setOption(options, key, value) {
128
+ const existing = options[key];
129
+ if (typeof existing === "string") {
130
+ options[key] = [existing, value];
131
+ }
132
+ else if (Array.isArray(existing)) {
133
+ existing.push(value);
134
+ }
135
+ else {
136
+ options[key] = value;
137
+ }
138
+ }
119
139
  function toCamel(input) {
120
140
  return input.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
121
141
  }
@@ -174,9 +194,15 @@ async function authorizeCliWithBrowser(apiUrl) {
174
194
  redirect_uri: callback.redirectUri,
175
195
  state,
176
196
  })}`;
177
- console.log("Open this link to authorize the Railcode CLI:");
178
- console.log(authorizeUrl);
179
197
  console.log("");
198
+ printUrlBox("Railcode login", authorizeUrl);
199
+ console.log("");
200
+ if (openBrowser(authorizeUrl)) {
201
+ console.log("Opening your browser. If it does not open, paste the URL above.");
202
+ }
203
+ else {
204
+ console.log("Paste the URL above into your browser to continue.");
205
+ }
180
206
  console.log("Waiting for browser authorization...");
181
207
  const code = await callback.code;
182
208
  return await exchangeCliAuthCode(apiUrl, code);
@@ -267,6 +293,25 @@ async function startCliAuthCallback(expectedState) {
267
293
  },
268
294
  };
269
295
  }
296
+ function openBrowser(url) {
297
+ const opener = process.platform === "darwin"
298
+ ? { command: "open", args: [url] }
299
+ : process.platform === "win32"
300
+ ? { command: "cmd", args: ["/c", "start", "", url] }
301
+ : { command: "xdg-open", args: [url] };
302
+ try {
303
+ const child = spawn(opener.command, opener.args, {
304
+ detached: true,
305
+ stdio: "ignore",
306
+ });
307
+ child.on("error", () => { });
308
+ child.unref();
309
+ return true;
310
+ }
311
+ catch {
312
+ return false;
313
+ }
314
+ }
270
315
  function sendBrowserCallback(response, status, message) {
271
316
  response.writeHead(status, { "Content-Type": "text/html" });
272
317
  response.end(`<!doctype html><title>Railcode CLI</title><p>${escapeHtml(message)}</p>`);
@@ -353,17 +398,6 @@ async function resolveOrCreateApp(config, slug) {
353
398
  });
354
399
  return (await readJsonOrThrow(created, "Create app"));
355
400
  }
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
- }
367
401
  async function postDeploy(config, appUuid, files) {
368
402
  // The deploy API is multipart: one part named "files" per file, with the
369
403
  // relative path carried as the part filename.
@@ -815,7 +849,7 @@ Query options:
815
849
  --json Print the raw { columns, rows, rowcount, truncated } envelope
816
850
 
817
851
  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).
852
+ the query. Works right after \`railcode login\` — no app or ${MANIFEST_NAME} required.
819
853
  `;
820
854
  async function commandDb(args) {
821
855
  try {
@@ -851,8 +885,7 @@ async function commandDbList(args) {
851
885
  throw new CliError(`railcode db list takes no arguments (got "${args.rest[1]}").`, 2);
852
886
  }
853
887
  const config = requireLogin(args);
854
- const app = await resolveExistingApp(config, requireAppSlug());
855
- const connections = await fetchDbConnections(config, app.uuid);
888
+ const connections = await fetchDbConnections(config);
856
889
  if (args.options.json) {
857
890
  console.log(JSON.stringify(connections, null, 2));
858
891
  return;
@@ -868,16 +901,15 @@ async function commandDbQuery(args) {
868
901
  if (args.rest.length > 2) {
869
902
  throw new CliError('railcode db query takes a single SQL string — quote it: railcode db query "select 1".', 2);
870
903
  }
871
- const source = pickSqlSource(args.rest[1], args.options.file);
904
+ const source = pickSqlSource(args.rest[1], singleOption(args, "file"));
872
905
  const params = parseSqlParams(optionString(args, "params"));
873
- const engineOption = parseEngine(args.options.engine);
906
+ const engineOption = parseEngine(singleOption(args, "engine"));
874
907
  const connection = optionString(args, "connection") ?? "default";
875
908
  const query = source.kind === "file" ? readSqlFile(source.path) : source.sql;
876
909
  const config = requireLogin(args);
877
- const app = await resolveExistingApp(config, requireAppSlug());
878
910
  // --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 });
911
+ const engine = engineOption ?? inferEngine(await fetchDbConnections(config), connection);
912
+ const result = await runDbQuery(config, { engine, connection, query, params });
881
913
  if (args.options.json) {
882
914
  console.log(JSON.stringify(result, null, 2));
883
915
  return;
@@ -889,22 +921,14 @@ async function commandDbQuery(args) {
889
921
  console.log("Note: results were truncated by the server.");
890
922
  }
891
923
  }
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`);
924
+ async function fetchDbConnections(config) {
925
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/connections`);
902
926
  if (resp.status === 401)
903
927
  await reLoginOrThrow();
904
928
  return (await readJsonOrThrow(resp, "List connections"));
905
929
  }
906
- async function runDbQuery(config, appUuid, body) {
907
- const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/sql`, {
930
+ async function runDbQuery(config, body) {
931
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/sql`, {
908
932
  method: "POST",
909
933
  headers: { "Content-Type": "application/json" },
910
934
  body: JSON.stringify(body),
@@ -926,8 +950,20 @@ function optionString(args, key) {
926
950
  const value = args.options[key];
927
951
  if (value === true)
928
952
  throw new CliError(`--${camelToKebab(key)} requires a value.`, 2);
953
+ if (Array.isArray(value)) {
954
+ throw new CliError(`--${camelToKebab(key)} can only be given once.`, 2);
955
+ }
929
956
  return typeof value === "string" ? value : undefined;
930
957
  }
958
+ // A single-valued option that may legitimately be a bare flag (the db/connector
959
+ // parsers handle `true` themselves) — only a REPEATED option is rejected here.
960
+ function singleOption(args, key) {
961
+ const value = args.options[key];
962
+ if (Array.isArray(value)) {
963
+ throw new CliError(`--${camelToKebab(key)} can only be given once.`, 2);
964
+ }
965
+ return value;
966
+ }
931
967
  function assertKnownDbOptions(args, allowed) {
932
968
  for (const key of Object.keys(args.options)) {
933
969
  if (!allowed.includes(key)) {
@@ -939,13 +975,260 @@ function camelToKebab(value) {
939
975
  return value.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
940
976
  }
941
977
  // ---------------------------------------------------------------------------
978
+ // query — saved queries (`railcode query list|run|create|delete`)
979
+ //
980
+ // list/run hit the org-scoped `/data/queries` plane with the personal token
981
+ // (subject to `saved_query` grants; `_ctx_*` binds resolve to YOU). create/delete
982
+ // hit the admin management API (`/saved-queries`, connection:manage required).
983
+ // ---------------------------------------------------------------------------
984
+ const QUERY_HELP = `railcode query — saved queries
985
+
986
+ Usage:
987
+ railcode query list List this org's saved queries
988
+ railcode query run <name> [--params <json>] Invoke a saved query by name
989
+ railcode query create --name <n> --connection <c> (--sql <sql> | --sql-file <f>)
990
+ [--params <json>] [--description <d>]
991
+ Author a saved query (admin)
992
+ railcode query update <name> [--sql <sql> | --sql-file <f>] [--params <json>]
993
+ [--clear-params] [--connection <c>] [--description <d>]
994
+ Edit a saved query in place (admin;
995
+ omitted fields keep their value)
996
+ railcode query delete <name> Delete a saved query (admin)
997
+
998
+ Aliases: query = queries; list = ls; run = invoke.
999
+
1000
+ Run options:
1001
+ --params '<json-object>' Caller params, exactly as the API takes
1002
+ them: '{"region":"emea","limit":5}'.
1003
+ Params declared with a default may be
1004
+ omitted (the server binds the default).
1005
+ --json Print the raw { columns, rows, ... } envelope
1006
+
1007
+ Create / update options:
1008
+ --name <name> Invoke handle ([a-z0-9_], unique per org)
1009
+ --connection <name> Data connection the query runs against
1010
+ --sql <sql> The SQL template inline (:param style);
1011
+ quote it, or use --sql="..." if it starts
1012
+ with a -- comment
1013
+ --sql-file <path> File holding the SQL template
1014
+ --params '<json-array>' Declared params in the API shape:
1015
+ '[{"name":"region","type":"string","default":"emea"},
1016
+ {"name":"limit","type":"int"}]'
1017
+ Types: string, int, float, bool. A "default"
1018
+ makes the param optional at invoke time.
1019
+ On update the array REPLACES the params
1020
+ --clear-params (update only) declare no params
1021
+ --description <text> Shown to members next to the name
1022
+
1023
+ Editing the SQL or params bumps the query's version; grants naming the query are
1024
+ untouched (unlike delete + recreate, which removes them).
1025
+
1026
+ Templates may reference :_ctx_user_id, :_ctx_user_email and :_ctx_org — the server
1027
+ injects those from whoever invokes (they can never be passed as params), so a
1028
+ query like "where rep_email = :_ctx_user_email" is row-scoped per caller.
1029
+ `;
1030
+ async function commandQuery(args) {
1031
+ try {
1032
+ const sub = args.rest[0] ?? "";
1033
+ switch (sub) {
1034
+ case "list":
1035
+ case "ls":
1036
+ await commandQueryList(args);
1037
+ return;
1038
+ case "run":
1039
+ case "invoke":
1040
+ await commandQueryRun(args);
1041
+ return;
1042
+ case "create":
1043
+ await commandQueryCreate(args);
1044
+ return;
1045
+ case "update":
1046
+ await commandQueryUpdate(args);
1047
+ return;
1048
+ case "delete":
1049
+ await commandQueryDelete(args);
1050
+ return;
1051
+ case "":
1052
+ case "help":
1053
+ console.log(QUERY_HELP);
1054
+ return;
1055
+ default:
1056
+ throw new CliError(`Unknown query command: ${sub}\n\n${QUERY_HELP}`, 2);
1057
+ }
1058
+ }
1059
+ catch (error) {
1060
+ if (error instanceof QueryCmdError)
1061
+ throw new CliError(error.message, 2);
1062
+ throw error;
1063
+ }
1064
+ }
1065
+ async function commandQueryList(args) {
1066
+ assertKnownQueryOptions(args, ["json", "apiUrl"]);
1067
+ if (args.rest.length > 1) {
1068
+ throw new CliError(`railcode query list takes no arguments (got "${args.rest[1]}").`, 2);
1069
+ }
1070
+ const config = requireLogin(args);
1071
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/queries`);
1072
+ if (resp.status === 401)
1073
+ await reLoginOrThrow();
1074
+ const queries = (await readJsonOrThrow(resp, "List saved queries"));
1075
+ if (args.options.json) {
1076
+ console.log(JSON.stringify(queries, null, 2));
1077
+ return;
1078
+ }
1079
+ if (queries.length === 0) {
1080
+ console.log("No saved queries in this organization.");
1081
+ return;
1082
+ }
1083
+ console.log(formatTable(["name", "params", "version", "description"], queries.map((query) => [
1084
+ query.name,
1085
+ paramSignature(query.params),
1086
+ `v${query.version}`,
1087
+ query.description || "-",
1088
+ ])));
1089
+ }
1090
+ async function commandQueryRun(args) {
1091
+ assertKnownQueryOptions(args, ["params", "json", "apiUrl"]);
1092
+ const name = args.rest[1];
1093
+ if (!name || args.rest.length > 2) {
1094
+ throw new CliError(`Usage: railcode query run <name> [--params '{"k":"v"}']`, 2);
1095
+ }
1096
+ const params = parseRunParams(optionString(args, "params"));
1097
+ const config = requireLogin(args);
1098
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/queries/${encodeURIComponent(name)}`, {
1099
+ method: "POST",
1100
+ headers: { "Content-Type": "application/json" },
1101
+ body: JSON.stringify({ params }),
1102
+ });
1103
+ if (resp.status === 401)
1104
+ await reLoginOrThrow();
1105
+ const result = (await readJsonOrThrow(resp, `Run saved query "${name}"`));
1106
+ if (args.options.json) {
1107
+ console.log(JSON.stringify(result, null, 2));
1108
+ return;
1109
+ }
1110
+ console.log(formatTable(result.columns, result.rows));
1111
+ console.log("");
1112
+ console.log(`(${result.rowcount} row${result.rowcount === 1 ? "" : "s"})`);
1113
+ if (result.truncated) {
1114
+ console.log("Note: results were truncated by the server.");
1115
+ }
1116
+ }
1117
+ async function commandQueryCreate(args) {
1118
+ assertKnownQueryOptions(args, [
1119
+ "name",
1120
+ "connection",
1121
+ "sql",
1122
+ "sqlFile",
1123
+ "params",
1124
+ "description",
1125
+ "apiUrl",
1126
+ ]);
1127
+ const name = optionString(args, "name");
1128
+ const connection = optionString(args, "connection");
1129
+ if (!name || !connection) {
1130
+ throw new CliError("railcode query create requires --name and --connection.\n\n" + QUERY_HELP, 2);
1131
+ }
1132
+ // required=true: pickAuthoringSql throws rather than return null here.
1133
+ const source = pickAuthoringSql(optionString(args, "sql"), optionString(args, "sqlFile"), true);
1134
+ const params = parseParamDecls(optionString(args, "params"));
1135
+ const sql = source.kind === "file" ? readSqlFile(source.path) : source.sql;
1136
+ const config = requireLogin(args);
1137
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/saved-queries`, {
1138
+ method: "POST",
1139
+ headers: { "Content-Type": "application/json" },
1140
+ body: JSON.stringify({
1141
+ name,
1142
+ connection,
1143
+ sql,
1144
+ params,
1145
+ description: optionString(args, "description") ?? "",
1146
+ }),
1147
+ });
1148
+ if (resp.status === 401)
1149
+ await reLoginOrThrow();
1150
+ const created = (await readJsonOrThrow(resp, `Create saved query "${name}"`));
1151
+ console.log(`Saved query "${created.name}" created (v${created.version}).`);
1152
+ console.log(`Invoke it: railcode query run ${created.name}`);
1153
+ }
1154
+ async function commandQueryUpdate(args) {
1155
+ assertKnownQueryOptions(args, [
1156
+ "sql",
1157
+ "sqlFile",
1158
+ "params",
1159
+ "clearParams",
1160
+ "connection",
1161
+ "description",
1162
+ "apiUrl",
1163
+ ]);
1164
+ const name = args.rest[1];
1165
+ if (!name || args.rest.length > 2) {
1166
+ throw new CliError("Usage: railcode query update <name> [--sql ... | --sql-file ...]", 2);
1167
+ }
1168
+ const source = pickAuthoringSql(optionString(args, "sql"), optionString(args, "sqlFile"), false);
1169
+ const sql = source ? (source.kind === "file" ? readSqlFile(source.path) : source.sql) : undefined;
1170
+ const paramsJson = optionString(args, "params");
1171
+ const clearParams = args.options.clearParams === true;
1172
+ if (clearParams && paramsJson !== undefined) {
1173
+ throw new CliError("--clear-params cannot be combined with --params.", 2);
1174
+ }
1175
+ const params = clearParams ? [] : paramsJson !== undefined ? parseParamDecls(paramsJson) : undefined;
1176
+ const connection = optionString(args, "connection");
1177
+ const description = optionString(args, "description");
1178
+ // PATCH semantics: only the fields present in the body change; params replace
1179
+ // the whole declared list (the backend re-validates them against the SQL).
1180
+ const body = {};
1181
+ if (connection !== undefined)
1182
+ body.connection = connection;
1183
+ if (sql !== undefined)
1184
+ body.sql = sql;
1185
+ if (params !== undefined)
1186
+ body.params = params;
1187
+ if (description !== undefined)
1188
+ body.description = description;
1189
+ if (Object.keys(body).length === 0) {
1190
+ throw new CliError("Nothing to update. Pass --sql/--sql-file, --params/--clear-params, --connection or --description.", 2);
1191
+ }
1192
+ const config = requireLogin(args);
1193
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/saved-queries/${encodeURIComponent(name)}`, {
1194
+ method: "PATCH",
1195
+ headers: { "Content-Type": "application/json" },
1196
+ body: JSON.stringify(body),
1197
+ });
1198
+ if (resp.status === 401)
1199
+ await reLoginOrThrow();
1200
+ const updated = (await readJsonOrThrow(resp, `Update saved query "${name}"`));
1201
+ console.log(`Saved query "${updated.name}" updated (v${updated.version}).`);
1202
+ }
1203
+ async function commandQueryDelete(args) {
1204
+ assertKnownQueryOptions(args, ["apiUrl"]);
1205
+ const name = args.rest[1];
1206
+ if (!name || args.rest.length > 2) {
1207
+ throw new CliError("Usage: railcode query delete <name>", 2);
1208
+ }
1209
+ const config = requireLogin(args);
1210
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/saved-queries/${encodeURIComponent(name)}`, { method: "DELETE" });
1211
+ if (resp.status === 401)
1212
+ await reLoginOrThrow();
1213
+ if (!resp.ok) {
1214
+ throw new CliError(`Delete saved query "${name}" failed (${resp.status}): ${await errorDetail(resp)}`, 1);
1215
+ }
1216
+ console.log(`Saved query "${name}" deleted (its grants were removed too).`);
1217
+ }
1218
+ function assertKnownQueryOptions(args, allowed) {
1219
+ for (const key of Object.keys(args.options)) {
1220
+ if (!allowed.includes(key)) {
1221
+ throw new CliError(`Unknown option --${camelToKebab(key)} for \`railcode query\`.\n\n${QUERY_HELP}`, 2);
1222
+ }
1223
+ }
1224
+ }
1225
+ // ---------------------------------------------------------------------------
942
1226
  // connector — list service connectors + proxy one call
943
1227
  // (`railcode connector list|fetch`)
944
1228
  //
945
1229
  // 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.
1230
+ // Like `railcode db`, these one-shot commands hit the org-scoped `/data/*` plane
1231
+ // with the login bearer token no app (and no railcode.json) required.
949
1232
  // ---------------------------------------------------------------------------
950
1233
  const CONNECTOR_HELP = `railcode connector — service connectors
951
1234
 
@@ -972,8 +1255,8 @@ Fetch options:
972
1255
  --json Print the raw { status, ok, headers, body, truncated } envelope
973
1256
 
974
1257
  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).
1258
+ upstream status is still printed but exits non-zero. Works right after
1259
+ \`railcode login\` — no app or ${MANIFEST_NAME} required.
977
1260
  `;
978
1261
  async function commandConnector(args) {
979
1262
  try {
@@ -1013,8 +1296,7 @@ async function commandConnectorList(args) {
1013
1296
  throw new CliError(`railcode connector list takes no arguments (got "${args.rest[1]}").`, 2);
1014
1297
  }
1015
1298
  const config = requireLogin(args);
1016
- const app = await resolveExistingApp(config, requireAppSlug());
1017
- const connectors = await fetchServiceConnectors(config, app.uuid);
1299
+ const connectors = await fetchServiceConnectors(config);
1018
1300
  if (args.options.json) {
1019
1301
  console.log(JSON.stringify(connectors, null, 2));
1020
1302
  return;
@@ -1030,9 +1312,9 @@ async function commandConnectorFetch(args) {
1030
1312
  if (args.rest.length > 2) {
1031
1313
  throw new CliError('railcode connector fetch takes a single path — quote it: railcode connector fetch "/v1/charges".', 2);
1032
1314
  }
1033
- const connector = parseConnectorOption(args.options.connector);
1034
- const method = parseMethodOption(args.options.method);
1035
- const bodySource = pickConnectorBody(args.options.body, args.options.file);
1315
+ const connector = parseConnectorOption(singleOption(args, "connector"));
1316
+ const method = parseMethodOption(singleOption(args, "method"));
1317
+ const bodySource = pickConnectorBody(singleOption(args, "body"), singleOption(args, "file"));
1036
1318
  const body = bodySource.kind === "file"
1037
1319
  ? readConnectorBodyFile(bodySource.path)
1038
1320
  : bodySource.kind === "inline"
@@ -1040,8 +1322,7 @@ async function commandConnectorFetch(args) {
1040
1322
  : null;
1041
1323
  const payload = buildConnectorRequest({ connector, method, path: args.rest[1] ?? "", body });
1042
1324
  const config = requireLogin(args);
1043
- const app = await resolveExistingApp(config, requireAppSlug());
1044
- const envelope = await runConnectorRequest(config, app.uuid, payload);
1325
+ const envelope = await runConnectorRequest(config, payload);
1045
1326
  if (args.options.json) {
1046
1327
  console.log(JSON.stringify(envelope, null, 2));
1047
1328
  }
@@ -1065,8 +1346,7 @@ async function commandConnectorDocs(args) {
1065
1346
  throw new CliError("Pass either --json or --openapi, not both.", 2);
1066
1347
  }
1067
1348
  const config = requireLogin(args);
1068
- const app = await resolveExistingApp(config, requireAppSlug());
1069
- const docs = await fetchServiceConnectorDocs(config, app.uuid, name);
1349
+ const docs = await fetchServiceConnectorDocs(config, name);
1070
1350
  if (args.options.json) {
1071
1351
  console.log(JSON.stringify(docs, null, 2));
1072
1352
  return;
@@ -1081,14 +1361,14 @@ async function commandConnectorDocs(args) {
1081
1361
  }
1082
1362
  console.log(renderConnectorDocs(docs));
1083
1363
  }
1084
- async function fetchServiceConnectors(config, appUuid) {
1085
- const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/service-connectors`);
1364
+ async function fetchServiceConnectors(config) {
1365
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/service-connectors`);
1086
1366
  if (resp.status === 401)
1087
1367
  await reLoginOrThrow();
1088
1368
  return (await readJsonOrThrow(resp, "List service connectors"));
1089
1369
  }
1090
- async function fetchServiceConnectorDocs(config, appUuid, name) {
1091
- const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/service-connectors/${encodeURIComponent(name)}`);
1370
+ async function fetchServiceConnectorDocs(config, name) {
1371
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/service-connectors/${encodeURIComponent(name)}`);
1092
1372
  if (resp.status === 401)
1093
1373
  await reLoginOrThrow();
1094
1374
  if (resp.status === 404) {
@@ -1096,8 +1376,8 @@ async function fetchServiceConnectorDocs(config, appUuid, name) {
1096
1376
  }
1097
1377
  return (await readJsonOrThrow(resp, "Connector docs"));
1098
1378
  }
1099
- async function runConnectorRequest(config, appUuid, body) {
1100
- const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/service-connectors/request`, {
1379
+ async function runConnectorRequest(config, body) {
1380
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/service-connectors/request`, {
1101
1381
  method: "POST",
1102
1382
  headers: { "Content-Type": "application/json" },
1103
1383
  body: JSON.stringify(body),
@@ -1849,6 +2129,8 @@ async function handleLocalApi(ctx, req, res, url) {
1849
2129
  }
1850
2130
  // Proxied surfaces → forward to the real instance with the saved token.
1851
2131
  if (path === "/connections" ||
2132
+ path === "/queries" ||
2133
+ path.startsWith("/queries/") ||
1852
2134
  path === "/sql" ||
1853
2135
  path === "/llm/generate" ||
1854
2136
  path === "/llm/stream" ||
@@ -2093,10 +2375,10 @@ function resolveDevAppUuid(ctx, creds, allowCreate) {
2093
2375
  }
2094
2376
  async function forwardCompute(ctx, req, res, path, url) {
2095
2377
  // Never reply 401 to the browser — the SDK reloads the page on 401 (loop).
2096
- // Load-time list calls degrade to [] so dataConnectors()/serviceConnectors() at
2097
- // load don't break; the actual call surfaces (/sql, /llm, /service-connectors/request)
2098
- // return 503 instead.
2099
- const degradeOk = path === "/connections" || path === "/service-connectors";
2378
+ // Load-time list calls degrade to [] so dataConnectors()/savedQueries()/
2379
+ // serviceConnectors() at load don't break; actual call surfaces
2380
+ // (/queries/{name}, /sql, /llm, /service-connectors/request) return 503 instead.
2381
+ const degradeOk = path === "/connections" || path === "/queries" || path === "/service-connectors";
2100
2382
  const creds = devCreds(ctx.config);
2101
2383
  if (!creds) {
2102
2384
  if (degradeOk)
@@ -2106,8 +2388,8 @@ async function forwardCompute(ctx, req, res, path, url) {
2106
2388
  message: "Run `railcode login` to use LLM and connectors in dev.",
2107
2389
  });
2108
2390
  }
2109
- // Only the actual call surfaces (llm/sql/service-connectors/request) may create
2110
- // the app server-side; a load-time list (GET /connections, /service-connectors) won't.
2391
+ // Only the actual call surfaces (saved-query invoke / llm / sql /
2392
+ // service-connectors/request) may create the app server-side; load-time lists won't.
2111
2393
  const appUuid = await resolveDevAppUuid(ctx, creds, !degradeOk);
2112
2394
  if (!appUuid) {
2113
2395
  if (degradeOk)
package/dist/query.js ADDED
@@ -0,0 +1,94 @@
1
+ // Pure helpers behind `railcode query list|run|create|delete`. Kept free of any
2
+ // I/O or process state so they can be unit-tested directly (see
3
+ // test/query.test.mjs); the command layer in index.ts does the HTTP and printing.
4
+ export class QueryCmdError extends Error {
5
+ }
6
+ // Matches the backend's declared param types (schemas/saved_query.py).
7
+ export const QUERY_PARAM_TYPES = ["string", "int", "float", "bool"];
8
+ // `query run --params` — the caller params as ONE JSON object. The CLI is
9
+ // mostly driven by agents, and agents speak JSON natively: no name=value
10
+ // mini-grammar, the flag takes exactly what the API takes.
11
+ export function parseRunParams(json) {
12
+ if (json === undefined)
13
+ return {};
14
+ let parsed;
15
+ try {
16
+ parsed = JSON.parse(json);
17
+ }
18
+ catch {
19
+ throw new QueryCmdError(`--params must be valid JSON. Example: --params '{"region":"emea","limit":5}'`);
20
+ }
21
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
22
+ throw new QueryCmdError(`--params must be a JSON object, e.g. '{"region":"emea"}'.`);
23
+ }
24
+ return parsed;
25
+ }
26
+ // `query create|update --params` — the declared params as a JSON array in the
27
+ // exact API shape: [{"name","type","default"?}]. A "default" makes the param
28
+ // optional at invoke time (the server binds it when a caller omits the param);
29
+ // the backend re-validates it against the declared type at save.
30
+ export function parseParamDecls(json) {
31
+ if (json === undefined)
32
+ return [];
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(json);
36
+ }
37
+ catch {
38
+ throw new QueryCmdError(`--params must be valid JSON. Example: --params '[{"name":"region","type":"string","default":"emea"}]'`);
39
+ }
40
+ if (!Array.isArray(parsed)) {
41
+ throw new QueryCmdError(`--params must be a JSON array of {"name","type","default"?} objects.`);
42
+ }
43
+ return parsed.map((item, i) => {
44
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
45
+ throw new QueryCmdError(`--params[${i}] must be an object like {"name":"region","type":"string"}.`);
46
+ }
47
+ const { name, type, default: def, ...extra } = item;
48
+ if (typeof name !== "string" || name === "") {
49
+ throw new QueryCmdError(`--params[${i}] needs a non-empty string "name".`);
50
+ }
51
+ if (typeof type !== "string" || !QUERY_PARAM_TYPES.includes(type)) {
52
+ throw new QueryCmdError(`--params[${i}] ("${name}") needs "type": one of ${QUERY_PARAM_TYPES.join(", ")}.`);
53
+ }
54
+ const extraKeys = Object.keys(extra);
55
+ if (extraKeys.length > 0) {
56
+ throw new QueryCmdError(`--params[${i}] ("${name}") has unknown key(s): ${extraKeys.join(", ")}.`);
57
+ }
58
+ const decl = { name, type: type };
59
+ if (def !== undefined) {
60
+ if (def === null || !["string", "number", "boolean"].includes(typeof def)) {
61
+ throw new QueryCmdError(`--params[${i}] ("${name}") "default" must be a string, number or boolean — omit it to make the param required.`);
62
+ }
63
+ decl.default = def;
64
+ }
65
+ return decl;
66
+ });
67
+ }
68
+ export function pickAuthoringSql(sql, sqlFile, required) {
69
+ if (sql !== undefined && sqlFile !== undefined) {
70
+ throw new QueryCmdError("Pass the SQL template with --sql or --sql-file, not both.");
71
+ }
72
+ if (sql !== undefined) {
73
+ const trimmed = sql.trim();
74
+ if (!trimmed)
75
+ throw new QueryCmdError("--sql is empty.");
76
+ return { kind: "inline", sql: trimmed };
77
+ }
78
+ if (sqlFile !== undefined) {
79
+ return { kind: "file", path: sqlFile };
80
+ }
81
+ if (required) {
82
+ throw new QueryCmdError('Provide the SQL template: --sql "select ..." or --sql-file query.sql.');
83
+ }
84
+ return null;
85
+ }
86
+ // Render one saved query's signature for the list table, e.g.
87
+ // "region?: string = "emea", limit: int" — `?` marks optional (defaulted) params.
88
+ export function paramSignature(params) {
89
+ return (params
90
+ .map((p) => p.default === undefined
91
+ ? `${p.name}: ${p.type}`
92
+ : `${p.name}?: ${p.type} = ${JSON.stringify(p.default)}`)
93
+ .join(", ") || "-");
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "railcode",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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",
package/static/sdk.js CHANGED
@@ -120,10 +120,10 @@
120
120
  this.name = "ApiError";
121
121
  }
122
122
  };
123
- function buildUrl(path, query) {
123
+ function buildUrl(path, query2) {
124
124
  const url2 = new URL(BASE + path, location.origin);
125
- if (query) {
126
- for (const [key, value] of Object.entries(query)) {
125
+ if (query2) {
126
+ for (const [key, value] of Object.entries(query2)) {
127
127
  if (value !== void 0) url2.searchParams.set(key, String(value));
128
128
  }
129
129
  }
@@ -148,8 +148,8 @@
148
148
  if (resp.status === 204) return void 0;
149
149
  return await resp.json();
150
150
  }
151
- async function getOrNull(path, query) {
152
- const resp = await send(buildUrl(path, query), { credentials: "include" });
151
+ async function getOrNull(path, query2) {
152
+ const resp = await send(buildUrl(path, query2), { credentials: "include" });
153
153
  if (resp.status === 404) return null;
154
154
  if (!resp.ok) throw new ApiError(resp.status, await resp.text());
155
155
  return await resp.json();
@@ -232,8 +232,8 @@
232
232
  rows.truncated = r.truncated;
233
233
  return rows;
234
234
  };
235
- var runSQL = (engine, connection, query, params) => {
236
- const trimmed = shorten(query.replace(/\s+/g, " ").trim(), QUERY_LABEL_MAX);
235
+ var runSQL = (engine, connection, query2, params) => {
236
+ const trimmed = shorten(query2.replace(/\s+/g, " ").trim(), QUERY_LABEL_MAX);
237
237
  const label2 = `${engine ?? "data"}(${q(connection)}).runSQL(${q(trimmed)}${params && params.length ? `, ${shorten(JSON.stringify(params), PARAMS_LABEL_MAX)}` : ""})`;
238
238
  return track(
239
239
  "sql",
@@ -241,16 +241,16 @@
241
241
  () => call("POST", "/sql", {
242
242
  // engine is advisory; omit it for the generic data() namespace so the backend
243
243
  // dispatches on the connection's stored kind. postgres() pins engine="postgres".
244
- body: { ...engine ? { engine } : {}, connection, query, params: params || [] }
244
+ body: { ...engine ? { engine } : {}, connection, query: query2, params: params || [] }
245
245
  }).then(toSqlRows)
246
246
  );
247
247
  };
248
248
  var namespace = (engine) => {
249
249
  const handle = (connection) => ({
250
- runSQL: (query, params) => runSQL(engine, connection, query, params)
250
+ runSQL: (query2, params) => runSQL(engine, connection, query2, params)
251
251
  });
252
252
  const ns = handle;
253
- ns.runSQL = (query, params) => runSQL(engine, "default", query, params);
253
+ ns.runSQL = (query2, params) => runSQL(engine, "default", query2, params);
254
254
  return ns;
255
255
  };
256
256
  var data = namespace();
@@ -499,6 +499,20 @@
499
499
  }
500
500
  var llm = { generate, stream };
501
501
 
502
+ // ../sdk/src/queries.ts
503
+ var PARAMS_LABEL_MAX2 = 40;
504
+ var query = (name, params) => {
505
+ const label2 = `query(${q(name)}${params && Object.keys(params).length ? `, ${shorten(JSON.stringify(params), PARAMS_LABEL_MAX2)}` : ""})`;
506
+ return track(
507
+ "sql",
508
+ label2,
509
+ () => call("POST", `/queries/${encPath(name)}`, {
510
+ body: { params: params || {} }
511
+ }).then(toSqlRows)
512
+ );
513
+ };
514
+ var savedQueries = () => track("queries", "savedQueries()", () => call("GET", "/queries"));
515
+
502
516
  // ../sdk/src/service-connectors.ts
503
517
  var PATH_LABEL_MAX = 60;
504
518
  var toResponse = (env) => {
@@ -547,4 +561,6 @@
547
561
  target.dataConnectors = dataConnectors;
548
562
  target.connector = connector;
549
563
  target.serviceConnectors = serviceConnectors;
564
+ target.query = query;
565
+ target.savedQueries = savedQueries;
550
566
  })();