railcode 0.1.2 → 0.1.5

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,22 @@ 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.
73
75
 
74
76
  `railcode deploy` runs from the current Railcode app directory, builds the app,
75
77
  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,9 @@ 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 get design-system
28
30
  railcode init <app>
29
31
  railcode upgrade
30
32
  railcode skill stamp [path]
@@ -54,6 +56,12 @@ async function main() {
54
56
  case "deploy":
55
57
  await commandDeploy(args);
56
58
  return;
59
+ case "access":
60
+ await commandAccess(args);
61
+ return;
62
+ case "get":
63
+ await commandGet(args);
64
+ return;
57
65
  case "design-system":
58
66
  await commandDesignSystem(args);
59
67
  return;
@@ -110,6 +118,14 @@ function parseArgs(argv) {
110
118
  }
111
119
  return { command, rest, options };
112
120
  }
121
+ function readCliPackageVersion() {
122
+ const packagePath = join(CLI_PACKAGE_ROOT, "package.json");
123
+ const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
124
+ if (typeof pkg.version !== "string" || !pkg.version) {
125
+ throw new Error(`Missing CLI package version in ${packagePath}`);
126
+ }
127
+ return pkg.version;
128
+ }
113
129
  function toCamel(input) {
114
130
  return input.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
115
131
  }
@@ -125,17 +141,9 @@ async function commandDevProxy(args) {
125
141
  rmSync(localDevDir(ctx.app), { recursive: true, force: true });
126
142
  }
127
143
  mkdirSync(localDevDir(ctx.app), { recursive: true });
