railcode 0.1.2 → 0.1.6

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
@@ -46,7 +46,7 @@ mkdir my-app
46
46
  cd my-app
47
47
  railcode init my-app
48
48
  railcode dev
49
- railcode design-system pull design-system.md
49
+ railcode get design-system
50
50
  railcode deploy
51
51
  ```
52
52
 
@@ -56,20 +56,29 @@ at the root, and deployable build output goes in `dist/`. Direct dependencies
56
56
  in the starter are exact version pins.
57
57
 
58
58
  `railcode dev` installs missing app dependencies on first run, then runs the app
59
- from the current Railcode directory with no login step. Identity, access
60
- metadata, KV, and files run locally; backend-backed APIs such as SQL and LLM are
61
- forwarded to the configured Railcode URL when that backend access is available.
59
+ from the current Railcode directory with no login step. It starts on
60
+ `127.0.0.1:7331` and automatically uses the next available port when another dev
61
+ server is already running; use the printed URL. For inferred Vite apps, the
62
+ asset dev server likewise starts at `5173` and moves upward when needed.
63
+ Identity, access metadata, KV, and files run locally; backend-backed APIs such
64
+ as SQL and LLM are forwarded to the configured Railcode URL when that backend
65
+ access is available.
62
66
 
63
67
  `railcode login` opens a browser authorization flow. Pass `--api-url` or set
64
68
  `RAILCODE_API_URL` to choose the Railcode auth/API origin; if neither is set and
65
69
  no saved URL exists, the CLI prompts for the domain and assumes `https://` when
66
70
  you enter a bare domain.
67
71
 
68
- `railcode design-system pull` fetches the platform design-system markdown from
72
+ `railcode get design-system` fetches the platform design-system markdown from
69
73
  `/v1/config/design-system` using the saved browser-authorized API token or
70
- `RAILCODE_API_TOKEN`. It
71
- prints markdown to stdout by default, or writes to a path passed positionally or
72
- with `--output`.
74
+ `RAILCODE_API_TOKEN` and prints it to stdout.
75
+
76
+ `railcode db list` and `railcode db query "<sql>"` read the admin-configured data
77
+ connectors from the terminal — listing them (`GET /v1/connections`) or running a
78
+ read-only SQL query against one (`POST /v1/sql`). The engine is inferred from the
79
+ named connector (override with `--engine`); use `--connection <name>` to pick a
80
+ connector, `--params '<json-array>'` for `$1, $2, ...` placeholders, and `--json`
81
+ for the raw response. Authenticates with the saved token or `RAILCODE_API_TOKEN`.
73
82
 
74
83
  `railcode deploy` runs from the current Railcode app directory, builds the app,
75
84
  and publishes the inferred app output over HTTP to the canonical API host. It
package/dist/index.js CHANGED
@@ -8,14 +8,15 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
8
8
  import process from "node:process";
9
9
  import { createInterface } from "node:readline/promises";
10
10
  import { fileURLToPath } from "node:url";
11
- const VERSION = "0.1.1";
12
11
  const CLI_PACKAGE_NAME = "railcode";
13
12
  const SKILL_NAME = "create-railcode-app";
14
13
  const RAILCODE_HOME = process.env.RAILCODE_HOME || join(homedir(), ".railcode");
15
14
  const CONFIG_PATH = join(RAILCODE_HOME, "config.json");
16
15
  const CLI_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
- const DEFAULT_DEV_PORT = "7331";
18
- const DEFAULT_ASSET_DEV_PORT = "5173";
16
+ const VERSION = readCliPackageVersion();
17
+ const DEFAULT_DEV_PORT = 7331;
18
+ const DEFAULT_ASSET_DEV_PORT = 5173;
19
+ const PORT_SEARCH_LIMIT = 100;
19
20
  const CLI_AUTH_CALLBACK_PATH = "/railcode-cli/callback";
20
21
  const CLI_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
21
22
  const HELP = `railcode ${VERSION}
@@ -23,8 +24,11 @@ const HELP = `railcode ${VERSION}
23
24
  Usage:
24
25
  railcode dev [--verbose]
25
26
  railcode login [--api-url <url>]
26
- railcode deploy
27
- railcode design-system pull [output.md]
27
+ railcode deploy [--private | --public]
28
+ railcode access [public|private|restricted] [--users <a,b>]
29
+ railcode db list
30
+ railcode db query "<sql>" [--connection <name>] [--engine <postgres|mysql>]
31
+ railcode get design-system
28
32
  railcode init <app>
29
33
  railcode upgrade
30
34
  railcode skill stamp [path]
@@ -54,6 +58,15 @@ async function main() {
54
58
  case "deploy":
55
59
  await commandDeploy(args);
56
60
  return;
61
+ case "access":
62
+ await commandAccess(args);
63
+ return;
64
+ case "db":
65
+ await commandDb(args);
66
+ return;
67
+ case "get":
68
+ await commandGet(args);
69
+ return;
57
70
  case "design-system":
58
71
  await commandDesignSystem(args);
59
72
  return;
@@ -110,6 +123,14 @@ function parseArgs(argv) {
110
123
  }
111
124
  return { command, rest, options };
112
125
  }
126
+ function readCliPackageVersion() {
127
+ const packagePath = join(CLI_PACKAGE_ROOT, "package.json");
128
+ const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
129
+ if (typeof pkg.version !== "string" || !pkg.version) {
130
+ throw new Error(`Missing CLI package version in ${packagePath}`);
131
+ }
132
+ return pkg.version;
133
+ }
113
134
  function toCamel(input) {
114
135
  return input.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
115
136
  }
@@ -125,17 +146,9 @@ async function commandDevProxy(args) {
125
146
  rmSync(localDevDir(ctx.app), { recursive: true, force: true });
126
147
  }
127
148
  mkdirSync(localDevDir(ctx.app), { recursive: true });
