railcode 0.1.1 → 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/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/")) {
@@ -400,7 +601,11 @@ async function handleLocalApi(ctx, request, response, url) {
400
601
  return;
401
602
  }
402
603
  if (path === "/me") {
403
- sendJson(response, 200, { user: "local-dev", app: ctx.app });
604
+ sendJson(response, 200, {
605
+ user: "local-dev",
606
+ display_name: "Local Dev",
607
+ app: ctx.app,
608
+ });
404
609
  return;
405
610
  }
406
611
  if (path === "/app-users") {
@@ -1000,13 +1205,22 @@ function escapeHtml(value) {
1000
1205
  .replaceAll('"', "&quot;")
1001
1206
  .replaceAll("'", "&#39;");
1002
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
+ `;
1003
1216
  async function commandDeploy(args) {
1004
1217
  if (booleanOption(args, "help")) {
1005
- console.log("Usage: railcode deploy\n\nRun from a Railcode app repo.");
1218
+ console.log(DEPLOY_HELP);
1006
1219
  return;
1007
1220
  }
1008
1221
  rejectDeployArgs(args);
1009
1222
  const ctx = resolveDeployContext();
1223
+ const access = resolveDeployAccess(args, ctx);
1010
1224
  await buildApp(ctx.appRoot);
1011
1225
  const source = resolveDeploySource(ctx);
1012
1226
  const files = collectDeployFiles(source);
@@ -1015,7 +1229,7 @@ async function commandDeploy(args) {
1015
1229
  const apiUrl = canonicalApiOrigin(authUrl);
1016
1230
  let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Deploy");
1017
1231
  console.log(`Uploading ${ctx.app} to ${apiUrl}...`);
1018
- const response = await postDeployBundle(apiUrl, authedConfig, ctx.app, files);
1232
+ const response = await postDeployBundle(apiUrl, authedConfig, ctx.app, files, access);
1019
1233
  if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
1020
1234
  console.log("Saved login expired. Log in again to continue.");
1021
1235
  const retryConfig = { ...authedConfig, apiUrl: authUrl };
@@ -1024,19 +1238,46 @@ async function commandDeploy(args) {
1024
1238
  delete retryConfig.cookies;
1025
1239
  saveConfig(retryConfig);
1026
1240
  const freshConfig = await ensureApiToken(retryConfig, authUrl, "Deploy");
1027
- 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);
1028
1243
  return;
1029
1244
  }
1030
- await handleDeployResponse(response, ctx.app, apiUrl);
1245
+ await handleDeployResponse(response, ctx.app, apiUrl, access);
1031
1246
  }
1032
1247
  function rejectDeployArgs(args) {
1033
1248
  if (args.rest.length > 0) {
1034
1249
  throw new CliError("railcode deploy does not take an app argument. Run it from the Railcode app directory.", 2);
1035
1250
  }
1036
- const options = Object.keys(args.options);
1037
- if (options.length > 0) {
1038
- throw new CliError("railcode deploy does not take options. Configure the Railcode URL in railcode.json or RAILCODE_API_URL.", 2);
1039
- }
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);
1040
1281
  }
1041
1282
  function resolveDeployContext() {
1042
1283
  const cwd = process.cwd();
@@ -1054,6 +1295,7 @@ function resolveDeployContext() {
1054
1295
  appRoot,
1055
1296
  workspaceRoot,
1056
1297
  apiUrl: manifest?.deploy?.apiUrl,
1298
+ access: manifest?.deploy?.access,
1057
1299
  };
1058
1300
  }
1059
1301
  async function buildApp(appRoot) {
@@ -1145,7 +1387,7 @@ function collectDeployFiles(root) {
1145
1387
  }
1146
1388
  return files;
1147
1389
  }
1148
- async function postDeployBundle(apiUrl, config, app, files) {
1390
+ async function postDeployBundle(apiUrl, config, app, files, access) {
1149
1391
  try {
1150
1392
  return await fetch(`${apiUrl}/v1/apps/${app}/deploy`, {
1151
1393
  method: "POST",
@@ -1153,7 +1395,7 @@ async function postDeployBundle(apiUrl, config, app, files) {
1153
1395
  Authorization: `Bearer ${config.apiToken}`,
1154
1396
  "Content-Type": "application/json",
1155
1397
  },
1156
- body: JSON.stringify({ files }),
1398
+ body: JSON.stringify(access ? { files, access } : { files }),
1157
1399
  redirect: "manual",
1158
1400
  });
1159
1401
  }
@@ -1161,14 +1403,19 @@ async function postDeployBundle(apiUrl, config, app, files) {
1161
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);
1162
1404
  }
1163
1405
  }
1164
- async function handleDeployResponse(response, app, apiUrl) {
1406
+ async function handleDeployResponse(response, app, apiUrl, requestedAccess) {
1165
1407
  if (response.ok) {
1166
1408
  const body = await response.json();
1167
1409
  console.log(`Deployed ${app}${body.files ? ` (${body.files} files)` : ""}`);
1168
1410
  console.log(body.url || appUrlFromOrigin(app, apiUrl));
1169
1411
  const accessLabel = deployAccessLabel(body.access);
1170
- if (accessLabel)
1412
+ if (accessLabel) {
1171
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
+ }
1172
1419
  return;
1173
1420
  }
1174
1421
  const text = await response.text();
@@ -1183,8 +1430,156 @@ async function handleDeployResponse(response, app, apiUrl) {
1183
1430
  function deployAccessLabel(access) {
1184
1431
  if (access === "workspace_created")
1185
1432
  return "public to signed-in users";
1433
+ if (access === "private_created")
1434
+ return "private (owner only)";
1186
1435
  return undefined;
1187
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
+ }
1188
1583
  function appUrlFromOrigin(app, origin) {
1189
1584
  const url = new URL(origin);
1190
1585
  const [first, ...rest] = url.hostname.split(".");
@@ -1200,17 +1595,31 @@ function appUrlFromOrigin(app, origin) {
1200
1595
  return url.toString();
1201
1596
  }
1202
1597
  const DESIGN_SYSTEM_HELP = `Usage:
1203
- railcode design-system pull [output.md] [--output output.md]
1204
- railcode pull design-system [output.md] [--output output.md]
1598
+ railcode get design-system
1205
1599
 
1206
1600
  Options:
1207
1601
  --api-url <url> Railcode auth/API URL. Defaults to saved config, or prompts if missing.
1208
- --output <path> Write markdown to a file instead of stdout.
1602
+
1603
+ Compatibility aliases:
1604
+ railcode design-system pull
1605
+ railcode pull design-system
1209
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
+ }
1210
1619
  async function commandPull(args) {
1211
1620
  const target = args.rest[0];
1212
1621
  if (target === "design-system") {
1213
- await commandDesignSystemPull({ ...args, rest: args.rest.slice(1) });
1622
+ await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
1214
1623
  return;
1215
1624
  }
1216
1625
  if (!target || target === "help" || booleanOption(args, "help")) {
@@ -1222,7 +1631,11 @@ async function commandPull(args) {
1222
1631
  async function commandDesignSystem(args) {
1223
1632
  const subcommand = args.rest[0];
1224
1633
  if (subcommand === "pull") {
1225
- 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) });
1226
1639
  return;
1227
1640
  }
1228
1641
  if (!subcommand || subcommand === "help" || booleanOption(args, "help")) {
@@ -1231,16 +1644,16 @@ async function commandDesignSystem(args) {
1231
1644
  }
1232
1645
  throw new CliError(`Unknown design-system command: ${subcommand}\n\n${DESIGN_SYSTEM_HELP}`, 2);
1233
1646
  }
1234
- async function commandDesignSystemPull(args) {
1647
+ async function commandDesignSystemGet(args) {
1235
1648
  if (args.rest[0] === "help" || booleanOption(args, "help")) {
1236
1649
  console.log(DESIGN_SYSTEM_HELP);
1237
1650
  return;
1238
1651
  }
1239
- rejectDesignSystemPullArgs(args);
1652
+ rejectDesignSystemGetArgs(args);
1240
1653
  const config = loadConfig();
1241
1654
  const authUrl = await resolveDesignSystemAuthOrigin(config, args);
1242
1655
  const apiUrl = canonicalApiOrigin(authUrl);
1243
- let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Design system pull");
1656
+ let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Design system get");
1244
1657
  let response = await getDesignSystem(apiUrl, authedConfig);
1245
1658
  if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
1246
1659
  console.log("Saved login expired. Log in again to continue.");
@@ -1249,39 +1662,27 @@ async function commandDesignSystemPull(args) {
1249
1662
  delete retryConfig.apiTokenPrefix;
1250
1663
  delete retryConfig.cookies;
1251
1664
  saveConfig(retryConfig);
1252
- authedConfig = await ensureApiToken(retryConfig, authUrl, "Design system pull");
1665
+ authedConfig = await ensureApiToken(retryConfig, authUrl, "Design system get");
1253
1666
  response = await getDesignSystem(apiUrl, authedConfig);
1254
1667
  }
1255
- const designSystem = await handleDesignSystemResponse(response);
1256
- writeDesignSystemMarkdown(designSystem.markdown, designSystemOutput(args));
1668
+ writeDesignSystemMarkdown(await handleDesignSystemResponse(response));
1257
1669
  }
1258
- function rejectDesignSystemPullArgs(args) {
1259
- const allowedOptions = new Set(["apiUrl", "help", "output"]);
1670
+ function rejectDesignSystemGetArgs(args) {
1671
+ const allowedOptions = new Set(["apiUrl", "help"]);
1260
1672
  const unknown = Object.keys(args.options).filter((option) => !allowedOptions.has(option));
1261
1673
  if (unknown.length > 0) {
1262
- throw new CliError(`Unknown option for design-system pull: --${unknown[0]}`, 2);
1263
- }
1264
- if (args.rest.length > 1) {
1265
- 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);
1266
1675
  }
1267
- if (args.rest.length === 1 && typeof args.options.output === "string") {
1268
- throw new CliError("Pass the output path either positionally or with --output, not both.", 2);
1269
- }
1270
- if (args.options.output === true) {
1271
- 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);
1272
1678
  }
1273
1679
  }
1274
- function designSystemOutput(args) {
1275
- if (typeof args.options.output === "string")
1276
- return args.options.output;
1277
- return args.rest[0];
1278
- }
1279
1680
  async function resolveDesignSystemAuthOrigin(config, args) {
1280
1681
  const manifestApiUrl = readRailcodeManifest(process.cwd())?.manifest.deploy?.apiUrl;
1281
1682
  return resolveRequiredAuthOrigin({
1282
1683
  config,
1283
1684
  args,
1284
- action: "Design system pull",
1685
+ action: "Design system get",
1285
1686
  fallbackApiUrl: manifestApiUrl,
1286
1687
  missingUrlHint: "Pass --api-url <url>, set deploy.apiUrl in railcode.json, or set RAILCODE_API_URL.",
1287
1688
  });
@@ -1302,35 +1703,30 @@ async function getDesignSystem(apiUrl, config) {
1302
1703
  }
1303
1704
  async function handleDesignSystemResponse(response) {
1304
1705
  if (response.ok) {
1305
- const body = (await response.json());
1306
- if (typeof body.markdown !== "string") {
1307
- 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;
1308
1714
  }
1309
- return {
1310
- markdown: body.markdown,
1311
- updated_at: body.updated_at ?? null,
1312
- };
1715
+ return text;
1313
1716
  }
1314
1717
  const text = await response.text();
1315
1718
  if (response.status === 401) {
1316
- 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);
1317
1720
  }
1318
1721
  if (response.status === 403) {
1319
- throw new CliError(`Design system pull denied: ${text}`, 1);
1722
+ throw new CliError(`Design system get denied: ${text}`, 1);
1320
1723
  }
1321
- throw new CliError(`Design system pull failed (${response.status}): ${text}`, 1);
1724
+ throw new CliError(`Design system get failed (${response.status}): ${text}`, 1);
1322
1725
  }
1323
- function writeDesignSystemMarkdown(markdown, output) {
1324
- if (!output || output === "-") {
1325
- process.stdout.write(markdown);
1326
- if (markdown && !markdown.endsWith("\n"))
1327
- process.stdout.write("\n");
1328
- return;
1329
- }
1330
- const path = resolve(output);
1331
- mkdirSync(dirname(path), { recursive: true });
1332
- writeFileSync(path, markdown);
1333
- 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");
1334
1730
  }
1335
1731
  async function commandInit(args) {
1336
1732
  const app = args.rest[0];