128
- if (ctx.assetCommand) {
144
+ if (ctx.asset.kind !== "none") {
129
145
  await ensureAppDependencies(ctx.root);
130
146
  }
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
147
  const server = createServer((request, response) => {
140
148
  handleDevRequest(ctx, request, response).catch((error) => {
141
149
  const message = error instanceof Error ? error.message : String(error);
@@ -148,11 +156,26 @@ async function commandDevProxy(args) {
148
156
  server.on("upgrade", (request, socket, head) => {
149
157
  proxyWebSocket(ctx, request, socket, head);
150
158
  });
151
- await new Promise((resolveListen, reject) => {
152
- server.once("error", reject);
153
- server.listen(Number(ctx.port), "127.0.0.1", resolveListen);
154
- });
159
+ let assetProcess = null;
160
+ try {
161
+ ctx.port = await listenOnAvailablePort(server, ctx.portStart, "Railcode dev");
162
+ assetProcess = await startAssetDevServer(ctx);
163
+ }
164
+ catch (error) {
165
+ if (server.listening)
166
+ await closeServer(server);
167
+ assetProcess?.reservation?.release();
168
+ if (assetProcess?.child && !assetProcess.child.killed)
169
+ assetProcess.child.kill("SIGTERM");
170
+ throw error;
171
+ }
155
172
  const localUrl = `http://127.0.0.1:${ctx.port}`;
173
+ if (ctx.port !== ctx.portStart) {
174
+ console.log(`Port ${ctx.portStart} was busy; using ${ctx.port}.`);
175
+ }
176
+ if (ctx.assetPort && ctx.assetPortStart && ctx.assetPort !== ctx.assetPortStart) {
177
+ console.log(`Asset port ${ctx.assetPortStart} was busy; using ${ctx.assetPort}.`);
178
+ }
156
179
  printUrlBox(ctx.app, localUrl);
157
180
  console.log("");
158
181
  // Identity, KV, and files are served locally; LLM and SQL forward to the real
@@ -173,8 +196,11 @@ async function commandDevProxy(args) {
173
196
  console.log(`App root: ${ctx.root}`);
174
197
  console.log(`KV/files: ${localDevDir(ctx.app)}`);
175
198
  console.log(`Prod APIs: ${ctx.apiBase ? `${ctx.apiBase}/v1` : "unconfigured"}`);
199
+ console.log(`Dev port: ${ctx.port}`);
176
200
  if (ctx.assetCommand)
177
201
  console.log(`App command: ${ctx.assetCommand}`);
202
+ if (ctx.assetTarget)
203
+ console.log(`Asset target: ${ctx.assetTarget}`);
178
204
  }
179
205
  console.log("");
180
206
  await new Promise((resolveRun) => {
@@ -186,13 +212,20 @@ async function commandDevProxy(args) {
186
212
  if (code !== 0)
187
213
  process.exitCode = code;
188
214
  server.close();
189
- if (assetChild && !assetChild.killed)
190
- assetChild.kill("SIGTERM");
215
+ assetProcess?.reservation?.release();
216
+ if (assetProcess?.child && !assetProcess.child.killed)
217
+ assetProcess.child.kill("SIGTERM");
191
218
  resolveRun();
192
219
  };
193
220
  process.once("SIGINT", () => shutdown());
194
221
  process.once("SIGTERM", () => shutdown());
195
- assetChild?.once("exit", (code) => {
222
+ assetProcess?.child.once("error", (error) => {
223
+ console.error(`App command failed to start: ${error.message}`);
224
+ shutdown(1);
225
+ });
226
+ assetProcess?.child.once("exit", (code) => {
227
+ if (closed)
228
+ return;
196
229
  if (code && code !== 0)
197
230
  console.error(`App command exited with status ${code}`);
198
231
  shutdown(code || 0);
@@ -220,20 +253,21 @@ async function resolveDevContext(args) {
220
253
  }
221
254
  assertAppName(app);
222
255
  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;
256
+ const portStart = parsePort(stringOption(args, "port") ?? DEFAULT_DEV_PORT, "--port");
257
+ const assetPortStart = parsePort(stringOption(args, "assetPort") ?? manifest?.dev?.port ?? DEFAULT_ASSET_DEV_PORT, "--asset-port");
258
+ const explicitAssetCommand = stringOption(args, "command") || manifest?.dev?.command;
259
+ const asset = resolveAssetDevConfig(root, explicitAssetCommand, assetPortStart);
227
260
  const config = loadConfig();
228
261
  const apiBase = apiOrigin(config, args);
229
262
  return {
230
263
  app,
231
264
  root,
232
- port,
265
+ port: portStart,
266
+ portStart,
233
267
  apiBase,
234
268
  token: process.env.RAILCODE_API_TOKEN || config.apiToken,
235
- assetCommand,
236
- assetTarget,
269
+ asset,
270
+ assetPortStart: asset.kind === "none" ? undefined : assetPortStart,
237
271
  };
238
272
  }
239
273
  function readRailcodeManifest(start) {
@@ -328,18 +362,51 @@ function resolveSdkPath() {
328
362
  return sourceTree;
329
363
  throw new CliError(`Railcode SDK file not found. Expected ${bundled}`, 1);
330
364
  }
331
- function inferAssetDevCommand(root, port) {
365
+ function resolveAssetDevConfig(root, explicitCommand, targetPort) {
366
+ if (explicitCommand)
367
+ return { kind: "custom", command: explicitCommand, targetPort };
332
368
  const packagePath = join(root, "package.json");
333
369
  if (!existsSync(packagePath))
334
- return undefined;
370
+ return { kind: "none" };
335
371
  const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
336
372
  if (!pkg.scripts?.dev)
337
- return undefined;
373
+ return { kind: "none" };
338
374
  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`);
375
+ if (isViteProject(root))
376
+ return { kind: "vite", packageManager: pm, startPort: targetPort };
377
+ return { kind: "script", command: runScriptCommand(pm, "dev"), targetPort };
378
+ }
379
+ function isViteProject(root) {
380
+ return existsSync(join(root, "vite.config.ts")) || existsSync(join(root, "vite.config.js"));
381
+ }
382
+ async function startAssetDevServer(ctx) {
383
+ if (ctx.asset.kind === "none")
384
+ return null;
385
+ let reservation;
386
+ if (ctx.asset.kind === "vite") {
387
+ reservation = await reserveAvailablePort(ctx.asset.startPort, "asset dev server");
388
+ ctx.assetPort = reservation.port;
389
+ ctx.assetCommand = runScriptCommand(ctx.asset.packageManager, "dev", `--host 127.0.0.1 --port ${reservation.port} --strictPort --logLevel warn`);
390
+ }
391
+ else {
392
+ ctx.assetPort = ctx.asset.targetPort;
393
+ ctx.assetCommand = ctx.asset.command;
341
394
  }
342
- return runScriptCommand(pm, "dev");
395
+ ctx.assetTarget = `http://127.0.0.1:${ctx.assetPort}`;
396
+ return {
397
+ child: spawn(ctx.assetCommand, {
398
+ cwd: ctx.root,
399
+ env: {
400
+ ...process.env,
401
+ PORT: String(ctx.assetPort),
402
+ RAILCODE_DEV_PORT: String(ctx.port),
403
+ RAILCODE_ASSET_PORT: String(ctx.assetPort),
404
+ },
405
+ shell: true,
406
+ stdio: "inherit",
407
+ }),
408
+ reservation,
409
+ };
343
410
  }
344
411
  async function ensureAppDependencies(root) {
345
412
  if (!existsSync(join(root, "package.json")))
@@ -355,7 +422,7 @@ function dependenciesReady(root) {
355
422
  const nodeModules = join(root, "node_modules");
356
423
  if (!existsSync(nodeModules) || !statSync(nodeModules).isDirectory())
357
424
  return false;
358
- if (existsSync(join(root, "vite.config.ts")) || existsSync(join(root, "vite.config.js"))) {
425
+ if (isViteProject(root)) {
359
426
  return existsSync(join(nodeModules, ".bin", process.platform === "win32" ? "vite.cmd" : "vite"));
360
427
  }
361
428
  return true;
@@ -380,6 +447,140 @@ function runScriptCommand(pm, script, passthrough = "") {
380
447
  return `yarn ${script}${suffix}`;
381
448
  return `${pm} run ${script}${passthrough ? ` -- ${passthrough}` : ""}`;
382
449
  }
450
+ function parsePort(value, label) {
451
+ const port = typeof value === "number" ? value : Number(value);
452
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
453
+ throw new CliError(`${label} must be a TCP port from 1 to 65535.`, 2);
454
+ }
455
+ return port;
456
+ }
457
+ async function listenOnAvailablePort(server, startPort, label) {
458
+ for (let port = startPort, checked = 0; port <= 65535 && checked < PORT_SEARCH_LIMIT; port += 1, checked += 1) {
459
+ try {
460
+ await listenOnPort(server, port);
461
+ return port;
462
+ }
463
+ catch (error) {
464
+ if (errorCode(error) !== "EADDRINUSE")
465
+ throw error;
466
+ }
467
+ }
468
+ throw new CliError(`${label} could not find an available port starting at ${startPort}.`, 1);
469
+ }
470
+ async function reserveAvailablePort(startPort, label) {
471
+ for (let port = startPort, checked = 0; port <= 65535 && checked < PORT_SEARCH_LIMIT; port += 1, checked += 1) {
472
+ const reservation = reserveRailcodePort(port);
473
+ if (!reservation)
474
+ continue;
475
+ try {
476
+ const available = await canListenOnPort(port);
477
+ if (available)
478
+ return reservation;
479
+ }
480
+ catch (error) {
481
+ reservation.release();
482
+ throw error;
483
+ }
484
+ reservation.release();
485
+ }
486
+ throw new CliError(`${label} could not find an available port starting at ${startPort}.`, 1);
487
+ }
488
+ async function canListenOnPort(port) {
489
+ const server = createServer();
490
+ try {
491
+ await listenOnPort(server, port);
492
+ return true;
493
+ }
494
+ catch (error) {
495
+ if (errorCode(error) === "EADDRINUSE")
496
+ return false;
497
+ throw error;
498
+ }
499
+ finally {
500
+ if (server.listening)
501
+ await closeServer(server);
502
+ }
503
+ }
504
+ async function listenOnPort(server, port) {
505
+ await new Promise((resolveListen, rejectListen) => {
506
+ const cleanup = () => {
507
+ server.off("error", onError);
508
+ server.off("listening", onListening);
509
+ };
510
+ const onError = (error) => {
511
+ cleanup();
512
+ rejectListen(error);
513
+ };
514
+ const onListening = () => {
515
+ cleanup();
516
+ resolveListen();
517
+ };
518
+ server.once("error", onError);
519
+ server.once("listening", onListening);
520
+ server.listen(port, "127.0.0.1");
521
+ });
522
+ }
523
+ function reserveRailcodePort(port) {
524
+ const dir = localPortLockDir(port);
525
+ mkdirSync(dirname(dir), { recursive: true });
526
+ try {
527
+ mkdirSync(dir);
528
+ }
529
+ catch (error) {
530
+ if (errorCode(error) !== "EEXIST")
531
+ throw error;
532
+ if (!removeStalePortLock(dir))
533
+ return null;
534
+ try {
535
+ mkdirSync(dir);
536
+ }
537
+ catch (retryError) {
538
+ if (errorCode(retryError) === "EEXIST")
539
+ return null;
540
+ throw retryError;
541
+ }
542
+ }
543
+ writeJsonFile(join(dir, "owner.json"), { pid: process.pid, started_at: nowIso() });
544
+ let released = false;
545
+ return {
546
+ port,
547
+ release: () => {
548
+ if (released)
549
+ return;
550
+ released = true;
551
+ const owner = readJsonFile(join(dir, "owner.json"), {});
552
+ if (owner.pid === process.pid)
553
+ rmSync(dir, { recursive: true, force: true });
554
+ },
555
+ };
556
+ }
557
+ function removeStalePortLock(dir) {
558
+ const owner = readJsonFile(join(dir, "owner.json"), {});
559
+ if (typeof owner.pid === "number" && processIsRunning(owner.pid))
560
+ return false;
561
+ rmSync(dir, { recursive: true, force: true });
562
+ return true;
563
+ }
564
+ function localPortLockDir(port) {
565
+ return join(RAILCODE_HOME, "dev", ".ports", String(port));
566
+ }
567
+ function processIsRunning(pid) {
568
+ try {
569
+ process.kill(pid, 0);
570
+ return true;
571
+ }
572
+ catch (error) {
573
+ return errorCode(error) === "EPERM";
574
+ }
575
+ }
576
+ function errorCode(error) {
577
+ return typeof error === "object" &&
578
+ error !== null &&
579
+ "code" in error &&
580
+ typeof error.code === "string"
581
+ ? error.code
582
+ : undefined;
583
+ }
383
584
  async function handleDevRequest(ctx, request, response) {
384
585
  const url = new URL(request.url || "/", "http://127.0.0.1");
385
586
  if (url.pathname.startsWith("/_api/")) {
@@ -1004,13 +1205,22 @@ function escapeHtml(value) {
1004
1205
  .replaceAll('"', "&quot;")
1005
1206
  .replaceAll("'", "&#39;");
1006
1207
  }
1208
+ const DEPLOY_HELP = `Usage:
1209
+ railcode deploy [--private | --public]
1210
+
1211
+ Run from a Railcode app repo. The first deploy creates the app's access policy:
1212
+ public (anyone signed in) by default, or owner-only with --private. You can also
1213
+ set it declaratively in railcode.json: { "deploy": { "access": "private" } }.
1214
+ Redeploys keep the existing policy; change it later with railcode access.
1215
+ `;
1007
1216
  async function commandDeploy(args) {
1008
1217
  if (booleanOption(args, "help")) {
1009
- console.log("Usage: railcode deploy\n\nRun from a Railcode app repo.");
1218
+ console.log(DEPLOY_HELP);
1010
1219
  return;
1011
1220
  }
1012
1221
  rejectDeployArgs(args);
1013
1222
  const ctx = resolveDeployContext();
1223
+ const access = resolveDeployAccess(args, ctx);
1014
1224
  await buildApp(ctx.appRoot);
1015
1225
  const source = resolveDeploySource(ctx);
1016
1226
  const files = collectDeployFiles(source);
@@ -1019,7 +1229,7 @@ async function commandDeploy(args) {
1019
1229
  const apiUrl = canonicalApiOrigin(authUrl);
1020
1230
  let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Deploy");
1021
1231
  console.log(`Uploading ${ctx.app} to ${apiUrl}...`);
1022
- const response = await postDeployBundle(apiUrl, authedConfig, ctx.app, files);
1232
+ const response = await postDeployBundle(apiUrl, authedConfig, ctx.app, files, access);
1023
1233
  if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
1024
1234
  console.log("Saved login expired. Log in again to continue.");
1025
1235
  const retryConfig = { ...authedConfig, apiUrl: authUrl };
@@ -1028,19 +1238,46 @@ async function commandDeploy(args) {
1028
1238
  delete retryConfig.cookies;
1029
1239
  saveConfig(retryConfig);
1030
1240
  const freshConfig = await ensureApiToken(retryConfig, authUrl, "Deploy");
1031
- await handleDeployResponse(await postDeployBundle(apiUrl, freshConfig, ctx.app, files), ctx.app, apiUrl);
1241
+ const retry = await postDeployBundle(apiUrl, freshConfig, ctx.app, files, access);
1242
+ await handleDeployResponse(retry, ctx.app, apiUrl, access);
1032
1243
  return;
1033
1244
  }
1034
- await handleDeployResponse(response, ctx.app, apiUrl);
1245
+ await handleDeployResponse(response, ctx.app, apiUrl, access);
1035
1246
  }
1036
1247
  function rejectDeployArgs(args) {
1037
1248
  if (args.rest.length > 0) {
1038
1249
  throw new CliError("railcode deploy does not take an app argument. Run it from the Railcode app directory.", 2);
1039
1250
  }
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
- }
1251
+ const allowed = new Set(["private", "public", "help"]);
1252
+ const unknown = Object.keys(args.options).filter((option) => !allowed.has(option));
1253
+ if (unknown.length > 0) {
1254
+ throw new CliError(`Unknown option for deploy: --${unknown[0]}. Configure the Railcode URL in railcode.json or RAILCODE_API_URL.`, 2);
1255
+ }
1256
+ }
1257
+ // First-deploy access: --private/--public win over railcode.json deploy.access.
1258
+ // Returns the canonical policy mode, or undefined to let the server default.
1259
+ function resolveDeployAccess(args, ctx) {
1260
+ const wantsPrivate = booleanOption(args, "private");
1261
+ const wantsPublic = booleanOption(args, "public");
1262
+ if (wantsPrivate && wantsPublic) {
1263
+ throw new CliError("Use either --private or --public, not both.", 2);
1264
+ }
1265
+ if (wantsPrivate)
1266
+ return "private";
1267
+ if (wantsPublic)
1268
+ return "workspace";
1269
+ return ctx.access ? normalizeDeployAccess(ctx.access) : undefined;
1270
+ }
1271
+ function normalizeDeployAccess(value) {
1272
+ const mode = value.trim().toLowerCase();
1273
+ if (mode === "public" || mode === "workspace")
1274
+ return "workspace";
1275
+ if (mode === "private")
1276
+ return "private";
1277
+ if (mode === "restricted") {
1278
+ throw new CliError("deploy.access 'restricted' is not supported at deploy. Deploy, then run railcode access restricted --users <...>.", 2);
1279
+ }
1280
+ throw new CliError(`Invalid deploy.access '${value}'. Use 'public' or 'private'.`, 2);
1044
1281
  }
1045
1282
  function resolveDeployContext() {
1046
1283
  const cwd = process.cwd();
@@ -1058,6 +1295,7 @@ function resolveDeployContext() {
1058
1295
  appRoot,
1059
1296
  workspaceRoot,
1060
1297
  apiUrl: manifest?.deploy?.apiUrl,
1298
+ access: manifest?.deploy?.access,
1061
1299
  };
1062
1300
  }
1063
1301
  async function buildApp(appRoot) {
@@ -1149,7 +1387,7 @@ function collectDeployFiles(root) {
1149
1387
  }
1150
1388
  return files;
1151
1389
  }
1152
- async function postDeployBundle(apiUrl, config, app, files) {
1390
+ async function postDeployBundle(apiUrl, config, app, files, access) {
1153
1391
  try {
1154
1392
  return await fetch(`${apiUrl}/v1/apps/${app}/deploy`, {
1155
1393
  method: "POST",
@@ -1157,7 +1395,7 @@ async function postDeployBundle(apiUrl, config, app, files) {
1157
1395
  Authorization: `Bearer ${config.apiToken}`,
1158
1396
  "Content-Type": "application/json",
1159
1397
  },
1160
- body: JSON.stringify({ files }),
1398
+ body: JSON.stringify(access ? { files, access } : { files }),
1161
1399
  redirect: "manual",
1162
1400
  });
1163
1401
  }
@@ -1165,14 +1403,19 @@ async function postDeployBundle(apiUrl, config, app, files) {
1165
1403
  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
1404
  }
1167
1405
  }
1168
- async function handleDeployResponse(response, app, apiUrl) {
1406
+ async function handleDeployResponse(response, app, apiUrl, requestedAccess) {
1169
1407
  if (response.ok) {
1170
1408
  const body = await response.json();
1171
1409
  console.log(`Deployed ${app}${body.files ? ` (${body.files} files)` : ""}`);
1172
1410
  console.log(body.url || appUrlFromOrigin(app, apiUrl));
1173
1411
  const accessLabel = deployAccessLabel(body.access);
1174
- if (accessLabel)
1412
+ if (accessLabel) {
1175
1413
  console.log(`Access: ${accessLabel}`);
1414
+ }
1415
+ else if (requestedAccess && body.access === "existing_policy") {
1416
+ const word = requestedAccess === "private" ? "private" : "public";
1417
+ console.log(`Access unchanged (${app} already exists). Run 'railcode access ${word}' to change it.`);
1418
+ }
1176
1419
  return;
1177
1420
  }
1178
1421
  const text = await response.text();
@@ -1187,8 +1430,156 @@ async function handleDeployResponse(response, app, apiUrl) {
1187
1430
  function deployAccessLabel(access) {
1188
1431
  if (access === "workspace_created")
1189
1432
  return "public to signed-in users";
1433
+ if (access === "private_created")
1434
+ return "private (owner only)";
1190
1435
  return undefined;
1191
1436
  }
1437
+ const ACCESS_HELP = `Usage:
1438
+ railcode access Show the app's current access
1439
+ railcode access public Anyone signed in
1440
+ railcode access private Just you (the owner)
1441
+ railcode access restricted --users a@b.com,c@d.com Named users only
1442
+
1443
+ Run from a Railcode app repo. Deploy the app at least once before changing access.
1444
+ Only the app owner or an admin can change it. 'public' is an alias for 'workspace'.
1445
+ `;
1446
+ // Friendly CLI words mapped to the server's canonical policy modes.
1447
+ const ACCESS_MODES = {
1448
+ public: "workspace",
1449
+ workspace: "workspace",
1450
+ private: "private",
1451
+ restricted: "restricted",
1452
+ };
1453
+ async function commandAccess(args) {
1454
+ if (booleanOption(args, "help") || args.rest[0] === "help") {
1455
+ console.log(ACCESS_HELP);
1456
+ return;
1457
+ }
1458
+ const { method, payload } = parseAccessArgs(args);
1459
+ const ctx = resolveDeployContext();
1460
+ const config = loadConfig();
1461
+ const authUrl = await resolveDeployAuthOrigin(config, ctx);
1462
+ const apiUrl = canonicalApiOrigin(authUrl);
1463
+ let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Access");
1464
+ let response = await sendAccessRequest(apiUrl, authedConfig, ctx.app, method, payload);
1465
+ if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
1466
+ console.log("Saved login expired. Log in again to continue.");
1467
+ const retryConfig = { ...authedConfig, apiUrl: authUrl };
1468
+ delete retryConfig.apiToken;
1469
+ delete retryConfig.apiTokenPrefix;
1470
+ delete retryConfig.cookies;
1471
+ saveConfig(retryConfig);
1472
+ authedConfig = await ensureApiToken(retryConfig, authUrl, "Access");
1473
+ response = await sendAccessRequest(apiUrl, authedConfig, ctx.app, method, payload);
1474
+ }
1475
+ await handleAccessResponse(response, ctx.app, method);
1476
+ }
1477
+ function parseAccessArgs(args) {
1478
+ const requested = args.rest[0];
1479
+ if (!requested) {
1480
+ rejectAccessOptions(args, false);
1481
+ return { method: "GET" };
1482
+ }
1483
+ if (args.rest.length > 1) {
1484
+ throw new CliError("railcode access takes a single mode argument.", 2);
1485
+ }
1486
+ const mode = ACCESS_MODES[requested.toLowerCase()];
1487
+ if (!mode) {
1488
+ throw new CliError(`Unknown access mode '${requested}'. Use public, private, or restricted.`, 2);
1489
+ }
1490
+ rejectAccessOptions(args, mode === "restricted");
1491
+ const payload = { mode };
1492
+ if (mode === "restricted") {
1493
+ payload.members = parseMembers(args);
1494
+ if (payload.members.length === 0) {
1495
+ throw new CliError("restricted access needs --users <a@b.com,c@d.com> of existing Railcode users.", 2);
1496
+ }
1497
+ }
1498
+ return { method: "PUT", payload };
1499
+ }
1500
+ function rejectAccessOptions(args, allowUsers) {
1501
+ const allowed = new Set(["help"]);
1502
+ if (allowUsers)
1503
+ allowed.add("users");
1504
+ const unknown = Object.keys(args.options).filter((option) => !allowed.has(option));
1505
+ if (unknown.length > 0) {
1506
+ const hint = unknown[0] === "users" ? " --users only applies to restricted access." : "";
1507
+ throw new CliError(`Unknown option for access: --${unknown[0]}.${hint}`, 2);
1508
+ }
1509
+ }
1510
+ function parseMembers(args) {
1511
+ const raw = stringOption(args, "users");
1512
+ if (!raw)
1513
+ return [];
1514
+ return raw
1515
+ .split(",")
1516
+ .map((member) => member.trim())
1517
+ .filter(Boolean);
1518
+ }
1519
+ async function sendAccessRequest(apiUrl, config, app, method, payload) {
1520
+ if (!config.apiToken) {
1521
+ throw new CliError("Missing Railcode API token. Run from a terminal once, or set RAILCODE_API_TOKEN.", 1);
1522
+ }
1523
+ const headers = { Authorization: `Bearer ${config.apiToken}` };
1524
+ if (method === "PUT")
1525
+ headers["Content-Type"] = "application/json";
1526
+ try {
1527
+ return await fetch(`${apiUrl}/v1/apps/${app}/access`, {
1528
+ method,
1529
+ headers,
1530
+ body: method === "PUT" ? JSON.stringify(payload) : undefined,
1531
+ redirect: "manual",
1532
+ });
1533
+ }
1534
+ catch (error) {
1535
+ 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);
1536
+ }
1537
+ }
1538
+ async function handleAccessResponse(response, app, method) {
1539
+ if (response.ok) {
1540
+ printAccess((await response.json()), method === "PUT");
1541
+ return;
1542
+ }
1543
+ const detail = errorDetail(await response.text());
1544
+ if (response.status === 401) {
1545
+ throw new CliError("Access login was rejected. Run the command again to log in.", 1);
1546
+ }
1547
+ if (response.status === 403) {
1548
+ throw new CliError(`Access change denied for ${app}: ${detail}. Only the app owner or an admin can change access.`, 1);
1549
+ }
1550
+ if (response.status === 404 || response.status === 409) {
1551
+ throw new CliError(detail || `App ${app} not found.`, 1);
1552
+ }
1553
+ throw new CliError(`Access ${method === "PUT" ? "update" : "lookup"} failed (${response.status}): ${detail}`, 1);
1554
+ }
1555
+ function printAccess(body, changed) {
1556
+ console.log(`${changed ? "Access set:" : "Access:"} ${accessModeLabel(body.mode)}`);
1557
+ if (body.mode === "restricted" && body.members && body.members.length > 0) {
1558
+ console.log(`Allowed: ${body.members.join(", ")}`);
1559
+ }
1560
+ if (body.url)
1561
+ console.log(body.url);
1562
+ }
1563
+ function accessModeLabel(mode) {
1564
+ if (mode === "workspace")
1565
+ return "public (anyone signed in)";
1566
+ if (mode === "private")
1567
+ return "private (owner only)";
1568
+ if (mode === "restricted")
1569
+ return "restricted (named users only)";
1570
+ return mode || "unknown";
1571
+ }
1572
+ function errorDetail(text) {
1573
+ try {
1574
+ const parsed = JSON.parse(text);
1575
+ if (typeof parsed.detail === "string")
1576
+ return parsed.detail;
1577
+ }
1578
+ catch {
1579
+ // Non-JSON error body; fall through to the raw text.
1580
+ }
1581
+ return text;
1582
+ }
1192
1583
  function appUrlFromOrigin(app, origin) {
1193
1584
  const url = new URL(origin);
1194
1585
  const [first, ...rest] = url.hostname.split(".");
@@ -1204,17 +1595,31 @@ function appUrlFromOrigin(app, origin) {
1204
1595
  return url.toString();
1205
1596
  }
1206
1597
  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]
1598
+ railcode get design-system
1209
1599
 
1210
1600
  Options:
1211
1601
  --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.
1602
+
1603
+ Compatibility aliases:
1604
+ railcode design-system pull
1605
+ railcode pull design-system
1213
1606
  `;
1607
+ async function commandGet(args) {
1608
+ const target = args.rest[0];
1609
+ if (target === "design-system") {
1610
+ await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
1611
+ return;
1612
+ }
1613
+ if (!target || target === "help" || booleanOption(args, "help")) {
1614
+ console.log(DESIGN_SYSTEM_HELP);
1615
+ return;
1616
+ }
1617
+ throw new CliError(`Unknown get target: ${target}\n\n${DESIGN_SYSTEM_HELP}`, 2);
1618
+ }
1214
1619
  async function commandPull(args) {
1215
1620
  const target = args.rest[0];
1216
1621
  if (target === "design-system") {
1217
- await commandDesignSystemPull({ ...args, rest: args.rest.slice(1) });
1622
+ await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
1218
1623
  return;
1219
1624
  }
1220
1625
  if (!target || target === "help" || booleanOption(args, "help")) {
@@ -1226,7 +1631,11 @@ async function commandPull(args) {
1226
1631
  async function commandDesignSystem(args) {
1227
1632
  const subcommand = args.rest[0];
1228
1633
  if (subcommand === "pull") {
1229
- await commandDesignSystemPull({ ...args, rest: args.rest.slice(1) });
1634
+ await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
1635
+ return;
1636
+ }
1637
+ if (subcommand === "get") {
1638
+ await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
1230
1639
  return;
1231
1640
  }
1232
1641
  if (!subcommand || subcommand === "help" || booleanOption(args, "help")) {
@@ -1235,16 +1644,16 @@ async function commandDesignSystem(args) {
1235
1644
  }
1236
1645
  throw new CliError(`Unknown design-system command: ${subcommand}\n\n${DESIGN_SYSTEM_HELP}`, 2);
1237
1646
  }
1238
- async function commandDesignSystemPull(args) {
1647
+ async function commandDesignSystemGet(args) {
1239
1648
  if (args.rest[0] === "help" || booleanOption(args, "help")) {
1240
1649
  console.log(DESIGN_SYSTEM_HELP);
1241
1650
  return;
1242
1651
  }
1243
- rejectDesignSystemPullArgs(args);
1652
+ rejectDesignSystemGetArgs(args);
1244
1653
  const config = loadConfig();
1245
1654
  const authUrl = await resolveDesignSystemAuthOrigin(config, args);
1246
1655
  const apiUrl = canonicalApiOrigin(authUrl);
1247
- let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Design system pull");
1656
+ let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Design system get");
1248
1657
  let response = await getDesignSystem(apiUrl, authedConfig);
1249
1658
  if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
1250
1659
  console.log("Saved login expired. Log in again to continue.");
@@ -1253,39 +1662,27 @@ async function commandDesignSystemPull(args) {
1253
1662
  delete retryConfig.apiTokenPrefix;
1254
1663
  delete retryConfig.cookies;
1255
1664
  saveConfig(retryConfig);
1256
- authedConfig = await ensureApiToken(retryConfig, authUrl, "Design system pull");
1665
+ authedConfig = await ensureApiToken(retryConfig, authUrl, "Design system get");
1257
1666
  response = await getDesignSystem(apiUrl, authedConfig);
1258
1667
  }
1259
- const designSystem = await handleDesignSystemResponse(response);
1260
- writeDesignSystemMarkdown(designSystem.markdown, designSystemOutput(args));
1668
+ writeDesignSystemMarkdown(await handleDesignSystemResponse(response));
1261
1669
  }
1262
- function rejectDesignSystemPullArgs(args) {
1263
- const allowedOptions = new Set(["apiUrl", "help", "output"]);
1670
+ function rejectDesignSystemGetArgs(args) {
1671
+ const allowedOptions = new Set(["apiUrl", "help"]);
1264
1672
  const unknown = Object.keys(args.options).filter((option) => !allowedOptions.has(option));
1265
1673
  if (unknown.length > 0) {
1266
- throw new CliError(`Unknown option for design-system pull: --${unknown[0]}`, 2);
1267
- }
1268
- if (args.rest.length > 1) {
1269
- throw new CliError("design-system pull accepts at most one output path.", 2);
1674
+ throw new CliError(`Unknown option for design-system get: --${unknown[0]}`, 2);
1270
1675
  }
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);
1676
+ if (args.rest.length > 0) {
1677
+ throw new CliError("railcode get design-system does not accept positional arguments.", 2);
1276
1678
  }
1277
1679
  }
1278
- function designSystemOutput(args) {
1279
- if (typeof args.options.output === "string")
1280
- return args.options.output;
1281
- return args.rest[0];
1282
- }
1283
1680
  async function resolveDesignSystemAuthOrigin(config, args) {
1284
1681
  const manifestApiUrl = readRailcodeManifest(process.cwd())?.manifest.deploy?.apiUrl;
1285
1682
  return resolveRequiredAuthOrigin({
1286
1683
  config,
1287
1684
  args,
1288
- action: "Design system pull",
1685
+ action: "Design system get",
1289
1686
  fallbackApiUrl: manifestApiUrl,
1290
1687
  missingUrlHint: "Pass --api-url <url>, set deploy.apiUrl in railcode.json, or set RAILCODE_API_URL.",
1291
1688
  });
@@ -1306,35 +1703,30 @@ async function getDesignSystem(apiUrl, config) {
1306
1703
  }
1307
1704
  async function handleDesignSystemResponse(response) {
1308
1705
  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);
1706
+ const text = await response.text();
1707
+ const contentType = response.headers.get("content-type") || "";
1708
+ if (contentType.includes("application/json")) {
1709
+ const body = JSON.parse(text);
1710
+ if (typeof body.markdown !== "string") {
1711
+ throw new CliError("Design system response did not include markdown.", 1);
1712
+ }
1713
+ return body.markdown;
1312
1714
  }
1313
- return {
1314
- markdown: body.markdown,
1315
- updated_at: body.updated_at ?? null,
1316
- };
1715
+ return text;
1317
1716
  }
1318
1717
  const text = await response.text();
1319
1718
  if (response.status === 401) {
1320
- throw new CliError("Design system pull login was rejected. Run the command again to log in.", 1);
1719
+ throw new CliError("Design system get login was rejected. Run the command again to log in.", 1);
1321
1720
  }
1322
1721
  if (response.status === 403) {
1323
- throw new CliError(`Design system pull denied: ${text}`, 1);
1722
+ throw new CliError(`Design system get denied: ${text}`, 1);
1324
1723
  }
1325
- throw new CliError(`Design system pull failed (${response.status}): ${text}`, 1);
1724
+ throw new CliError(`Design system get failed (${response.status}): ${text}`, 1);
1326
1725
  }
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)}`);
1726
+ function writeDesignSystemMarkdown(markdown) {
1727
+ process.stdout.write(markdown);
1728
+ if (markdown && !markdown.endsWith("\n"))
1729
+ process.stdout.write("\n");
1338
1730
  }
1339
1731
  async function commandInit(args) {
1340
1732
  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.5",
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 = {