128
- if (ctx.assetCommand) {
149
+ if (ctx.asset.kind !== "none") {
129
150
  await ensureAppDependencies(ctx.root);
130
151
  }
131
- const assetChild = ctx.assetCommand
132
- ? spawn(ctx.assetCommand, {
133
- cwd: ctx.root,
134
- env: process.env,
135
- shell: true,
136
- stdio: "inherit",
137
- })
138
- : null;
139
152
  const server = createServer((request, response) => {
140
153
  handleDevRequest(ctx, request, response).catch((error) => {
141
154
  const message = error instanceof Error ? error.message : String(error);
@@ -148,11 +161,26 @@ async function commandDevProxy(args) {
148
161
  server.on("upgrade", (request, socket, head) => {
149
162
  proxyWebSocket(ctx, request, socket, head);
150
163
  });
151
- await new Promise((resolveListen, reject) => {
152
- server.once("error", reject);
153
- server.listen(Number(ctx.port), "127.0.0.1", resolveListen);
154
- });
164
+ let assetProcess = null;
165
+ try {
166
+ ctx.port = await listenOnAvailablePort(server, ctx.portStart, "Railcode dev");
167
+ assetProcess = await startAssetDevServer(ctx);
168
+ }
169
+ catch (error) {
170
+ if (server.listening)
171
+ await closeServer(server);
172
+ assetProcess?.reservation?.release();
173
+ if (assetProcess?.child && !assetProcess.child.killed)
174
+ assetProcess.child.kill("SIGTERM");
175
+ throw error;
176
+ }
155
177
  const localUrl = `http://127.0.0.1:${ctx.port}`;
178
+ if (ctx.port !== ctx.portStart) {
179
+ console.log(`Port ${ctx.portStart} was busy; using ${ctx.port}.`);
180
+ }
181
+ if (ctx.assetPort && ctx.assetPortStart && ctx.assetPort !== ctx.assetPortStart) {
182
+ console.log(`Asset port ${ctx.assetPortStart} was busy; using ${ctx.assetPort}.`);
183
+ }
156
184
  printUrlBox(ctx.app, localUrl);
157
185
  console.log("");
158
186
  // Identity, KV, and files are served locally; LLM and SQL forward to the real
@@ -173,8 +201,11 @@ async function commandDevProxy(args) {
173
201
  console.log(`App root: ${ctx.root}`);
174
202
  console.log(`KV/files: ${localDevDir(ctx.app)}`);
175
203
  console.log(`Prod APIs: ${ctx.apiBase ? `${ctx.apiBase}/v1` : "unconfigured"}`);
204
+ console.log(`Dev port: ${ctx.port}`);
176
205
  if (ctx.assetCommand)
177
206
  console.log(`App command: ${ctx.assetCommand}`);
207
+ if (ctx.assetTarget)
208
+ console.log(`Asset target: ${ctx.assetTarget}`);
178
209
  }
179
210
  console.log("");
180
211
  await new Promise((resolveRun) => {
@@ -186,13 +217,20 @@ async function commandDevProxy(args) {
186
217
  if (code !== 0)
187
218
  process.exitCode = code;
188
219
  server.close();
189
- if (assetChild && !assetChild.killed)
190
- assetChild.kill("SIGTERM");
220
+ assetProcess?.reservation?.release();
221
+ if (assetProcess?.child && !assetProcess.child.killed)
222
+ assetProcess.child.kill("SIGTERM");
191
223
  resolveRun();
192
224
  };
193
225
  process.once("SIGINT", () => shutdown());
194
226
  process.once("SIGTERM", () => shutdown());
195
- assetChild?.once("exit", (code) => {
227
+ assetProcess?.child.once("error", (error) => {
228
+ console.error(`App command failed to start: ${error.message}`);
229
+ shutdown(1);
230
+ });
231
+ assetProcess?.child.once("exit", (code) => {
232
+ if (closed)
233
+ return;
196
234
  if (code && code !== 0)
197
235
  console.error(`App command exited with status ${code}`);
198
236
  shutdown(code || 0);
@@ -220,20 +258,21 @@ async function resolveDevContext(args) {
220
258
  }
221
259
  assertAppName(app);
222
260
  const root = resolveAppRoot(cwd, app, manifestInfo);
223
- const port = stringOption(args, "port") || DEFAULT_DEV_PORT;
224
- const assetPort = String(stringOption(args, "assetPort") || manifest?.dev?.port || DEFAULT_ASSET_DEV_PORT);
225
- const assetCommand = stringOption(args, "command") || manifest?.dev?.command || inferAssetDevCommand(root, assetPort);
226
- const assetTarget = assetCommand ? `http://127.0.0.1:${assetPort}` : undefined;
261
+ const portStart = parsePort(stringOption(args, "port") ?? DEFAULT_DEV_PORT, "--port");
262
+ const assetPortStart = parsePort(stringOption(args, "assetPort") ?? manifest?.dev?.port ?? DEFAULT_ASSET_DEV_PORT, "--asset-port");
263
+ const explicitAssetCommand = stringOption(args, "command") || manifest?.dev?.command;
264
+ const asset = resolveAssetDevConfig(root, explicitAssetCommand, assetPortStart);
227
265
  const config = loadConfig();
228
266
  const apiBase = apiOrigin(config, args);
229
267
  return {
230
268
  app,
231
269
  root,
232
- port,
270
+ port: portStart,
271
+ portStart,
233
272
  apiBase,
234
273
  token: process.env.RAILCODE_API_TOKEN || config.apiToken,
235
- assetCommand,
236
- assetTarget,
274
+ asset,
275
+ assetPortStart: asset.kind === "none" ? undefined : assetPortStart,
237
276
  };
238
277
  }
239
278
  function readRailcodeManifest(start) {
@@ -328,18 +367,51 @@ function resolveSdkPath() {
328
367
  return sourceTree;
329
368
  throw new CliError(`Railcode SDK file not found. Expected ${bundled}`, 1);
330
369
  }
331
- function inferAssetDevCommand(root, port) {
370
+ function resolveAssetDevConfig(root, explicitCommand, targetPort) {
371
+ if (explicitCommand)
372
+ return { kind: "custom", command: explicitCommand, targetPort };
332
373
  const packagePath = join(root, "package.json");
333
374
  if (!existsSync(packagePath))
334
- return undefined;
375
+ return { kind: "none" };
335
376
  const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
336
377
  if (!pkg.scripts?.dev)
337
- return undefined;
378
+ return { kind: "none" };
338
379
  const pm = packageManagerFor(root);
339
- if (existsSync(join(root, "vite.config.ts")) || existsSync(join(root, "vite.config.js"))) {
340
- return runScriptCommand(pm, "dev", `--host 127.0.0.1 --port ${port} --logLevel warn`);
380
+ if (isViteProject(root))
381
+ return { kind: "vite", packageManager: pm, startPort: targetPort };
382
+ return { kind: "script", command: runScriptCommand(pm, "dev"), targetPort };
383
+ }
384
+ function isViteProject(root) {
385
+ return existsSync(join(root, "vite.config.ts")) || existsSync(join(root, "vite.config.js"));
386
+ }
387
+ async function startAssetDevServer(ctx) {
388
+ if (ctx.asset.kind === "none")
389
+ return null;
390
+ let reservation;
391
+ if (ctx.asset.kind === "vite") {
392
+ reservation = await reserveAvailablePort(ctx.asset.startPort, "asset dev server");
393
+ ctx.assetPort = reservation.port;
394
+ ctx.assetCommand = runScriptCommand(ctx.asset.packageManager, "dev", `--host 127.0.0.1 --port ${reservation.port} --strictPort --logLevel warn`);
395
+ }
396
+ else {
397
+ ctx.assetPort = ctx.asset.targetPort;
398
+ ctx.assetCommand = ctx.asset.command;
341
399
  }
342
- return runScriptCommand(pm, "dev");
400
+ ctx.assetTarget = `http://127.0.0.1:${ctx.assetPort}`;
401
+ return {
402
+ child: spawn(ctx.assetCommand, {
403
+ cwd: ctx.root,
404
+ env: {
405
+ ...process.env,
406
+ PORT: String(ctx.assetPort),
407
+ RAILCODE_DEV_PORT: String(ctx.port),
408
+ RAILCODE_ASSET_PORT: String(ctx.assetPort),
409
+ },
410
+ shell: true,
411
+ stdio: "inherit",
412
+ }),
413
+ reservation,
414
+ };
343
415
  }
344
416
  async function ensureAppDependencies(root) {
345
417
  if (!existsSync(join(root, "package.json")))
@@ -355,7 +427,7 @@ function dependenciesReady(root) {
355
427
  const nodeModules = join(root, "node_modules");
356
428
  if (!existsSync(nodeModules) || !statSync(nodeModules).isDirectory())
357
429
  return false;
358
- if (existsSync(join(root, "vite.config.ts")) || existsSync(join(root, "vite.config.js"))) {
430
+ if (isViteProject(root)) {
359
431
  return existsSync(join(nodeModules, ".bin", process.platform === "win32" ? "vite.cmd" : "vite"));
360
432
  }
361
433
  return true;
@@ -380,6 +452,140 @@ function runScriptCommand(pm, script, passthrough = "") {
380
452
  return `yarn ${script}${suffix}`;
381
453
  return `${pm} run ${script}${passthrough ? ` -- ${passthrough}` : ""}`;
382
454
  }
455
+ function parsePort(value, label) {
456
+ const port = typeof value === "number" ? value : Number(value);
457
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
458
+ throw new CliError(`${label} must be a TCP port from 1 to 65535.`, 2);
459
+ }
460
+ return port;
461
+ }
462
+ async function listenOnAvailablePort(server, startPort, label) {
463
+ for (let port = startPort, checked = 0; port <= 65535 && checked < PORT_SEARCH_LIMIT; port += 1, checked += 1) {
464
+ try {
465
+ await listenOnPort(server, port);
466
+ return port;
467
+ }
468
+ catch (error) {
469
+ if (errorCode(error) !== "EADDRINUSE")
470
+ throw error;
471
+ }
472
+ }
473
+ throw new CliError(`${label} could not find an available port starting at ${startPort}.`, 1);
474
+ }
475
+ async function reserveAvailablePort(startPort, label) {
476
+ for (let port = startPort, checked = 0; port <= 65535 && checked < PORT_SEARCH_LIMIT; port += 1, checked += 1) {
477
+ const reservation = reserveRailcodePort(port);
478
+ if (!reservation)
479
+ continue;
480
+ try {
481
+ const available = await canListenOnPort(port);
482
+ if (available)
483
+ return reservation;
484
+ }
485
+ catch (error) {
486
+ reservation.release();
487
+ throw error;
488
+ }
489
+ reservation.release();
490
+ }
491
+ throw new CliError(`${label} could not find an available port starting at ${startPort}.`, 1);
492
+ }
493
+ async function canListenOnPort(port) {
494
+ const server = createServer();
495
+ try {
496
+ await listenOnPort(server, port);
497
+ return true;
498
+ }
499
+ catch (error) {
500
+ if (errorCode(error) === "EADDRINUSE")
501
+ return false;
502
+ throw error;
503
+ }
504
+ finally {
505
+ if (server.listening)
506
+ await closeServer(server);
507
+ }
508
+ }
509
+ async function listenOnPort(server, port) {
510
+ await new Promise((resolveListen, rejectListen) => {
511
+ const cleanup = () => {
512
+ server.off("error", onError);
513
+ server.off("listening", onListening);
514
+ };
515
+ const onError = (error) => {
516
+ cleanup();
517
+ rejectListen(error);
518
+ };
519
+ const onListening = () => {
520
+ cleanup();
521
+ resolveListen();
522
+ };
523
+ server.once("error", onError);
524
+ server.once("listening", onListening);
525
+ server.listen(port, "127.0.0.1");
526
+ });
527
+ }
528
+ function reserveRailcodePort(port) {
529
+ const dir = localPortLockDir(port);
530
+ mkdirSync(dirname(dir), { recursive: true });
531
+ try {
532
+ mkdirSync(dir);
533
+ }
534
+ catch (error) {
535
+ if (errorCode(error) !== "EEXIST")
536
+ throw error;
537
+ if (!removeStalePortLock(dir))
538
+ return null;
539
+ try {
540
+ mkdirSync(dir);
541
+ }
542
+ catch (retryError) {
543
+ if (errorCode(retryError) === "EEXIST")
544
+ return null;
545
+ throw retryError;
546
+ }
547
+ }
548
+ writeJsonFile(join(dir, "owner.json"), { pid: process.pid, started_at: nowIso() });
549
+ let released = false;
550
+ return {
551
+ port,
552
+ release: () => {
553
+ if (released)
554
+ return;
555
+ released = true;
556
+ const owner = readJsonFile(join(dir, "owner.json"), {});
557
+ if (owner.pid === process.pid)
558
+ rmSync(dir, { recursive: true, force: true });
559
+ },
560
+ };
561
+ }
562
+ function removeStalePortLock(dir) {
563
+ const owner = readJsonFile(join(dir, "owner.json"), {});
564
+ if (typeof owner.pid === "number" && processIsRunning(owner.pid))
565
+ return false;
566
+ rmSync(dir, { recursive: true, force: true });
567
+ return true;
568
+ }
569
+ function localPortLockDir(port) {
570
+ return join(RAILCODE_HOME, "dev", ".ports", String(port));
571
+ }
572
+ function processIsRunning(pid) {
573
+ try {
574
+ process.kill(pid, 0);
575
+ return true;
576
+ }
577
+ catch (error) {
578
+ return errorCode(error) === "EPERM";
579
+ }
580
+ }
581
+ function errorCode(error) {
582
+ return typeof error === "object" &&
583
+ error !== null &&
584
+ "code" in error &&
585
+ typeof error.code === "string"
586
+ ? error.code
587
+ : undefined;
588
+ }
383
589
  async function handleDevRequest(ctx, request, response) {
384
590
  const url = new URL(request.url || "/", "http://127.0.0.1");
385
591
  if (url.pathname.startsWith("/_api/")) {
@@ -1004,13 +1210,22 @@ function escapeHtml(value) {
1004
1210
  .replaceAll('"', "&quot;")
1005
1211
  .replaceAll("'", "&#39;");
1006
1212
  }
1213
+ const DEPLOY_HELP = `Usage:
1214
+ railcode deploy [--private | --public]
1215
+
1216
+ Run from a Railcode app repo. The first deploy creates the app's access policy:
1217
+ public (anyone signed in) by default, or owner-only with --private. You can also
1218
+ set it declaratively in railcode.json: { "deploy": { "access": "private" } }.
1219
+ Redeploys keep the existing policy; change it later with railcode access.
1220
+ `;
1007
1221
  async function commandDeploy(args) {
1008
1222
  if (booleanOption(args, "help")) {
1009
- console.log("Usage: railcode deploy\n\nRun from a Railcode app repo.");
1223
+ console.log(DEPLOY_HELP);
1010
1224
  return;
1011
1225
  }
1012
1226
  rejectDeployArgs(args);
1013
1227
  const ctx = resolveDeployContext();
1228
+ const access = resolveDeployAccess(args, ctx);
1014
1229
  await buildApp(ctx.appRoot);
1015
1230
  const source = resolveDeploySource(ctx);
1016
1231
  const files = collectDeployFiles(source);
@@ -1019,7 +1234,7 @@ async function commandDeploy(args) {
1019
1234
  const apiUrl = canonicalApiOrigin(authUrl);
1020
1235
  let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Deploy");
1021
1236
  console.log(`Uploading ${ctx.app} to ${apiUrl}...`);
1022
- const response = await postDeployBundle(apiUrl, authedConfig, ctx.app, files);
1237
+ const response = await postDeployBundle(apiUrl, authedConfig, ctx.app, files, access);
1023
1238
  if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
1024
1239
  console.log("Saved login expired. Log in again to continue.");
1025
1240
  const retryConfig = { ...authedConfig, apiUrl: authUrl };
@@ -1028,19 +1243,46 @@ async function commandDeploy(args) {
1028
1243
  delete retryConfig.cookies;
1029
1244
  saveConfig(retryConfig);
1030
1245
  const freshConfig = await ensureApiToken(retryConfig, authUrl, "Deploy");
1031
- await handleDeployResponse(await postDeployBundle(apiUrl, freshConfig, ctx.app, files), ctx.app, apiUrl);
1246
+ const retry = await postDeployBundle(apiUrl, freshConfig, ctx.app, files, access);
1247
+ await handleDeployResponse(retry, ctx.app, apiUrl, access);
1032
1248
  return;
1033
1249
  }
1034
- await handleDeployResponse(response, ctx.app, apiUrl);
1250
+ await handleDeployResponse(response, ctx.app, apiUrl, access);
1035
1251
  }
1036
1252
  function rejectDeployArgs(args) {
1037
1253
  if (args.rest.length > 0) {
1038
1254
  throw new CliError("railcode deploy does not take an app argument. Run it from the Railcode app directory.", 2);
1039
1255
  }
1040
- const options = Object.keys(args.options);
1041
- if (options.length > 0) {
1042
- throw new CliError("railcode deploy does not take options. Configure the Railcode URL in railcode.json or RAILCODE_API_URL.", 2);
1043
- }
1256
+ const allowed = new Set(["private", "public", "help"]);
1257
+ const unknown = Object.keys(args.options).filter((option) => !allowed.has(option));
1258
+ if (unknown.length > 0) {
1259
+ throw new CliError(`Unknown option for deploy: --${unknown[0]}. Configure the Railcode URL in railcode.json or RAILCODE_API_URL.`, 2);
1260
+ }
1261
+ }
1262
+ // First-deploy access: --private/--public win over railcode.json deploy.access.
1263
+ // Returns the canonical policy mode, or undefined to let the server default.
1264
+ function resolveDeployAccess(args, ctx) {
1265
+ const wantsPrivate = booleanOption(args, "private");
1266
+ const wantsPublic = booleanOption(args, "public");
1267
+ if (wantsPrivate && wantsPublic) {
1268
+ throw new CliError("Use either --private or --public, not both.", 2);
1269
+ }
1270
+ if (wantsPrivate)
1271
+ return "private";
1272
+ if (wantsPublic)
1273
+ return "workspace";
1274
+ return ctx.access ? normalizeDeployAccess(ctx.access) : undefined;
1275
+ }
1276
+ function normalizeDeployAccess(value) {
1277
+ const mode = value.trim().toLowerCase();
1278
+ if (mode === "public" || mode === "workspace")
1279
+ return "workspace";
1280
+ if (mode === "private")
1281
+ return "private";
1282
+ if (mode === "restricted") {
1283
+ throw new CliError("deploy.access 'restricted' is not supported at deploy. Deploy, then run railcode access restricted --users <...>.", 2);
1284
+ }
1285
+ throw new CliError(`Invalid deploy.access '${value}'. Use 'public' or 'private'.`, 2);
1044
1286
  }
1045
1287
  function resolveDeployContext() {
1046
1288
  const cwd = process.cwd();
@@ -1058,6 +1300,7 @@ function resolveDeployContext() {
1058
1300
  appRoot,
1059
1301
  workspaceRoot,
1060
1302
  apiUrl: manifest?.deploy?.apiUrl,
1303
+ access: manifest?.deploy?.access,
1061
1304
  };
1062
1305
  }
1063
1306
  async function buildApp(appRoot) {
@@ -1149,7 +1392,7 @@ function collectDeployFiles(root) {
1149
1392
  }
1150
1393
  return files;
1151
1394
  }
1152
- async function postDeployBundle(apiUrl, config, app, files) {
1395
+ async function postDeployBundle(apiUrl, config, app, files, access) {
1153
1396
  try {
1154
1397
  return await fetch(`${apiUrl}/v1/apps/${app}/deploy`, {
1155
1398
  method: "POST",
@@ -1157,7 +1400,7 @@ async function postDeployBundle(apiUrl, config, app, files) {
1157
1400
  Authorization: `Bearer ${config.apiToken}`,
1158
1401
  "Content-Type": "application/json",
1159
1402
  },
1160
- body: JSON.stringify({ files }),
1403
+ body: JSON.stringify(access ? { files, access } : { files }),
1161
1404
  redirect: "manual",
1162
1405
  });
1163
1406
  }
@@ -1165,14 +1408,19 @@ async function postDeployBundle(apiUrl, config, app, files) {
1165
1408
  throw new CliError(`Could not reach ${apiUrl}. Configure the Railcode URL in railcode.json deploy.apiUrl or RAILCODE_API_URL. ${error instanceof Error ? error.message : String(error)}`, 1);
1166
1409
  }
1167
1410
  }
1168
- async function handleDeployResponse(response, app, apiUrl) {
1411
+ async function handleDeployResponse(response, app, apiUrl, requestedAccess) {
1169
1412
  if (response.ok) {
1170
1413
  const body = await response.json();
1171
1414
  console.log(`Deployed ${app}${body.files ? ` (${body.files} files)` : ""}`);
1172
1415
  console.log(body.url || appUrlFromOrigin(app, apiUrl));
1173
1416
  const accessLabel = deployAccessLabel(body.access);
1174
- if (accessLabel)
1417
+ if (accessLabel) {
1175
1418
  console.log(`Access: ${accessLabel}`);
1419
+ }
1420
+ else if (requestedAccess && body.access === "existing_policy") {
1421
+ const word = requestedAccess === "private" ? "private" : "public";
1422
+ console.log(`Access unchanged (${app} already exists). Run 'railcode access ${word}' to change it.`);
1423
+ }
1176
1424
  return;
1177
1425
  }
1178
1426
  const text = await response.text();
@@ -1187,8 +1435,156 @@ async function handleDeployResponse(response, app, apiUrl) {
1187
1435
  function deployAccessLabel(access) {
1188
1436
  if (access === "workspace_created")
1189
1437
  return "public to signed-in users";
1438
+ if (access === "private_created")
1439
+ return "private (owner only)";
1190
1440
  return undefined;
1191
1441
  }
1442
+ const ACCESS_HELP = `Usage:
1443
+ railcode access Show the app's current access
1444
+ railcode access public Anyone signed in
1445
+ railcode access private Just you (the owner)
1446
+ railcode access restricted --users a@b.com,c@d.com Named users only
1447
+
1448
+ Run from a Railcode app repo. Deploy the app at least once before changing access.
1449
+ Only the app owner or an admin can change it. 'public' is an alias for 'workspace'.
1450
+ `;
1451
+ // Friendly CLI words mapped to the server's canonical policy modes.
1452
+ const ACCESS_MODES = {
1453
+ public: "workspace",
1454
+ workspace: "workspace",
1455
+ private: "private",
1456
+ restricted: "restricted",
1457
+ };
1458
+ async function commandAccess(args) {
1459
+ if (booleanOption(args, "help") || args.rest[0] === "help") {
1460
+ console.log(ACCESS_HELP);
1461
+ return;
1462
+ }
1463
+ const { method, payload } = parseAccessArgs(args);
1464
+ const ctx = resolveDeployContext();
1465
+ const config = loadConfig();
1466
+ const authUrl = await resolveDeployAuthOrigin(config, ctx);
1467
+ const apiUrl = canonicalApiOrigin(authUrl);
1468
+ let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Access");
1469
+ let response = await sendAccessRequest(apiUrl, authedConfig, ctx.app, method, payload);
1470
+ if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
1471
+ console.log("Saved login expired. Log in again to continue.");
1472
+ const retryConfig = { ...authedConfig, apiUrl: authUrl };
1473
+ delete retryConfig.apiToken;
1474
+ delete retryConfig.apiTokenPrefix;
1475
+ delete retryConfig.cookies;
1476
+ saveConfig(retryConfig);
1477
+ authedConfig = await ensureApiToken(retryConfig, authUrl, "Access");
1478
+ response = await sendAccessRequest(apiUrl, authedConfig, ctx.app, method, payload);
1479
+ }
1480
+ await handleAccessResponse(response, ctx.app, method);
1481
+ }
1482
+ function parseAccessArgs(args) {
1483
+ const requested = args.rest[0];
1484
+ if (!requested) {
1485
+ rejectAccessOptions(args, false);
1486
+ return { method: "GET" };
1487
+ }
1488
+ if (args.rest.length > 1) {
1489
+ throw new CliError("railcode access takes a single mode argument.", 2);
1490
+ }
1491
+ const mode = ACCESS_MODES[requested.toLowerCase()];
1492
+ if (!mode) {
1493
+ throw new CliError(`Unknown access mode '${requested}'. Use public, private, or restricted.`, 2);
1494
+ }
1495
+ rejectAccessOptions(args, mode === "restricted");
1496
+ const payload = { mode };
1497
+ if (mode === "restricted") {
1498
+ payload.members = parseMembers(args);
1499
+ if (payload.members.length === 0) {
1500
+ throw new CliError("restricted access needs --users <a@b.com,c@d.com> of existing Railcode users.", 2);
1501
+ }
1502
+ }
1503
+ return { method: "PUT", payload };
1504
+ }
1505
+ function rejectAccessOptions(args, allowUsers) {
1506
+ const allowed = new Set(["help"]);
1507
+ if (allowUsers)
1508
+ allowed.add("users");
1509
+ const unknown = Object.keys(args.options).filter((option) => !allowed.has(option));
1510
+ if (unknown.length > 0) {
1511
+ const hint = unknown[0] === "users" ? " --users only applies to restricted access." : "";
1512
+ throw new CliError(`Unknown option for access: --${unknown[0]}.${hint}`, 2);
1513
+ }
1514
+ }
1515
+ function parseMembers(args) {
1516
+ const raw = stringOption(args, "users");
1517
+ if (!raw)
1518
+ return [];
1519
+ return raw
1520
+ .split(",")
1521
+ .map((member) => member.trim())
1522
+ .filter(Boolean);
1523
+ }
1524
+ async function sendAccessRequest(apiUrl, config, app, method, payload) {
1525
+ if (!config.apiToken) {
1526
+ throw new CliError("Missing Railcode API token. Run from a terminal once, or set RAILCODE_API_TOKEN.", 1);
1527
+ }
1528
+ const headers = { Authorization: `Bearer ${config.apiToken}` };
1529
+ if (method === "PUT")
1530
+ headers["Content-Type"] = "application/json";
1531
+ try {
1532
+ return await fetch(`${apiUrl}/v1/apps/${app}/access`, {
1533
+ method,
1534
+ headers,
1535
+ body: method === "PUT" ? JSON.stringify(payload) : undefined,
1536
+ redirect: "manual",
1537
+ });
1538
+ }
1539
+ catch (error) {
1540
+ throw new CliError(`Could not reach ${apiUrl}. Configure the Railcode URL in railcode.json deploy.apiUrl or RAILCODE_API_URL. ${error instanceof Error ? error.message : String(error)}`, 1);
1541
+ }
1542
+ }
1543
+ async function handleAccessResponse(response, app, method) {
1544
+ if (response.ok) {
1545
+ printAccess((await response.json()), method === "PUT");
1546
+ return;
1547
+ }
1548
+ const detail = errorDetail(await response.text());
1549
+ if (response.status === 401) {
1550
+ throw new CliError("Access login was rejected. Run the command again to log in.", 1);
1551
+ }
1552
+ if (response.status === 403) {
1553
+ throw new CliError(`Access change denied for ${app}: ${detail}. Only the app owner or an admin can change access.`, 1);
1554
+ }
1555
+ if (response.status === 404 || response.status === 409) {
1556
+ throw new CliError(detail || `App ${app} not found.`, 1);
1557
+ }
1558
+ throw new CliError(`Access ${method === "PUT" ? "update" : "lookup"} failed (${response.status}): ${detail}`, 1);
1559
+ }
1560
+ function printAccess(body, changed) {
1561
+ console.log(`${changed ? "Access set:" : "Access:"} ${accessModeLabel(body.mode)}`);
1562
+ if (body.mode === "restricted" && body.members && body.members.length > 0) {
1563
+ console.log(`Allowed: ${body.members.join(", ")}`);
1564
+ }
1565
+ if (body.url)
1566
+ console.log(body.url);
1567
+ }
1568
+ function accessModeLabel(mode) {
1569
+ if (mode === "workspace")
1570
+ return "public (anyone signed in)";
1571
+ if (mode === "private")
1572
+ return "private (owner only)";
1573
+ if (mode === "restricted")
1574
+ return "restricted (named users only)";
1575
+ return mode || "unknown";
1576
+ }
1577
+ function errorDetail(text) {
1578
+ try {
1579
+ const parsed = JSON.parse(text);
1580
+ if (typeof parsed.detail === "string")
1581
+ return parsed.detail;
1582
+ }
1583
+ catch {
1584
+ // Non-JSON error body; fall through to the raw text.
1585
+ }
1586
+ return text;
1587
+ }
1192
1588
  function appUrlFromOrigin(app, origin) {
1193
1589
  const url = new URL(origin);
1194
1590
  const [first, ...rest] = url.hostname.split(".");
@@ -1203,18 +1599,296 @@ function appUrlFromOrigin(app, origin) {
1203
1599
  url.hash = "";
1204
1600
  return url.toString();
1205
1601
  }
1602
+ const DB_ENGINES = new Set(["postgres", "mysql"]);
1603
+ const DB_HELP = `Query Railcode data connectors.
1604
+
1605
+ Usage:
1606
+ railcode db list
1607
+ railcode db query "<sql>" [options]
1608
+
1609
+ Commands:
1610
+ list List configured data connectors (name + engine).
1611
+ query "<sql>" Run a read-only SQL query against a connector.
1612
+
1613
+ Query options:
1614
+ --connection <name> Connector to query (default: default).
1615
+ --engine <postgres|mysql> Override the engine. Inferred from the connector by default.
1616
+ --params '<json-array>' Params for $1, $2, ... placeholders, e.g. --params '[42,"ok"]'.
1617
+ --file <path> Read SQL from a file instead of an argument.
1618
+ --json Print raw JSON instead of a table.
1619
+
1620
+ Options:
1621
+ --api-url <url> Railcode auth/API URL. Defaults to saved config or railcode.json.
1622
+
1623
+ Examples:
1624
+ railcode db list
1625
+ railcode db query "select id, email from users limit 5"
1626
+ railcode db query "select * from orders where total > $1" --params '[100]'
1627
+ railcode db query "select 1" --connection analytics --engine mysql
1628
+ `;
1629
+ async function commandDb(args) {
1630
+ const subcommand = args.rest[0];
1631
+ const rest = { ...args, rest: args.rest.slice(1) };
1632
+ switch (subcommand) {
1633
+ case "list":
1634
+ case "ls":
1635
+ case "connections":
1636
+ await commandDbList(rest);
1637
+ return;
1638
+ case "query":
1639
+ case "sql":
1640
+ await commandDbQuery(rest);
1641
+ return;
1642
+ case undefined:
1643
+ case "help":
1644
+ console.log(DB_HELP);
1645
+ return;
1646
+ default:
1647
+ if (booleanOption(args, "help")) {
1648
+ console.log(DB_HELP);
1649
+ return;
1650
+ }
1651
+ throw new CliError(`Unknown db command: ${subcommand}\n\n${DB_HELP}`, 2);
1652
+ }
1653
+ }
1654
+ async function commandDbList(args) {
1655
+ if (booleanOption(args, "help") || args.rest[0] === "help") {
1656
+ console.log(DB_HELP);
1657
+ return;
1658
+ }
1659
+ rejectUnknownOptions(args, ["help", "json", "apiUrl"], "db list");
1660
+ if (args.rest.length > 0) {
1661
+ throw new CliError("railcode db list does not accept positional arguments.", 2);
1662
+ }
1663
+ const response = await dbAuthedRequest(args, "Data connectors list", fetchConnections);
1664
+ const connectors = (await readDbResponse(response, "Data connectors list"));
1665
+ printConnectors(connectors, booleanOption(args, "json"));
1666
+ }
1667
+ async function commandDbQuery(args) {
1668
+ if (booleanOption(args, "help") || args.rest[0] === "help") {
1669
+ console.log(DB_HELP);
1670
+ return;
1671
+ }
1672
+ rejectUnknownOptions(args, ["help", "json", "apiUrl", "connection", "engine", "params", "file"], "db query");
1673
+ const query = resolveQuerySql(args);
1674
+ const connection = (stringOption(args, "connection") || "default").trim() || "default";
1675
+ const params = parseSqlParams(args);
1676
+ const engine = parseEngineOption(args) ?? (await resolveConnectionEngine(args, connection));
1677
+ const body = { engine, connection, query, params };
1678
+ const response = await dbAuthedRequest(args, "SQL query", (apiUrl, config) => postSql(apiUrl, config, body));
1679
+ const result = (await readDbResponse(response, "SQL query", "Run `railcode db list` to see configured connectors."));
1680
+ printSqlResult(result, booleanOption(args, "json"));
1681
+ }
1682
+ function resolveQuerySql(args) {
1683
+ const file = stringOption(args, "file");
1684
+ if (file && args.rest.length > 0) {
1685
+ throw new CliError("Pass SQL as an argument or --file, not both.", 2);
1686
+ }
1687
+ if (file) {
1688
+ const path = resolve(file);
1689
+ if (!existsSync(path))
1690
+ throw new CliError(`SQL file not found: ${path}`, 1);
1691
+ const sql = readFileSync(path, "utf8").trim();
1692
+ if (!sql)
1693
+ throw new CliError(`SQL file is empty: ${path}`, 1);
1694
+ return sql;
1695
+ }
1696
+ if (args.rest.length > 1) {
1697
+ throw new CliError('railcode db query takes a single SQL string. Quote it: railcode db query "select 1".', 2);
1698
+ }
1699
+ const positional = args.rest[0];
1700
+ if (!positional || !positional.trim()) {
1701
+ throw new CliError('Missing SQL. Usage: railcode db query "select 1" [--connection <name>].', 2);
1702
+ }
1703
+ return positional.trim();
1704
+ }
1705
+ function parseSqlParams(args) {
1706
+ const raw = stringOption(args, "params");
1707
+ if (raw === undefined)
1708
+ return [];
1709
+ let parsed;
1710
+ try {
1711
+ parsed = JSON.parse(raw);
1712
+ }
1713
+ catch {
1714
+ throw new CliError('--params must be a JSON array, e.g. --params \'[42, "ok"]\'.', 2);
1715
+ }
1716
+ if (!Array.isArray(parsed)) {
1717
+ throw new CliError('--params must be a JSON array, e.g. --params \'[42, "ok"]\'.', 2);
1718
+ }
1719
+ return parsed;
1720
+ }
1721
+ function parseEngineOption(args) {
1722
+ const raw = stringOption(args, "engine");
1723
+ if (raw === undefined)
1724
+ return undefined;
1725
+ const engine = raw.trim().toLowerCase();
1726
+ if (!DB_ENGINES.has(engine)) {
1727
+ throw new CliError(`Unknown engine '${raw}'. Use postgres or mysql.`, 2);
1728
+ }
1729
+ return engine;
1730
+ }
1731
+ // The server requires an engine and rejects a mismatch, so when the caller omits
1732
+ // --engine we look the connection up in the registry and use its engine.
1733
+ async function resolveConnectionEngine(args, connection) {
1734
+ const response = await dbAuthedRequest(args, "Data connectors list", fetchConnections);
1735
+ const connectors = (await readDbResponse(response, "Data connectors list"));
1736
+ const match = connectors.find((c) => c.name === connection);
1737
+ if (!match) {
1738
+ const names = connectors.map((c) => c.name).join(", ") || "none";
1739
+ throw new CliError(`No data connector named '${connection}'. Configured: ${names}. Pass --engine to override.`, 1);
1740
+ }
1741
+ return match.engine;
1742
+ }
1743
+ async function dbAuthedRequest(args, action, send) {
1744
+ const config = loadConfig();
1745
+ const authUrl = await resolveDbAuthOrigin(config, args);
1746
+ const apiUrl = canonicalApiOrigin(authUrl);
1747
+ let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, action);
1748
+ let response = await send(apiUrl, authedConfig);
1749
+ if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
1750
+ console.log("Saved login expired. Log in again to continue.");
1751
+ const retryConfig = { ...authedConfig, apiUrl: authUrl };
1752
+ delete retryConfig.apiToken;
1753
+ delete retryConfig.apiTokenPrefix;
1754
+ delete retryConfig.cookies;
1755
+ saveConfig(retryConfig);
1756
+ authedConfig = await ensureApiToken(retryConfig, authUrl, action);
1757
+ response = await send(apiUrl, authedConfig);
1758
+ }
1759
+ return response;
1760
+ }
1761
+ async function resolveDbAuthOrigin(config, args) {
1762
+ const manifestApiUrl = readRailcodeManifest(process.cwd())?.manifest.deploy?.apiUrl;
1763
+ return resolveRequiredAuthOrigin({
1764
+ config,
1765
+ args,
1766
+ action: "Data connectors",
1767
+ fallbackApiUrl: manifestApiUrl,
1768
+ missingUrlHint: "Pass --api-url <url>, set deploy.apiUrl in railcode.json, or set RAILCODE_API_URL.",
1769
+ });
1770
+ }
1771
+ function fetchConnections(apiUrl, config) {
1772
+ return dbFetch(apiUrl, config, "/v1/connections", { redirect: "manual" });
1773
+ }
1774
+ function postSql(apiUrl, config, body) {
1775
+ return dbFetch(apiUrl, config, "/v1/sql", {
1776
+ method: "POST",
1777
+ headers: { "Content-Type": "application/json" },
1778
+ body: JSON.stringify(body),
1779
+ redirect: "manual",
1780
+ });
1781
+ }
1782
+ async function dbFetch(apiUrl, config, path, init) {
1783
+ if (!config.apiToken) {
1784
+ throw new CliError("Missing Railcode API token. Run from a terminal once, or set RAILCODE_API_TOKEN.", 1);
1785
+ }
1786
+ const headers = {
1787
+ ...(init.headers ?? {}),
1788
+ Authorization: `Bearer ${config.apiToken}`,
1789
+ };
1790
+ try {
1791
+ return await fetch(`${apiUrl}${path}`, { ...init, headers });
1792
+ }
1793
+ catch (error) {
1794
+ throw new CliError(`Could not reach ${apiUrl}. Configure the Railcode URL in railcode.json deploy.apiUrl, RAILCODE_API_URL, or --api-url. ${error instanceof Error ? error.message : String(error)}`, 1);
1795
+ }
1796
+ }
1797
+ async function readDbResponse(response, action, hint) {
1798
+ if (response.ok)
1799
+ return response.json();
1800
+ const detail = errorDetail(await response.text());
1801
+ if (response.status === 401) {
1802
+ throw new CliError(`${action} login was rejected. Run the command again to log in.`, 1);
1803
+ }
1804
+ if (response.status === 403) {
1805
+ throw new CliError(`${action} denied: ${detail}`, 1);
1806
+ }
1807
+ if (response.status === 502) {
1808
+ throw new CliError(detail || "Database unreachable.", 1);
1809
+ }
1810
+ if (response.status === 400 || response.status === 404) {
1811
+ throw new CliError([detail, hint].filter(Boolean).join(" ") || `${action} failed.`, 1);
1812
+ }
1813
+ throw new CliError(`${action} failed (${response.status}): ${detail}`, 1);
1814
+ }
1815
+ function printConnectors(connectors, asJson) {
1816
+ if (asJson) {
1817
+ console.log(JSON.stringify(connectors, null, 2));
1818
+ return;
1819
+ }
1820
+ if (!connectors || connectors.length === 0) {
1821
+ console.log("No data connectors configured.");
1822
+ return;
1823
+ }
1824
+ console.log(formatTable(["NAME", "ENGINE"], connectors.map((c) => [c.name, c.engine])));
1825
+ }
1826
+ function printSqlResult(result, asJson) {
1827
+ if (asJson) {
1828
+ console.log(JSON.stringify(result, null, 2));
1829
+ return;
1830
+ }
1831
+ const columns = result.columns ?? [];
1832
+ const rows = result.rows ?? [];
1833
+ if (columns.length > 0) {
1834
+ console.log(formatTable(columns, rows));
1835
+ }
1836
+ const count = typeof result.rowcount === "number" ? result.rowcount : rows.length;
1837
+ console.log(`${columns.length > 0 ? "\n" : ""}${count} ${count === 1 ? "row" : "rows"}${result.truncated ? " (truncated to first 1000)" : ""}`);
1838
+ }
1839
+ function rejectUnknownOptions(args, allowed, label) {
1840
+ const set = new Set(allowed);
1841
+ const unknown = Object.keys(args.options).filter((option) => !set.has(option));
1842
+ if (unknown.length > 0) {
1843
+ throw new CliError(`Unknown option for ${label}: --${unknown[0]}`, 2);
1844
+ }
1845
+ }
1846
+ function formatTable(columns, rows) {
1847
+ const header = columns.map((c) => c ?? "");
1848
+ const body = rows.map((row) => header.map((_, i) => formatCell(row?.[i])));
1849
+ const widths = header.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length), 0));
1850
+ const renderRow = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").replace(/\s+$/, "");
1851
+ const lines = [renderRow(header), renderRow(widths.map((w) => "-".repeat(w)))];
1852
+ for (const row of body)
1853
+ lines.push(renderRow(row));
1854
+ return lines.join("\n");
1855
+ }
1856
+ function formatCell(value) {
1857
+ if (value === null || value === undefined)
1858
+ return "";
1859
+ const text = typeof value === "string"
1860
+ ? value
1861
+ : typeof value === "object"
1862
+ ? JSON.stringify(value)
1863
+ : String(value);
1864
+ return text.replace(/[\r\n\t]+/g, " ");
1865
+ }
1206
1866
  const DESIGN_SYSTEM_HELP = `Usage:
1207
- railcode design-system pull [output.md] [--output output.md]
1208
- railcode pull design-system [output.md] [--output output.md]
1867
+ railcode get design-system
1209
1868
 
1210
1869
  Options:
1211
1870
  --api-url <url> Railcode auth/API URL. Defaults to saved config, or prompts if missing.
1212
- --output <path> Write markdown to a file instead of stdout.
1871
+
1872
+ Compatibility aliases:
1873
+ railcode design-system pull
1874
+ railcode pull design-system
1213
1875
  `;
1876
+ async function commandGet(args) {
1877
+ const target = args.rest[0];
1878
+ if (target === "design-system") {
1879
+ await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
1880
+ return;
1881
+ }
1882
+ if (!target || target === "help" || booleanOption(args, "help")) {
1883
+ console.log(DESIGN_SYSTEM_HELP);
1884
+ return;
1885
+ }
1886
+ throw new CliError(`Unknown get target: ${target}\n\n${DESIGN_SYSTEM_HELP}`, 2);
1887
+ }
1214
1888
  async function commandPull(args) {
1215
1889
  const target = args.rest[0];
1216
1890
  if (target === "design-system") {
1217
- await commandDesignSystemPull({ ...args, rest: args.rest.slice(1) });
1891
+ await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
1218
1892
  return;
1219
1893
  }
1220
1894
  if (!target || target === "help" || booleanOption(args, "help")) {
@@ -1226,7 +1900,11 @@ async function commandPull(args) {
1226
1900
  async function commandDesignSystem(args) {
1227
1901
  const subcommand = args.rest[0];
1228
1902
  if (subcommand === "pull") {
1229
- await commandDesignSystemPull({ ...args, rest: args.rest.slice(1) });
1903
+ await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
1904
+ return;
1905
+ }
1906
+ if (subcommand === "get") {
1907
+ await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
1230
1908
  return;
1231
1909
  }
1232
1910
  if (!subcommand || subcommand === "help" || booleanOption(args, "help")) {
@@ -1235,16 +1913,16 @@ async function commandDesignSystem(args) {
1235
1913
  }
1236
1914
  throw new CliError(`Unknown design-system command: ${subcommand}\n\n${DESIGN_SYSTEM_HELP}`, 2);
1237
1915
  }
1238
- async function commandDesignSystemPull(args) {
1916
+ async function commandDesignSystemGet(args) {
1239
1917
  if (args.rest[0] === "help" || booleanOption(args, "help")) {
1240
1918
  console.log(DESIGN_SYSTEM_HELP);
1241
1919
  return;
1242
1920
  }
1243
- rejectDesignSystemPullArgs(args);
1921
+ rejectDesignSystemGetArgs(args);
1244
1922
  const config = loadConfig();
1245
1923
  const authUrl = await resolveDesignSystemAuthOrigin(config, args);
1246
1924
  const apiUrl = canonicalApiOrigin(authUrl);
1247
- let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Design system pull");
1925
+ let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Design system get");
1248
1926
  let response = await getDesignSystem(apiUrl, authedConfig);
1249
1927
  if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
1250
1928
  console.log("Saved login expired. Log in again to continue.");
@@ -1253,39 +1931,27 @@ async function commandDesignSystemPull(args) {
1253
1931
  delete retryConfig.apiTokenPrefix;
1254
1932
  delete retryConfig.cookies;
1255
1933
  saveConfig(retryConfig);
1256
- authedConfig = await ensureApiToken(retryConfig, authUrl, "Design system pull");
1934
+ authedConfig = await ensureApiToken(retryConfig, authUrl, "Design system get");
1257
1935
  response = await getDesignSystem(apiUrl, authedConfig);
1258
1936
  }
1259
- const designSystem = await handleDesignSystemResponse(response);
1260
- writeDesignSystemMarkdown(designSystem.markdown, designSystemOutput(args));
1937
+ writeDesignSystemMarkdown(await handleDesignSystemResponse(response));
1261
1938
  }
1262
- function rejectDesignSystemPullArgs(args) {
1263
- const allowedOptions = new Set(["apiUrl", "help", "output"]);
1939
+ function rejectDesignSystemGetArgs(args) {
1940
+ const allowedOptions = new Set(["apiUrl", "help"]);
1264
1941
  const unknown = Object.keys(args.options).filter((option) => !allowedOptions.has(option));
1265
1942
  if (unknown.length > 0) {
1266
- throw new CliError(`Unknown option for design-system pull: --${unknown[0]}`, 2);
1943
+ throw new CliError(`Unknown option for design-system get: --${unknown[0]}`, 2);
1267
1944
  }
1268
- if (args.rest.length > 1) {
1269
- throw new CliError("design-system pull accepts at most one output path.", 2);
1270
- }
1271
- if (args.rest.length === 1 && typeof args.options.output === "string") {
1272
- throw new CliError("Pass the output path either positionally or with --output, not both.", 2);
1273
- }
1274
- if (args.options.output === true) {
1275
- throw new CliError("--output requires a file path.", 2);
1945
+ if (args.rest.length > 0) {
1946
+ throw new CliError("railcode get design-system does not accept positional arguments.", 2);
1276
1947
  }
1277
1948
  }
1278
- function designSystemOutput(args) {
1279
- if (typeof args.options.output === "string")
1280
- return args.options.output;
1281
- return args.rest[0];
1282
- }
1283
1949
  async function resolveDesignSystemAuthOrigin(config, args) {
1284
1950
  const manifestApiUrl = readRailcodeManifest(process.cwd())?.manifest.deploy?.apiUrl;
1285
1951
  return resolveRequiredAuthOrigin({
1286
1952
  config,
1287
1953
  args,
1288
- action: "Design system pull",
1954
+ action: "Design system get",
1289
1955
  fallbackApiUrl: manifestApiUrl,
1290
1956
  missingUrlHint: "Pass --api-url <url>, set deploy.apiUrl in railcode.json, or set RAILCODE_API_URL.",
1291
1957
  });
@@ -1306,35 +1972,30 @@ async function getDesignSystem(apiUrl, config) {
1306
1972
  }
1307
1973
  async function handleDesignSystemResponse(response) {
1308
1974
  if (response.ok) {
1309
- const body = (await response.json());
1310
- if (typeof body.markdown !== "string") {
1311
- throw new CliError("Design system response did not include markdown.", 1);
1975
+ const text = await response.text();
1976
+ const contentType = response.headers.get("content-type") || "";
1977
+ if (contentType.includes("application/json")) {
1978
+ const body = JSON.parse(text);
1979
+ if (typeof body.markdown !== "string") {
1980
+ throw new CliError("Design system response did not include markdown.", 1);
1981
+ }
1982
+ return body.markdown;
1312
1983
  }
1313
- return {
1314
- markdown: body.markdown,
1315
- updated_at: body.updated_at ?? null,
1316
- };
1984
+ return text;
1317
1985
  }
1318
1986
  const text = await response.text();
1319
1987
  if (response.status === 401) {
1320
- throw new CliError("Design system pull login was rejected. Run the command again to log in.", 1);
1988
+ throw new CliError("Design system get login was rejected. Run the command again to log in.", 1);
1321
1989
  }
1322
1990
  if (response.status === 403) {
1323
- throw new CliError(`Design system pull denied: ${text}`, 1);
1991
+ throw new CliError(`Design system get denied: ${text}`, 1);
1324
1992
  }
1325
- throw new CliError(`Design system pull failed (${response.status}): ${text}`, 1);
1993
+ throw new CliError(`Design system get failed (${response.status}): ${text}`, 1);
1326
1994
  }
1327
- function writeDesignSystemMarkdown(markdown, output) {
1328
- if (!output || output === "-") {
1329
- process.stdout.write(markdown);
1330
- if (markdown && !markdown.endsWith("\n"))
1331
- process.stdout.write("\n");
1332
- return;
1333
- }
1334
- const path = resolve(output);
1335
- mkdirSync(dirname(path), { recursive: true });
1336
- writeFileSync(path, markdown);
1337
- console.log(`Wrote ${relativeLabel(process.cwd(), path)}`);
1995
+ function writeDesignSystemMarkdown(markdown) {
1996
+ process.stdout.write(markdown);
1997
+ if (markdown && !markdown.endsWith("\n"))
1998
+ process.stdout.write("\n");
1338
1999
  }
1339
2000
  async function commandInit(args) {
1340
2001
  const app = args.rest[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "railcode",
3
- "version": "0.1.2",
3
+ "version": "0.1.6",
4
4
  "description": "CLI for building, testing, and deploying Railcode apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "devDependencies": {
17
17
  "@tailwindcss/vite": "4.3.1",
18
+ "@types/node": "26.0.0",
18
19
  "@types/react": "19.2.17",
19
20
  "@types/react-dom": "19.2.3",
20
21
  "@vitejs/plugin-react": "6.0.2",
@@ -779,6 +780,16 @@
779
780
  "tslib": "^2.4.0"
780
781
  }
781
782
  },
783
+ "node_modules/@types/node": {
784
+ "version": "26.0.0",
785
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
786
+ "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
787
+ "dev": true,
788
+ "license": "MIT",
789
+ "dependencies": {
790
+ "undici-types": "~8.3.0"
791
+ }
792
+ },
782
793
  "node_modules/@types/react": {
783
794
  "version": "19.2.17",
784
795
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
@@ -1397,6 +1408,13 @@
1397
1408
  "node": ">=14.17"
1398
1409
  }
1399
1410
  },
1411
+ "node_modules/undici-types": {
1412
+ "version": "8.3.0",
1413
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
1414
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
1415
+ "dev": true,
1416
+ "license": "MIT"
1417
+ },
1400
1418
  "node_modules/vite": {
1401
1419
  "version": "8.0.16",
1402
1420
  "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "devDependencies": {
18
18
  "@tailwindcss/vite": "4.3.1",
19
+ "@types/node": "26.0.0",
19
20
  "@types/react": "19.2.17",
20
21
  "@types/react-dom": "19.2.3",
21
22
  "@vitejs/plugin-react": "6.0.2",
package/static/sdk.js CHANGED
@@ -372,7 +372,16 @@
372
372
  var databaseConnectors = dataConnectors;
373
373
 
374
374
  // src/design-system.ts
375
- var designSystem = () => track("design-system", "designSystem()", () => call("GET", "/config/design-system"));
375
+ var designSystem = () => track("design-system", "designSystem()", async () => {
376
+ const response = await call("GET", "/config/design-system");
377
+ if (typeof response === "string") return response;
378
+ if (response && typeof response === "object") {
379
+ const body = response;
380
+ if (typeof body.markdown === "string") return body.markdown;
381
+ if (typeof body.text === "function") return body.text();
382
+ }
383
+ throw new Error("Design system response did not include markdown.");
384
+ });
376
385
 
377
386
  // src/files.ts
378
387
  var files = {