railcode 0.1.26 → 0.1.28

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
@@ -6,12 +6,15 @@ import { createServer, request as httpRequest, } from "node:http";
6
6
  import { createServer as createNetServer, connect as netConnect } from "node:net";
7
7
  import { homedir } from "node:os";
8
8
  import { Readable } from "node:stream";
9
- import { dirname, join, relative, resolve, sep } from "node:path";
9
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
10
10
  import process from "node:process";
11
11
  import { createInterface } from "node:readline/promises";
12
12
  import { fileURLToPath } from "node:url";
13
+ import { parse as parseYaml } from "yaml";
13
14
  import { KvQueryError, kvCount, kvQuery, } from "./dev/kv-query.js";
15
+ import { detectGlobalInstaller, fetchPackageVersions, pickLatestSameMajor, shouldCheckForUpdate, } from "./update.js";
14
16
  import { DbError, formatTable, inferEngine, parseEngine, parseSqlParams, pickSqlSource, } from "./db.js";
17
+ import { StorageError, encodeFilePath, guessContentType, ownerCell, pageParams, parseKvValue, parseScopeSelector, parseWhere, scopeQuery, } from "./app-storage.js";
15
18
  import { QueryCmdError, paramSignature, parseParamDecls, parseRunParams, pickAuthoringSql, } from "./query.js";
16
19
  import { APP_MANIFEST_NAME, ManifestParseError, documentOperations, parseManifestYaml, renderManifestDeployOutcome, renderManifestSummary, } from "./manifest.js";
17
20
  import { ConnectorError, buildConnectorRequest, connectorOpenapiOutput, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderConnectorDocs, renderProxyResponse, } from "./connectors.js";
@@ -26,7 +29,11 @@ const RAILCODE_HOME = process.env.RAILCODE_HOME || join(homedir(), ".railcode");
26
29
  const CONFIG_PATH = join(RAILCODE_HOME, "config.json");
27
30
  const CLI_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
31
  const VERSION = readCliPackageVersion();
32
+ const PACKAGE_NAME = readCliPackageName();
29
33
  const DEFAULT_API_URL = "https://api.railcode.app";
34
+ // Where the auto-update throttle records its last check (see maybeAutoUpdate).
35
+ const UPDATE_STATE_PATH = join(RAILCODE_HOME, "update-check.json");
36
+ const DEFAULT_REGISTRY_URL = "https://registry.npmjs.org";
30
37
  const MANIFEST_NAME = "railcode.json";
31
38
  const CLI_AUTH_CALLBACK_PATH = "/railcode-cli/callback";
32
39
  const CLI_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
@@ -35,11 +42,13 @@ const HELP = `railcode ${VERSION}
35
42
  Developer CLI for the multi-tenant Railcode platform.
36
43
 
37
44
  Usage:
38
- railcode login [--api-url <url>] Sign in and mint a personal API token
45
+ railcode login [--api-url <url>] [--paste] Sign in and mint a personal API token
46
+ (--paste / --no-browser: skip the localhost
47
+ callback, paste a code instead — for SSH/headless)
39
48
  railcode login --setup-token <token> Sign in with a one-time onboarding setup token
40
49
  railcode deploy [--private] Build (if configured) and deploy the app here
41
50
  railcode dev [--port <n>] [--reset] Run the app locally against an emulated /_api
42
- railcode init <app> [--template react|static]
51
+ railcode init <app> [dir] [--template react|static] [--force]
43
52
  railcode design-system Print your org's design-system guidance (markdown)
44
53
  railcode db <list|query> ... List data connectors / run read-only SQL
45
54
  railcode query <list|run|create|update|delete> ... Saved queries: invoke by name / author (admin)
@@ -56,6 +65,8 @@ Usage:
56
65
  railcode roles <list|create|update|delete|add-member|remove-member|grants|grant|revoke|materialize|effective|catalog> ...
57
66
  Manage org roles + the granular grants table (admin)
58
67
  railcode apps <list|show|access|set-access|transfer|delete> ... Manage apps + access policy
68
+ railcode app kv <collections|list|get|set|delete|drop> ... Manage an app's KV store (owner)
69
+ railcode app files <list|download|upload|delete> ... Manage an app's files (owner)
59
70
  railcode analytics <app> [--range 1d|7d|30d|90d] Per-app pageview analytics (admin/owner)
60
71
  railcode logs <connector|service-connector|llm|email|agent> [filters]
61
72
  Read org observability logs (admin)
@@ -63,13 +74,19 @@ Usage:
63
74
  railcode --help
64
75
 
65
76
  Config: ~/.railcode/config.json
66
- API URL resolves from --api-url, then RAILCODE_API_URL, then saved config.
77
+ API URL resolves from --api-url, then RAILCODE_API_URL, then saved config, then
78
+ the default (${DEFAULT_API_URL}) — never prompted.
67
79
  `;
68
80
  // ---------------------------------------------------------------------------
69
81
  // Entry point
70
82
  // ---------------------------------------------------------------------------
71
83
  async function main() {
72
84
  const args = parseArgs(process.argv.slice(2));
85
+ // Self-update check runs before real work, but never for the instant meta
86
+ // commands (help/version) — those must stay fast and side-effect free.
87
+ if (!isMetaCommand(args.command)) {
88
+ await maybeAutoUpdate();
89
+ }
73
90
  switch (args.command) {
74
91
  case "":
75
92
  case "help":
@@ -202,6 +219,15 @@ function readCliPackageVersion() {
202
219
  const pkg = JSON.parse(readFileSync(join(CLI_PACKAGE_ROOT, "package.json"), "utf8"));
203
220
  return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
204
221
  }
222
+ function readCliPackageName() {
223
+ try {
224
+ const pkg = JSON.parse(readFileSync(join(CLI_PACKAGE_ROOT, "package.json"), "utf8"));
225
+ return typeof pkg.name === "string" && pkg.name ? pkg.name : "railcode";
226
+ }
227
+ catch {
228
+ return "railcode";
229
+ }
230
+ }
205
231
  // ---------------------------------------------------------------------------
206
232
  // login
207
233
  // ---------------------------------------------------------------------------
@@ -212,9 +238,12 @@ async function commandLogin(args) {
212
238
  await loginWithSetupToken(args, saved, setupToken);
213
239
  return;
214
240
  }
215
- const apiUrl = await resolveLoginApiUrl(args, saved);
241
+ const apiUrl = resolveLoginApiUrl(args, saved);
242
+ // --paste / --no-browser: skip the localhost callback entirely and go straight
243
+ // to "open this URL, paste the code" — the right default for headless/SSH.
244
+ const pasteMode = args.options.paste === true || args.options.noBrowser === true;
216
245
  const serverConfig = (await readJsonOrThrow(await apiFetch(apiUrl, "/api/config", {}), "Server config"));
217
- const minted = await authorizeCliWithBrowser(apiUrl);
246
+ const minted = await authorizeCliWithBrowser(apiUrl, pasteMode);
218
247
  // Resolve the caller's identity + organization.
219
248
  const me = (await readJsonOrThrow(await apiFetch(apiUrl, "/api/auth/me", {
220
249
  headers: { Authorization: `Bearer ${minted.token}` },
@@ -282,10 +311,16 @@ async function loginWithSetupToken(args, saved, setupToken) {
282
311
  }
283
312
  console.log(`Token ${redeemed.token_prefix}... saved to ${CONFIG_PATH}.`);
284
313
  }
285
- async function authorizeCliWithBrowser(apiUrl) {
314
+ async function authorizeCliWithBrowser(apiUrl, pasteMode) {
286
315
  if (!process.stdin.isTTY) {
287
316
  throw new CliError("Browser login requires a TTY. Set RAILCODE_API_TOKEN in non-interactive environments.", 2);
288
317
  }
318
+ if (pasteMode) {
319
+ return authorizeCliByPaste(apiUrl);
320
+ }
321
+ // Happy path: a localhost callback server catches the code automatically. We
322
+ // also print a code-mode URL so login still completes when the browser is on a
323
+ // different machine and can't reach this box's localhost (the common SSH case).
289
324
  const state = randomBytes(16).toString("hex");
290
325
  const callback = await startCliAuthCallback(state);
291
326
  try {
@@ -293,23 +328,80 @@ async function authorizeCliWithBrowser(apiUrl) {
293
328
  redirect_uri: callback.redirectUri,
294
329
  state,
295
330
  })}`;
296
- console.log("");
297
- printUrlBox("Railcode login", authorizeUrl);
331
+ const manualUrl = `${apiUrl}/api/auth/cli/authorize?${new URLSearchParams({ mode: "code" })}`;
298
332
  console.log("");
299
333
  if (openBrowser(authorizeUrl)) {
300
- console.log("Opening your browser. If it does not open, paste the URL above.");
334
+ console.log("Opening your browser to authorize Railcode.");
301
335
  }
302
336
  else {
303
- console.log("Paste the URL above into your browser to continue.");
337
+ console.log("Open this URL in your browser to authorize Railcode:");
338
+ console.log(` ${authorizeUrl}`);
304
339
  }
305
- console.log("Waiting for browser authorization...");
306
- const code = await callback.code;
340
+ console.log("");
341
+ console.log("On a remote/SSH box? If the browser can't reach this machine, open the");
342
+ console.log("URL below to get a code, then paste it here:");
343
+ console.log("");
344
+ printUrlBox("Railcode login", manualUrl);
345
+ console.log("");
346
+ const code = await waitForCallbackOrPastedCode(callback.code);
307
347
  return await exchangeCliAuthCode(apiUrl, code);
308
348
  }
309
349
  finally {
310
350
  await callback.close();
311
351
  }
312
352
  }
353
+ // Paste-only flow (--paste / --no-browser): no localhost server, no waiting on a
354
+ // callback. Show the code-mode URL and read the pasted code back.
355
+ async function authorizeCliByPaste(apiUrl) {
356
+ const manualUrl = `${apiUrl}/api/auth/cli/authorize?${new URLSearchParams({ mode: "code" })}`;
357
+ console.log("");
358
+ printUrlBox("Railcode login", manualUrl);
359
+ console.log("");
360
+ if (openBrowser(manualUrl)) {
361
+ console.log("Opening your browser. If it does not open, paste the URL above.");
362
+ }
363
+ else {
364
+ console.log("Paste the URL above into your browser to continue.");
365
+ }
366
+ console.log("");
367
+ const code = (await prompt("Paste the code from your browser: ")).trim();
368
+ if (!code) {
369
+ throw new CliError("No code entered.", 1);
370
+ }
371
+ return exchangeCliAuthCode(apiUrl, code);
372
+ }
373
+ // Race the localhost callback against a pasted line — whichever yields a code
374
+ // first wins. An empty line means "keep waiting", so we re-prompt. When the
375
+ // callback wins, the abort signal cancels the pending readline question.
376
+ async function waitForCallbackOrPastedCode(callbackCode) {
377
+ const controller = new AbortController();
378
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
379
+ const fromCallback = callbackCode.then((code) => {
380
+ controller.abort();
381
+ return code;
382
+ }, (error) => {
383
+ controller.abort();
384
+ throw error;
385
+ });
386
+ const fromPaste = (async () => {
387
+ for (;;) {
388
+ const answer = (await rl.question("Paste the code from your browser (or press Enter to keep waiting): ", {
389
+ signal: controller.signal,
390
+ })).trim();
391
+ if (answer)
392
+ return answer;
393
+ }
394
+ })();
395
+ try {
396
+ return await Promise.race([fromCallback, fromPaste]);
397
+ }
398
+ finally {
399
+ controller.abort();
400
+ rl.close();
401
+ fromCallback.catch(() => { });
402
+ fromPaste.catch(() => { });
403
+ }
404
+ }
313
405
  async function startCliAuthCallback(expectedState) {
314
406
  let resolveCode = () => { };
315
407
  let rejectCode = () => { };
@@ -443,13 +535,12 @@ function escapeHtml(value) {
443
535
  .replaceAll('"', "&quot;")
444
536
  .replaceAll("'", "&#39;");
445
537
  }
446
- async function resolveLoginApiUrl(args, saved) {
447
- const explicit = optionApiUrl(args) || process.env.RAILCODE_API_URL;
448
- if (explicit)
449
- return normalizeApiOrigin(explicit);
450
- const fallback = saved.apiUrl || DEFAULT_API_URL;
451
- const answer = await prompt(`Server URL [${fallback}]: `);
452
- return normalizeApiOrigin(answer.trim() || fallback);
538
+ // Resolve the API URL non-interactively — no prompt. Precedence:
539
+ // --api-url flag RAILCODE_API_URL env → saved config → DEFAULT_API_URL.
540
+ // The default "just works"; override it only with --api-url (env/saved kept for
541
+ // continuity). Mirrors the setup-token path's resolution in loginWithSetupToken.
542
+ function resolveLoginApiUrl(args, saved) {
543
+ return normalizeApiOrigin(optionApiUrl(args) || process.env.RAILCODE_API_URL || saved.apiUrl || DEFAULT_API_URL);
453
544
  }
454
545
  // ---------------------------------------------------------------------------
455
546
  // deploy
@@ -700,12 +791,19 @@ function packageBuildCommand(root) {
700
791
  async function commandInit(args) {
701
792
  const app = args.rest[0];
702
793
  if (!app)
703
- throw new CliError("Usage: railcode init <app>", 2);
794
+ throw new CliError("Usage: railcode init <app> [dir]", 2);
704
795
  assertAppName(app);
705
796
  const template = initTemplate(args);
706
- const target = resolve(process.cwd(), app);
707
- if (existsSync(target) && readdirSync(target).length > 0) {
708
- throw new CliError(`Refusing to scaffold into a non-empty directory: ${target}`, 1);
797
+ // Optional second positional: the directory to scaffold into. Defaults to a
798
+ // new "<app>/" subdir. Passing "." scaffolds into the current directory. The
799
+ // target need not be empty same-named files are overwritten.
800
+ const dir = args.rest[1];
801
+ const target = resolve(process.cwd(), dir ?? app);
802
+ // Light-touch guard: never clobber an existing app manifest without --force.
803
+ const manifestPath = join(target, MANIFEST_NAME);
804
+ if (existsSync(manifestPath) && !args.options.force) {
805
+ throw new CliError(`A ${MANIFEST_NAME} already exists in ${relativeLabel(process.cwd(), target)}/. ` +
806
+ "Re-run with --force to overwrite it.", 1);
709
807
  }
710
808
  mkdirSync(target, { recursive: true });
711
809
  if (template === "static") {
@@ -716,7 +814,10 @@ async function commandInit(args) {
716
814
  }
717
815
  console.log(`Created ${template} Railcode app "${app}" in ${relativeLabel(process.cwd(), target)}/`);
718
816
  console.log("Next:");
719
- console.log(` cd ${app}`);
817
+ // Only prompt a `cd` when the files landed somewhere other than the cwd.
818
+ if (resolve(target) !== resolve(process.cwd())) {
819
+ console.log(` cd ${relativeLabel(process.cwd(), target)}`);
820
+ }
720
821
  if (template === "react") {
721
822
  console.log(" npm install");
722
823
  }
@@ -970,7 +1071,6 @@ type LlmOptions = {
970
1071
  provider?: string;
971
1072
  system?: string;
972
1073
  output?: LlmOutputSpec;
973
- temperature?: number;
974
1074
  maxOutputTokens?: number;
975
1075
  metadata?: Record<string, unknown>;
976
1076
  };
@@ -3794,8 +3894,8 @@ Usage:
3794
3894
  railcode agent show <agent> Show one agent
3795
3895
  railcode agent pull <agent> [--output <path>]
3796
3896
  Write the agent manifest as JSON
3797
- railcode agent create --file <path> Create an agent from a JSON manifest
3798
- railcode agent update <agent> --file <path> Update an agent from a JSON manifest
3897
+ railcode agent create --file <path> Create an agent from a JSON or YAML manifest
3898
+ railcode agent update <agent> --file <path> Update an agent from a JSON or YAML manifest
3799
3899
  railcode agent delete <agent> [--yes] Delete/archive an agent
3800
3900
  railcode agent test --file <path> [opts] Run a draft manifest without saving
3801
3901
  railcode agent run <agent> [opts] Trigger a saved agent and print results
@@ -3803,7 +3903,7 @@ Usage:
3803
3903
 
3804
3904
  Agent options:
3805
3905
  --json Print the raw API response
3806
- --file <path> JSON manifest file
3906
+ --file <path> JSON or YAML manifest file
3807
3907
  --output <path> Output path for pull (default stdout)
3808
3908
  --input '<json>' Invoke/test input JSON (default null)
3809
3909
  --input-file <path> Read invoke/test input JSON from a file
@@ -3823,7 +3923,7 @@ Schedule options:
3823
3923
  --timezone <iana> IANA timezone (default: UTC on create)
3824
3924
  --enabled / --disabled Initial or updated enabled state
3825
3925
 
3826
- Manifests are JSON files in the same shape the API stores under agent.manifest.
3926
+ Manifests are JSON or YAML files in the same shape the API stores under agent.manifest.
3827
3927
  Schedules are currently one per agent, so "set" creates the schedule if missing
3828
3928
  or updates the existing one.
3829
3929
  `;
@@ -4185,8 +4285,9 @@ async function requireSingleSchedule(config, ref) {
4185
4285
  function readAgentManifest(args) {
4186
4286
  const path = optionString(args, "file");
4187
4287
  if (!path)
4188
- throw new CliError("Pass --file <agent-manifest.json>.", 2);
4189
- return readJsonObjectFile(path, "Agent manifest");
4288
+ throw new CliError("Pass --file <agent-manifest.json|yaml|yml>.", 2);
4289
+ const format = /\.ya?ml$/i.test(path) ? "YAML" : "JSON";
4290
+ return readObjectFile(path, "Agent manifest", format);
4190
4291
  }
4191
4292
  // Omitted means "use the server default" on create/test (org) and "leave alone"
4192
4293
  // on update — never send the key unless the caller passed it explicitly.
@@ -4200,18 +4301,22 @@ function agentVisibilityOption(args) {
4200
4301
  return value;
4201
4302
  }
4202
4303
  function readJsonObjectFile(path, label) {
4304
+ return readObjectFile(path, label, "JSON");
4305
+ }
4306
+ function readObjectFile(path, label, format) {
4203
4307
  const resolved = resolve(process.cwd(), path);
4204
4308
  if (!existsSync(resolved))
4205
4309
  throw new CliError(`${label} file not found: ${path}`, 2);
4206
4310
  let parsed;
4207
4311
  try {
4208
- parsed = JSON.parse(readFileSync(resolved, "utf8"));
4312
+ const raw = readFileSync(resolved, "utf8");
4313
+ parsed = format === "YAML" ? parseYaml(raw) : JSON.parse(raw);
4209
4314
  }
4210
4315
  catch (error) {
4211
- throw new CliError(`${label} file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, 2);
4316
+ throw new CliError(`${label} file is not valid ${format}: ${error instanceof Error ? error.message : String(error)}`, 2);
4212
4317
  }
4213
4318
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4214
- throw new CliError(`${label} file must contain a JSON object.`, 2);
4319
+ throw new CliError(`${label} file must contain a ${format} object.`, 2);
4215
4320
  }
4216
4321
  return parsed;
4217
4322
  }
@@ -5148,7 +5253,9 @@ const PC_HELP = `railcode personal-connectors — your own connected accounts (G
5148
5253
  Usage:
5149
5254
  railcode personal-connectors list Toolkits this deployment brokers + your status
5150
5255
  railcode personal-connectors tools <toolkit> The callable tools of a toolkit (and their inputs)
5151
- railcode personal-connectors connect <toolkit> Print the OAuth URL to connect it (open in a browser)
5256
+ railcode personal-connectors connect <toolkit> Start connecting it: prints the OAuth URL, or
5257
+ reports if it needs a token
5258
+ railcode personal-connectors connect <toolkit> --token '<value>' Link a token-auth connector (e.g. a GitHub PAT)
5152
5259
  railcode personal-connectors call <toolkit> <tool> [--args '<json>'] Run one tool on your own account
5153
5260
 
5154
5261
  Aliases: personal-connector, pc
@@ -5159,6 +5266,7 @@ input schema, so you know what to put in an app's \`personal_connectors:\` and w
5159
5266
 
5160
5267
  Options:
5161
5268
  --json Print raw JSON instead of a table
5269
+ --token '<value>' Token/key for \`connect\` on a token-auth connector
5162
5270
  --args '<json>' Arguments object for \`call\` (default {})
5163
5271
  `;
5164
5272
  async function commandPersonalConnectors(args) {
@@ -5237,13 +5345,31 @@ async function commandPcTools(args) {
5237
5345
  console.log(`\n${tools.length} tool(s). Add \`--json\` to see each tool's input schema.`);
5238
5346
  }
5239
5347
  async function commandPcConnect(args) {
5240
- rejectUnknownOptions(args, ["json", "apiUrl"], "personal-connectors connect", PC_HELP);
5348
+ rejectUnknownOptions(args, ["json", "token", "apiUrl"], "personal-connectors connect", PC_HELP);
5241
5349
  const toolkit = args.rest[1];
5242
5350
  if (!toolkit)
5243
5351
  throw new CliError("Usage: railcode personal-connectors connect <toolkit>", 2);
5244
5352
  const config = requireLogin(args);
5245
- // Connecting is browser-bound OAuth; the CLI can't complete it. It prints the
5246
- // URL and leaves opening it to you — the same handoff `railcode login` uses.
5353
+ // A token-auth connector (e.g. a GitHub PAT): `--token <value>` submits it
5354
+ // directly, otherwise we tell the caller a token is needed.
5355
+ if (typeof args.options.token === "string") {
5356
+ const tokResp = await authedFetch(config, `/api/personal-connections/${encodeURIComponent(toolkit)}/token`, {
5357
+ method: "POST",
5358
+ headers: { "Content-Type": "application/json" },
5359
+ body: JSON.stringify({ token: args.options.token }),
5360
+ });
5361
+ if (tokResp.status === 401)
5362
+ await reLoginOrThrow();
5363
+ if (pcUnavailable(tokResp.status)) {
5364
+ console.log("Personal connectors are not enabled on this deployment.");
5365
+ return;
5366
+ }
5367
+ await readJsonOrThrow(tokResp, `Connect ${toolkit}`);
5368
+ console.log(`Connected ${toolkit}.`);
5369
+ return;
5370
+ }
5371
+ // Otherwise start the flow. OAuth is browser-bound (the CLI can't complete it),
5372
+ // so it prints the URL; a token connector reports that a token is required.
5247
5373
  const resp = await authedFetch(config, `/api/personal-connections/${encodeURIComponent(toolkit)}/connect`, { method: "POST" });
5248
5374
  if (resp.status === 401)
5249
5375
  await reLoginOrThrow();
@@ -5256,6 +5382,11 @@ async function commandPcConnect(args) {
5256
5382
  console.log(JSON.stringify(body, null, 2));
5257
5383
  return;
5258
5384
  }
5385
+ if (body.mode === "token") {
5386
+ console.log(`${toolkit} connects with a ${body.token_label ?? "token"}. ` +
5387
+ `Re-run with --token '<value>' to link it:\n\n railcode personal-connectors connect ${toolkit} --token '<value>'\n`);
5388
+ return;
5389
+ }
5259
5390
  console.log(`Open this URL to connect ${toolkit}, then re-run your command:\n\n ${body.redirect_url}\n`);
5260
5391
  }
5261
5392
  async function commandPcCall(args) {
@@ -5338,8 +5469,14 @@ function jsonBody(body) {
5338
5469
  async function fetchMembers(config) {
5339
5470
  return (await authedJson(config, `/api/organizations/${config.orgUuid}/members`, "List members"));
5340
5471
  }
5341
- async function fetchApps(config) {
5342
- return (await authedJson(config, `/api/organizations/${config.orgUuid}/apps`, "List apps"));
5472
+ // `archived` mirrors the API's ?archived= filter. The default is "all", NOT the
5473
+ // API's own "false": nearly every caller here is resolving a <app> reference (or
5474
+ // mapping uuid→slug), and archiving is supposed to take nothing away — an
5475
+ // archived app must still be addressable by `apps show/access/transfer/archive/
5476
+ // delete`, `analytics`, and the storage commands. `apps list` is the one caller
5477
+ // that actually filters, and it says so explicitly.
5478
+ async function fetchApps(config, archived = "all") {
5479
+ return (await authedJson(config, `/api/organizations/${config.orgUuid}/apps?archived=${archived}`, "List apps"));
5343
5480
  }
5344
5481
  async function fetchPermissions(config) {
5345
5482
  return (await authedJson(config, `/api/organizations/${config.orgUuid}/permissions`, "List roles + grants"));
@@ -5712,21 +5849,84 @@ async function commandRolesCatalog(args) {
5712
5849
  const APPS_HELP = `railcode apps — apps + access policy
5713
5850
 
5714
5851
  Usage:
5715
- railcode apps list List apps you can see
5852
+ railcode apps list [--archived|--all] List apps you can see
5716
5853
  railcode apps show <app> Show one app's details
5717
5854
  railcode apps access <app> Show an app's access policy + grants
5718
5855
  railcode apps set-access <app> --mode organization|private|restricted
5719
5856
  [--members <email,email>] Set the policy (members: restricted only)
5720
5857
  railcode apps transfer <app> --to <email|uuid> Transfer ownership
5858
+ railcode apps archive <app> Hide an app from the launcher
5859
+ railcode apps unarchive <app> Restore an archived app
5721
5860
  railcode apps delete <app> [--yes] Delete an app (deploys + data, irreversible)
5722
5861
 
5723
- <app> is an app slug or UUID. set-access/transfer/delete need manage rights
5862
+ railcode app kv ... Manage an app's KV store (owner)
5863
+ railcode app files ... Manage an app's files (owner)
5864
+
5865
+ <app> is an app slug or UUID. set-access/transfer/archive/delete need manage rights
5724
5866
  (owner or org admin). delete prompts for confirmation on a TTY; pass --yes to skip
5725
5867
  (required in non-interactive sessions).
5726
5868
 
5869
+ Archiving is INERT and reversible, so it never prompts: an archived app keeps
5870
+ serving at its host, keeps its slug, keeps its data, and keeps running its agents
5871
+ — it just drops out of the launcher. list hides archived apps by default;
5872
+ --archived lists only those, --all lists both.
5873
+
5727
5874
  Options:
5875
+ --archived (list) Only archived apps
5876
+ --all (list) Archived and live apps
5728
5877
  --json Print raw JSON
5729
5878
  --yes Skip the delete confirmation prompt
5879
+
5880
+ Run \`railcode app kv\` or \`railcode app files\` for the storage sub-help.
5881
+ `;
5882
+ // The storage sub-commands manage an app's stored data. They require APP OWNERSHIP
5883
+ // (owner grant, or an org admin holding app:manage_any) — a plain member with app
5884
+ // access is cleanly rejected. The app is chosen with --app <slug|uuid>, or resolved
5885
+ // from the railcode.json in the current directory (like deploy).
5886
+ const APP_KV_HELP = `railcode app kv — manage an app's KV store (owner only)
5887
+
5888
+ Usage:
5889
+ railcode app kv collections List collections + record counts
5890
+ railcode app kv list <collection> List/query records in a collection
5891
+ railcode app kv get <collection> <key> Print one record's JSON value
5892
+ railcode app kv set <collection> <key> <json> Create/update a record (or --file)
5893
+ railcode app kv delete <collection> <key> Delete one record
5894
+ railcode app kv drop <collection> [--yes] Delete every record in a collection
5895
+
5896
+ App: --app <slug|uuid> (else resolved from ./railcode.json, like deploy)
5897
+
5898
+ Scope (default shared):
5899
+ --scope shared|user|role|all Namespace to act on (all = enumerate every scope,
5900
+ listings only, with owner attribution)
5901
+ --user <member-uuid> Required for --scope user
5902
+ --role <role-uuid> Required for --scope role
5903
+
5904
+ list options:
5905
+ --query '<json>' A where clause: [[field, op, value], ...]
5906
+ --limit <n> Max records; --offset <n> pages (a multiple of --limit)
5907
+ --count Print the matching record count instead of the rows
5908
+
5909
+ set options: --file <path> Read the JSON value from a file
5910
+ Common: --json Print raw JSON --yes Skip the drop confirmation
5911
+ `;
5912
+ const APP_FILES_HELP = `railcode app files — manage an app's files (owner only)
5913
+
5914
+ Usage:
5915
+ railcode app files list List file metadata
5916
+ railcode app files download <name> [--out p] Download a file (default: ./<name>)
5917
+ railcode app files upload <path> [--name n] Upload a local file
5918
+ railcode app files delete <name> Delete a file
5919
+
5920
+ App: --app <slug|uuid> (else resolved from ./railcode.json, like deploy)
5921
+
5922
+ Scope (default shared): --scope shared|user|role|all, --user <uuid>, --role <uuid>
5923
+ (all lists across every scope with owner attribution; upload/download/delete target
5924
+ one scope).
5925
+
5926
+ Options:
5927
+ --out <path> (download) Where to write the bytes
5928
+ --name <name> (upload) The remote name (default: the local basename)
5929
+ --json Print raw JSON (list)
5730
5930
  `;
5731
5931
  async function commandApps(args) {
5732
5932
  try {
@@ -5749,10 +5949,23 @@ async function commandApps(args) {
5749
5949
  case "transfer":
5750
5950
  await commandAppsTransfer(args);
5751
5951
  return;
5952
+ case "archive":
5953
+ await commandAppsArchive(args, true);
5954
+ return;
5955
+ case "unarchive":
5956
+ await commandAppsArchive(args, false);
5957
+ return;
5752
5958
  case "delete":
5753
5959
  case "rm":
5754
5960
  await commandAppsDelete(args);
5755
5961
  return;
5962
+ case "kv":
5963
+ await commandAppKv(args);
5964
+ return;
5965
+ case "files":
5966
+ case "file":
5967
+ await commandAppFiles(args);
5968
+ return;
5756
5969
  case "":
5757
5970
  case "help":
5758
5971
  console.log(APPS_HELP);
@@ -5762,25 +5975,396 @@ async function commandApps(args) {
5762
5975
  }
5763
5976
  }
5764
5977
  catch (error) {
5765
- if (error instanceof OrgAdminError)
5978
+ if (error instanceof OrgAdminError || error instanceof StorageError) {
5766
5979
  throw new CliError(error.message, 2);
5980
+ }
5767
5981
  throw error;
5768
5982
  }
5769
5983
  }
5984
+ // ── app storage: KV + files (owner only) ─────────────────────────────────────
5985
+ //
5986
+ // Both groups resolve the target app (--app or ./railcode.json) then hit the
5987
+ // owner-facing management plane at
5988
+ // `/api/organizations/{org}/apps/{app}/storage/{kv,files}/*`. Non-owners get the
5989
+ // server's plain "requires app ownership" 403.
5990
+ function storageBase(config, app) {
5991
+ return `/api/organizations/${config.orgUuid}/apps/${app.uuid}/storage`;
5992
+ }
5993
+ async function resolveStorageApp(config, args) {
5994
+ const ref = optionString(args, "app") ?? readManifest(process.cwd())?.app;
5995
+ if (!ref) {
5996
+ throw new StorageError("No app: pass --app <slug|uuid>, or run inside an app directory (railcode.json).");
5997
+ }
5998
+ return resolveAppRef(await fetchApps(config), ref);
5999
+ }
6000
+ // The shared scope flags, off the parsed args. `allowAll` gates `--scope all`.
6001
+ function storageScope(args, allowAll) {
6002
+ return parseScopeSelector(optionString(args, "scope"), optionString(args, "user"), optionString(args, "role"), { allowAll });
6003
+ }
6004
+ function intOption(args, key) {
6005
+ const raw = optionString(args, key);
6006
+ if (raw === undefined)
6007
+ return undefined;
6008
+ const n = Number(raw);
6009
+ if (!Number.isFinite(n))
6010
+ throw new StorageError(`--${camelToKebab(key)} must be a number.`);
6011
+ return n;
6012
+ }
6013
+ async function commandAppKv(args) {
6014
+ const sub = args.rest[1] ?? "";
6015
+ switch (sub) {
6016
+ case "collections":
6017
+ case "cols":
6018
+ await commandAppKvCollections(args);
6019
+ return;
6020
+ case "list":
6021
+ case "ls":
6022
+ await commandAppKvList(args);
6023
+ return;
6024
+ case "get":
6025
+ await commandAppKvGet(args);
6026
+ return;
6027
+ case "set":
6028
+ case "put":
6029
+ await commandAppKvSet(args);
6030
+ return;
6031
+ case "delete":
6032
+ case "rm":
6033
+ await commandAppKvDelete(args);
6034
+ return;
6035
+ case "drop":
6036
+ await commandAppKvDrop(args);
6037
+ return;
6038
+ case "":
6039
+ case "help":
6040
+ console.log(APP_KV_HELP);
6041
+ return;
6042
+ default:
6043
+ throw new StorageError(`Unknown \`app kv\` command: ${sub}\n\n${APP_KV_HELP}`);
6044
+ }
6045
+ }
6046
+ async function commandAppKvCollections(args) {
6047
+ rejectUnknownOptions(args, ["app", "scope", "user", "role", "json", "apiUrl"], "app kv", APP_KV_HELP);
6048
+ const scope = storageScope(args, true);
6049
+ const config = requireLogin(args);
6050
+ const app = await resolveStorageApp(config, args);
6051
+ const body = (await authedJson(config, `${storageBase(config, app)}/kv?${scopeQuery(scope)}`, "List collections"));
6052
+ if (args.options.json) {
6053
+ console.log(JSON.stringify(body.items, null, 2));
6054
+ return;
6055
+ }
6056
+ if (body.items.length === 0) {
6057
+ console.log("No KV collections in this scope.");
6058
+ return;
6059
+ }
6060
+ console.log(formatTable(["scope", "owner", "collection", "count"], body.items.map((c) => [c.scope, ownerCell(c.scope, c.owner_label), c.collection, c.count])));
6061
+ }
6062
+ async function commandAppKvList(args) {
6063
+ rejectUnknownOptions(args, ["app", "scope", "user", "role", "query", "limit", "offset", "count", "json", "apiUrl"], "app kv", APP_KV_HELP);
6064
+ const collection = args.rest[2];
6065
+ if (!collection)
6066
+ throw new StorageError("Usage: railcode app kv list <collection>");
6067
+ const scope = storageScope(args, true);
6068
+ const where = parseWhere(optionString(args, "query"));
6069
+ const paging = pageParams(intOption(args, "limit"), intOption(args, "offset"));
6070
+ const config = requireLogin(args);
6071
+ const app = await resolveStorageApp(config, args);
6072
+ const params = new URLSearchParams(scopeQuery(scope));
6073
+ if (where !== undefined)
6074
+ params.set("where", where);
6075
+ if (paging.page !== undefined)
6076
+ params.set("page", String(paging.page));
6077
+ if (paging.size !== undefined)
6078
+ params.set("size", String(paging.size));
6079
+ if (args.options.count)
6080
+ params.set("count", "true");
6081
+ const path = `${storageBase(config, app)}/kv/${encodeURIComponent(collection)}?${params}`;
6082
+ const body = (await authedJson(config, path, "List records"));
6083
+ if (args.options.count) {
6084
+ const count = body.count;
6085
+ if (args.options.json) {
6086
+ console.log(JSON.stringify({ count }, null, 2));
6087
+ return;
6088
+ }
6089
+ console.log(String(count));
6090
+ return;
6091
+ }
6092
+ const items = body.items;
6093
+ if (args.options.json) {
6094
+ console.log(JSON.stringify(items, null, 2));
6095
+ return;
6096
+ }
6097
+ if (items.length === 0) {
6098
+ console.log("No records.");
6099
+ return;
6100
+ }
6101
+ const crossScope = scope.scope === "all";
6102
+ const columns = crossScope ? ["scope", "owner", "key", "value"] : ["key", "value"];
6103
+ console.log(formatTable(columns, items.map((r) => crossScope
6104
+ ? [r.scope, ownerCell(r.scope, r.owner_label), r.key, JSON.stringify(r.value)]
6105
+ : [r.key, JSON.stringify(r.value)])));
6106
+ }
6107
+ async function commandAppKvGet(args) {
6108
+ rejectUnknownOptions(args, ["app", "scope", "user", "role", "json", "apiUrl"], "app kv", APP_KV_HELP);
6109
+ const collection = args.rest[2];
6110
+ const key = args.rest[3];
6111
+ if (!collection || !key)
6112
+ throw new StorageError("Usage: railcode app kv get <collection> <key>");
6113
+ const scope = storageScope(args, false);
6114
+ const config = requireLogin(args);
6115
+ const app = await resolveStorageApp(config, args);
6116
+ const record = (await authedJson(config, `${storageBase(config, app)}/kv/${encodeURIComponent(collection)}/${encodeFilePath(key)}?${scopeQuery(scope)}`, "Get record"));
6117
+ if (args.options.json) {
6118
+ console.log(JSON.stringify(record, null, 2));
6119
+ return;
6120
+ }
6121
+ // The value is the payload — print it as pretty JSON so it pipes cleanly.
6122
+ console.log(JSON.stringify(record.value, null, 2));
6123
+ }
6124
+ async function commandAppKvSet(args) {
6125
+ rejectUnknownOptions(args, ["app", "scope", "user", "role", "file", "json", "apiUrl"], "app kv", APP_KV_HELP);
6126
+ const collection = args.rest[2];
6127
+ const key = args.rest[3];
6128
+ if (!collection || !key) {
6129
+ throw new StorageError("Usage: railcode app kv set <collection> <key> <json|--file path>");
6130
+ }
6131
+ const filePath = optionString(args, "file");
6132
+ const fileText = filePath !== undefined ? readValueFile(filePath) : undefined;
6133
+ const value = parseKvValue(args.rest[4], fileText);
6134
+ const scope = storageScope(args, false);
6135
+ const config = requireLogin(args);
6136
+ const app = await resolveStorageApp(config, args);
6137
+ const record = (await authedJson(config, `${storageBase(config, app)}/kv/${encodeURIComponent(collection)}/${encodeFilePath(key)}?${scopeQuery(scope)}`, "Set record", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ value }) }));
6138
+ if (args.options.json) {
6139
+ console.log(JSON.stringify(record, null, 2));
6140
+ return;
6141
+ }
6142
+ console.log(`Set ${collection}/${key} (scope: ${scope.scope}).`);
6143
+ }
6144
+ async function commandAppKvDelete(args) {
6145
+ rejectUnknownOptions(args, ["app", "scope", "user", "role", "apiUrl"], "app kv", APP_KV_HELP);
6146
+ const collection = args.rest[2];
6147
+ const key = args.rest[3];
6148
+ if (!collection || !key) {
6149
+ throw new StorageError("Usage: railcode app kv delete <collection> <key>");
6150
+ }
6151
+ const scope = storageScope(args, false);
6152
+ const config = requireLogin(args);
6153
+ const app = await resolveStorageApp(config, args);
6154
+ await authedNoContent(config, `${storageBase(config, app)}/kv/${encodeURIComponent(collection)}/${encodeFilePath(key)}?${scopeQuery(scope)}`, "Delete record", { method: "DELETE" });
6155
+ console.log(`Deleted ${collection}/${key} (scope: ${scope.scope}).`);
6156
+ }
6157
+ async function commandAppKvDrop(args) {
6158
+ rejectUnknownOptions(args, ["app", "scope", "user", "role", "yes", "json", "apiUrl"], "app kv", APP_KV_HELP);
6159
+ const collection = args.rest[2];
6160
+ if (!collection)
6161
+ throw new StorageError("Usage: railcode app kv drop <collection>");
6162
+ const scope = storageScope(args, false);
6163
+ const config = requireLogin(args);
6164
+ const app = await resolveStorageApp(config, args);
6165
+ if (args.options.yes !== true) {
6166
+ if (!process.stdin.isTTY) {
6167
+ throw new CliError("Pass --yes to drop a collection in a non-interactive session.", 2);
6168
+ }
6169
+ const answer = await prompt(`Drop every record in "${collection}" (scope: ${scope.scope}) of ${app.app_slug}? [y/N] `);
6170
+ if (!["y", "yes"].includes(answer.trim().toLowerCase())) {
6171
+ throw new CliError("Drop cancelled.", 1);
6172
+ }
6173
+ }
6174
+ const result = (await authedJson(config, `${storageBase(config, app)}/kv/${encodeURIComponent(collection)}?${scopeQuery(scope)}`, "Drop collection", { method: "DELETE" }));
6175
+ if (args.options.json) {
6176
+ console.log(JSON.stringify(result, null, 2));
6177
+ return;
6178
+ }
6179
+ console.log(`Dropped ${result.deleted} record(s) from "${collection}".`);
6180
+ }
6181
+ async function commandAppFiles(args) {
6182
+ const sub = args.rest[1] ?? "";
6183
+ switch (sub) {
6184
+ case "list":
6185
+ case "ls":
6186
+ await commandAppFilesList(args);
6187
+ return;
6188
+ case "download":
6189
+ case "get":
6190
+ await commandAppFilesDownload(args);
6191
+ return;
6192
+ case "upload":
6193
+ case "put":
6194
+ await commandAppFilesUpload(args);
6195
+ return;
6196
+ case "delete":
6197
+ case "rm":
6198
+ await commandAppFilesDelete(args);
6199
+ return;
6200
+ case "":
6201
+ case "help":
6202
+ console.log(APP_FILES_HELP);
6203
+ return;
6204
+ default:
6205
+ throw new StorageError(`Unknown \`app files\` command: ${sub}\n\n${APP_FILES_HELP}`);
6206
+ }
6207
+ }
6208
+ async function commandAppFilesList(args) {
6209
+ rejectUnknownOptions(args, ["app", "scope", "user", "role", "json", "apiUrl"], "app files", APP_FILES_HELP);
6210
+ const scope = storageScope(args, true);
6211
+ const config = requireLogin(args);
6212
+ const app = await resolveStorageApp(config, args);
6213
+ const body = (await authedJson(config, `${storageBase(config, app)}/files?${scopeQuery(scope)}`, "List files"));
6214
+ if (args.options.json) {
6215
+ console.log(JSON.stringify(body.items, null, 2));
6216
+ return;
6217
+ }
6218
+ if (body.items.length === 0) {
6219
+ console.log("No files in this scope.");
6220
+ return;
6221
+ }
6222
+ const crossScope = scope.scope === "all";
6223
+ const columns = crossScope
6224
+ ? ["scope", "owner", "name", "size", "type", "updated"]
6225
+ : ["name", "size", "type", "updated"];
6226
+ console.log(formatTable(columns, body.items.map((f) => crossScope
6227
+ ? [f.scope, ownerCell(f.scope, f.owner_label), f.name, f.size, f.content_type, f.updated_at]
6228
+ : [f.name, f.size, f.content_type, f.updated_at])));
6229
+ }
6230
+ async function commandAppFilesDownload(args) {
6231
+ rejectUnknownOptions(args, ["app", "scope", "user", "role", "out", "apiUrl"], "app files", APP_FILES_HELP);
6232
+ const name = args.rest[2];
6233
+ if (!name)
6234
+ throw new StorageError("Usage: railcode app files download <name> [--out path]");
6235
+ const scope = storageScope(args, false);
6236
+ const outPath = optionString(args, "out") ?? basename(name);
6237
+ const config = requireLogin(args);
6238
+ const app = await resolveStorageApp(config, args);
6239
+ const url = `${storageBase(config, app)}/files/${encodeFilePath(name)}?${scopeQuery(scope)}`;
6240
+ const resp = await authedFetch(config, url);
6241
+ if (resp.status === 401)
6242
+ await reLoginOrThrow();
6243
+ let bytes;
6244
+ if ([301, 302, 303, 307, 308].includes(resp.status)) {
6245
+ // S3 backend: the body lives at a short-lived presigned URL (no auth header).
6246
+ const location = resp.headers.get("location");
6247
+ if (!location)
6248
+ throw new CliError("File download redirect had no location.", 1);
6249
+ const signed = await fetch(location);
6250
+ if (!signed.ok)
6251
+ throw new CliError(`File download failed (${signed.status}).`, 1);
6252
+ bytes = await signed.arrayBuffer();
6253
+ }
6254
+ else if (resp.ok) {
6255
+ bytes = await resp.arrayBuffer();
6256
+ }
6257
+ else {
6258
+ throw new CliError(`File download failed (${resp.status}): ${await errorDetail(resp)}`, 1);
6259
+ }
6260
+ writeFileSync(resolve(process.cwd(), outPath), Buffer.from(bytes));
6261
+ console.log(`Downloaded ${name} → ${outPath} (${bytes.byteLength} bytes).`);
6262
+ }
6263
+ async function commandAppFilesUpload(args) {
6264
+ rejectUnknownOptions(args, ["app", "scope", "user", "role", "name", "json", "apiUrl"], "app files", APP_FILES_HELP);
6265
+ const localPath = args.rest[2];
6266
+ if (!localPath)
6267
+ throw new StorageError("Usage: railcode app files upload <local-path> [--name n]");
6268
+ const resolvedLocal = resolve(process.cwd(), localPath);
6269
+ if (!existsSync(resolvedLocal) || !statSync(resolvedLocal).isFile()) {
6270
+ throw new StorageError(`Not a file: ${localPath}`);
6271
+ }
6272
+ const remoteName = optionString(args, "name") ?? basename(localPath);
6273
+ const data = readFileSync(resolvedLocal);
6274
+ const contentType = guessContentType(remoteName);
6275
+ const scope = storageScope(args, false);
6276
+ const config = requireLogin(args);
6277
+ const app = await resolveStorageApp(config, args);
6278
+ const base = storageBase(config, app);
6279
+ const scopeQs = scopeQuery(scope);
6280
+ // Negotiate the upload — the same two-mode flow the SDK uses.
6281
+ const target = (await authedJson(config, `${base}/files?${scopeQs}`, "Negotiate upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: remoteName, content_type: contentType }) }));
6282
+ let meta;
6283
+ if (target.mode === "presigned") {
6284
+ // S3: PUT the bytes straight to the presigned URL, then finalize the row.
6285
+ const put = await fetch(target.url, {
6286
+ method: target.method,
6287
+ headers: target.headers,
6288
+ body: data,
6289
+ });
6290
+ if (!put.ok)
6291
+ throw new CliError(`Upload to storage failed (${put.status}).`, 1);
6292
+ meta = (await authedJson(config, `${base}/files/finalize?${scopeQs}`, "Finalize upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: remoteName }) }));
6293
+ }
6294
+ else {
6295
+ // Local: PUT the bytes to this plane's proxy blob endpoint (records the row).
6296
+ const resp = await authedFetch(config, target.url, {
6297
+ method: target.method,
6298
+ headers: { "Content-Type": contentType },
6299
+ body: data,
6300
+ });
6301
+ if (resp.status === 401)
6302
+ await reLoginOrThrow();
6303
+ meta = (await readJsonOrThrow(resp, "Upload file"));
6304
+ }
6305
+ if (args.options.json) {
6306
+ console.log(JSON.stringify(meta, null, 2));
6307
+ return;
6308
+ }
6309
+ console.log(`Uploaded ${remoteName} (${meta.size} bytes, scope: ${scope.scope}).`);
6310
+ }
6311
+ async function commandAppFilesDelete(args) {
6312
+ rejectUnknownOptions(args, ["app", "scope", "user", "role", "apiUrl"], "app files", APP_FILES_HELP);
6313
+ const name = args.rest[2];
6314
+ if (!name)
6315
+ throw new StorageError("Usage: railcode app files delete <name>");
6316
+ const scope = storageScope(args, false);
6317
+ const config = requireLogin(args);
6318
+ const app = await resolveStorageApp(config, args);
6319
+ await authedNoContent(config, `${storageBase(config, app)}/files/${encodeFilePath(name)}?${scopeQuery(scope)}`, "Delete file", { method: "DELETE" });
6320
+ console.log(`Deleted ${name} (scope: ${scope.scope}).`);
6321
+ }
6322
+ function readValueFile(path) {
6323
+ const resolved = resolve(process.cwd(), path);
6324
+ if (!existsSync(resolved))
6325
+ throw new StorageError(`Value file not found: ${path}`);
6326
+ return readFileSync(resolved, "utf8");
6327
+ }
5770
6328
  async function commandAppsList(args) {
5771
- rejectUnknownOptions(args, ["json", "apiUrl"], "apps", APPS_HELP);
6329
+ rejectUnknownOptions(args, ["json", "archived", "all", "apiUrl"], "apps", APPS_HELP);
6330
+ if (args.options.archived === true && args.options.all === true) {
6331
+ throw new CliError("Pass either --archived or --all, not both.", 2);
6332
+ }
6333
+ const archived = args.options.all === true ? "all" : args.options.archived === true ? "true" : "false";
5772
6334
  const config = requireLogin(args);
5773
- const apps = await fetchApps(config);
6335
+ const apps = await fetchApps(config, archived);
5774
6336
  if (args.options.json) {
5775
6337
  console.log(JSON.stringify(apps, null, 2));
5776
6338
  return;
5777
6339
  }
5778
6340
  if (apps.length === 0) {
5779
- console.log("No apps yet. Scaffold one: railcode init <app>.");
6341
+ console.log(archived === "true"
6342
+ ? "No archived apps."
6343
+ : "No apps yet. Scaffold one: railcode init <app>.");
5780
6344
  return;
5781
6345
  }
5782
6346
  console.log(appTable(apps));
5783
6347
  }
6348
+ // Archive / unarchive. No confirmation prompt: unlike delete this changes nothing
6349
+ // but the launcher's view of the app — it keeps serving, keeps its slug, keeps its
6350
+ // data — and it is one command to undo.
6351
+ async function commandAppsArchive(args, archive) {
6352
+ const verb = archive ? "archive" : "unarchive";
6353
+ rejectUnknownOptions(args, ["json", "apiUrl"], "apps", APPS_HELP);
6354
+ const ref = args.rest[1];
6355
+ if (!ref)
6356
+ throw new CliError(`Usage: railcode apps ${verb} <app>`, 2);
6357
+ const config = requireLogin(args);
6358
+ const app = resolveAppRef(await fetchApps(config), ref);
6359
+ const updated = (await authedJson(config, `/api/organizations/${config.orgUuid}/apps/${app.uuid}/archive`, archive ? "Archive app" : "Unarchive app", { method: archive ? "POST" : "DELETE" }));
6360
+ if (args.options.json) {
6361
+ console.log(JSON.stringify(updated, null, 2));
6362
+ return;
6363
+ }
6364
+ console.log(archive
6365
+ ? `App "${app.app_slug}" archived — hidden from the launcher, still serving.`
6366
+ : `App "${app.app_slug}" restored to the launcher.`);
6367
+ }
5784
6368
  async function commandAppsShow(args) {
5785
6369
  rejectUnknownOptions(args, ["json", "apiUrl"], "apps", APPS_HELP);
5786
6370
  const ref = args.rest[1];
@@ -5801,6 +6385,8 @@ async function commandAppsShow(args) {
5801
6385
  console.log(row("your role", full.your_role ?? "-"));
5802
6386
  console.log(row("can manage", full.can_manage ? "yes" : "no"));
5803
6387
  console.log(row("has manifest", full.has_manifest ? "yes" : "no"));
6388
+ if (full.archived_at)
6389
+ console.log(row("archived", String(full.archived_at)));
5804
6390
  if (full.last_deployed_at)
5805
6391
  console.log(row("deployed", String(full.last_deployed_at)));
5806
6392
  }
@@ -6167,6 +6753,98 @@ function saveConfig(config) {
6167
6753
  mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
6168
6754
  writeFileSync(CONFIG_PATH, `${JSON.stringify(clean, null, 2)}\n`, { mode: 0o600 });
6169
6755
  }
6756
+ function isMetaCommand(command) {
6757
+ return (command === "" ||
6758
+ command === "help" ||
6759
+ command === "--help" ||
6760
+ command === "-h" ||
6761
+ command === "--version" ||
6762
+ command === "-v");
6763
+ }
6764
+ // Truthy env flag: unset/empty/"0"/"false"/"no"/"off" are false, anything else true.
6765
+ function envFlag(value) {
6766
+ if (!value)
6767
+ return false;
6768
+ return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
6769
+ }
6770
+ function readUpdateState() {
6771
+ if (!existsSync(UPDATE_STATE_PATH))
6772
+ return {};
6773
+ try {
6774
+ return JSON.parse(readFileSync(UPDATE_STATE_PATH, "utf8"));
6775
+ }
6776
+ catch {
6777
+ return {};
6778
+ }
6779
+ }
6780
+ function writeUpdateState(state) {
6781
+ mkdirSync(dirname(UPDATE_STATE_PATH), { recursive: true, mode: 0o700 });
6782
+ writeFileSync(UPDATE_STATE_PATH, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
6783
+ }
6784
+ async function maybeAutoUpdate() {
6785
+ try {
6786
+ if (envFlag(process.env.RAILCODE_NO_UPDATE))
6787
+ return;
6788
+ const dryRun = envFlag(process.env.RAILCODE_UPDATE_DRY_RUN);
6789
+ // Only self-update for a human at an interactive terminal — CI and other
6790
+ // automation shouldn't silently mutate a global install. Dry-run forces the
6791
+ // check so the flow is exercisable non-interactively (and in tests).
6792
+ if (!dryRun && !process.stdout.isTTY)
6793
+ return;
6794
+ const now = Date.now();
6795
+ if (!shouldCheckForUpdate(readUpdateState().last_checked_for_updates, now))
6796
+ return;
6797
+ // Record the attempt up front so a slow or failed check still backs off for
6798
+ // the full interval instead of retrying on every subsequent command.
6799
+ writeUpdateState({ last_checked_for_updates: new Date(now).toISOString() });
6800
+ const registry = process.env.RAILCODE_REGISTRY_URL || process.env.npm_config_registry || DEFAULT_REGISTRY_URL;
6801
+ const versions = await fetchPackageVersions(registry, PACKAGE_NAME);
6802
+ const latest = pickLatestSameMajor(VERSION, versions);
6803
+ if (!latest)
6804
+ return;
6805
+ const spec = `${PACKAGE_NAME}@${latest}`;
6806
+ const installer = detectGlobalInstaller(CLI_PACKAGE_ROOT, process.env.npm_config_user_agent);
6807
+ const argv = installer.args(spec);
6808
+ const commandLine = `${installer.command} ${argv.join(" ")}`;
6809
+ process.stderr.write(`railcode: a new version is available — ${VERSION} → ${latest}. Updating…\n`);
6810
+ if (dryRun) {
6811
+ process.stderr.write(`railcode: [dry-run] would run \`${commandLine}\`\n`);
6812
+ return;
6813
+ }
6814
+ const code = await runGlobalInstall(installer.command, argv);
6815
+ if (code === 0) {
6816
+ process.stderr.write(`railcode: updated to ${latest}. It takes effect on your next command.\n`);
6817
+ }
6818
+ else {
6819
+ process.stderr.write(`railcode: auto-update failed. Update manually: ${commandLine}\n`);
6820
+ }
6821
+ }
6822
+ catch {
6823
+ // Best-effort: never let an update problem break the user's actual command.
6824
+ }
6825
+ }
6826
+ // Spawn the global install, routing all of its output to stderr so our own
6827
+ // stdout stays clean for parseable command output. Resolves with the exit code
6828
+ // (-1 if the manager binary couldn't be spawned).
6829
+ function runGlobalInstall(command, argv) {
6830
+ return new Promise((resolve) => {
6831
+ let child;
6832
+ try {
6833
+ child = spawn(command, argv, {
6834
+ stdio: ["ignore", "pipe", "pipe"],
6835
+ shell: process.platform === "win32",
6836
+ });
6837
+ }
6838
+ catch {
6839
+ resolve(-1);
6840
+ return;
6841
+ }
6842
+ child.stdout?.on("data", (chunk) => process.stderr.write(chunk));
6843
+ child.stderr?.on("data", (chunk) => process.stderr.write(chunk));
6844
+ child.on("error", () => resolve(-1));
6845
+ child.on("close", (code) => resolve(code ?? -1));
6846
+ });
6847
+ }
6170
6848
  // ---------------------------------------------------------------------------
6171
6849
  // Manifest + misc helpers
6172
6850
  // ---------------------------------------------------------------------------
@@ -6332,8 +7010,9 @@ class CliError extends Error {
6332
7010
  // Serves the app on a single loopback origin and emulates the /_api/* data plane:
6333
7011
  // identity is synthetic, KV + files live on local disk (isolated per storage scope —
6334
7012
  // shared/user/role — so `db.user`/`files.role(uuid)` behave like prod). llm/sql/queries/
6335
- // connectors/email proxy to the real instance as the signed-in user (their token + org
6336
- // grants) via the org/user-scoped routes — no deploy needed. Mirrors railcode-core's dev.
7013
+ // connectors/email/agents proxy to the real instance as the signed-in user (their token
7014
+ // + org grants) via the org/user-scoped routes — no deploy needed. Mirrors
7015
+ // railcode-core's dev.
6337
7016
  // ---------------------------------------------------------------------------
6338
7017
  const DEFAULT_DEV_PORT = 7331;
6339
7018
  const DEFAULT_ASSET_PORT = 5173;
@@ -6366,6 +7045,7 @@ async function commandDev(args) {
6366
7045
  throw new CliError("SDK bundle not found. Build it with `pnpm --dir sdk build` (or `pnpm --dir cli build` once the CLI bundles it).", 1);
6367
7046
  }
6368
7047
  const plan = resolveDevPlan(cwd, manifest);
7048
+ const manifestDoc = readLocalManifestDoc(cwd);
6369
7049
  const ctx = {
6370
7050
  app: manifest.app,
6371
7051
  root: plan.root,
@@ -6375,7 +7055,8 @@ async function commandDev(args) {
6375
7055
  fileStores: new Map(),
6376
7056
  port: requestedPort,
6377
7057
  config,
6378
- personalConnectors: readAppPersonalConnectors(cwd),
7058
+ personalConnectors: manifestDoc?.personal_connectors ?? [],
7059
+ agents: manifestDoc?.agents ?? [],
6379
7060
  };
6380
7061
  // Asset mode needs its deps before we bind anything; the check is cheap and
6381
7062
  // failing early avoids leaving the proxy port bound on a misconfigured app.
@@ -6880,6 +7561,10 @@ async function handleLocalApi(ctx, req, res, url) {
6880
7561
  await handlePersonalConnections(ctx, req, res, path, url);
6881
7562
  return;
6882
7563
  }
7564
+ if (path.startsWith("/agents/")) {
7565
+ await handleAgents(ctx, req, res, path, url);
7566
+ return;
7567
+ }
6883
7568
  // Proxied surfaces → forward to the real instance with the saved token.
6884
7569
  if (path === "/connections" ||
6885
7570
  path === "/queries" ||
@@ -7213,8 +7898,40 @@ export function devComputeTarget(creds, path, search) {
7213
7898
  if (path === "/email" || path === "/email/send") {
7214
7899
  return `${org}${path}${search}`;
7215
7900
  }
7901
+ // Managed agents. The static `/agents/runs/` prefix is matched BEFORE the dynamic
7902
+ // `{agent_ref}` matcher — the same order the backend declares its routes in — or an
7903
+ // agent whose ref is `runs` would shadow the poll target. Both segments are
7904
+ // re-encoded (see `devUrlSegment`) so the ref/request-id lands as exactly one path
7905
+ // segment upstream.
7906
+ if (path.startsWith("/agents/runs/")) {
7907
+ const requestId = devUrlSegment(path.slice("/agents/runs/".length));
7908
+ return requestId ? `${org}/agents/runs/${requestId}${search}` : null;
7909
+ }
7910
+ if (path.startsWith("/agents/")) {
7911
+ const ref = devUrlSegment(path.slice("/agents/".length));
7912
+ return ref ? `${org}/agents/${ref}/invoke${search}` : null;
7913
+ }
7216
7914
  return null;
7217
7915
  }
7916
+ // Normalize one path segment for the upstream URL. The browser sends it already
7917
+ // percent-encoded (the SDK encodes the name/request-id), so decode-then-encode
7918
+ // leaves it encoded exactly ONCE — no `%20` → `%2520` — and a `/` or `?` smuggled
7919
+ // into a name can never rewrite the upstream path. Returns null for an empty,
7920
+ // undecodable, or multi-segment value.
7921
+ function devUrlSegment(raw) {
7922
+ if (!raw || raw.includes("/"))
7923
+ return null;
7924
+ const decoded = devDecodeSegment(raw);
7925
+ return decoded === null ? null : encodeURIComponent(decoded);
7926
+ }
7927
+ function devDecodeSegment(raw) {
7928
+ try {
7929
+ return decodeURIComponent(raw) || null;
7930
+ }
7931
+ catch {
7932
+ return null;
7933
+ }
7934
+ }
7218
7935
  export function devUpstreamAuthFallback(status, degradeOk) {
7219
7936
  if (status !== 401)
7220
7937
  return null;
@@ -7228,6 +7945,22 @@ export function devUpstreamAuthFallback(status, degradeOk) {
7228
7945
  },
7229
7946
  };
7230
7947
  }
7948
+ // The local manifest.yaml, parsed — dev's stand-in for the ratified manifest when
7949
+ // reproducing prod's app-plane bounds (personal connectors, agents). Missing or
7950
+ // malformed → null. A malformed manifest is caught (loudly) at deploy; dev must
7951
+ // not crash on it, and callers read null as "nothing declared", which is the safe
7952
+ // direction: the declared-only surfaces refuse instead of running unbounded.
7953
+ function readLocalManifestDoc(cwd) {
7954
+ const path = join(cwd, APP_MANIFEST_NAME);
7955
+ if (!existsSync(path))
7956
+ return null;
7957
+ try {
7958
+ return parseManifestYaml(readFileSync(path, "utf8"));
7959
+ }
7960
+ catch {
7961
+ return null;
7962
+ }
7963
+ }
7231
7964
  // ── personal connectors in dev ────────────────────────────────────────────────
7232
7965
  //
7233
7966
  // The SDK's `personalConnections.*` methods hit the app plane in prod, which is
@@ -7238,20 +7971,6 @@ export function devUpstreamAuthFallback(status, degradeOk) {
7238
7971
  // SQL and LLM. The result: declared tools run against your real connection,
7239
7972
  // undeclared ones are refused exactly as they will be in prod, so a bad call
7240
7973
  // fails locally instead of only after deploy.
7241
- function readAppPersonalConnectors(cwd) {
7242
- const path = join(cwd, APP_MANIFEST_NAME);
7243
- if (!existsSync(path))
7244
- return [];
7245
- try {
7246
- return parseManifestYaml(readFileSync(path, "utf8")).personal_connectors ?? [];
7247
- }
7248
- catch {
7249
- // A malformed manifest is caught (loudly) at deploy; dev must not crash on it.
7250
- // Treating it as "nothing declared" makes personal-connector calls 403 here,
7251
- // which is the safe direction.
7252
- return [];
7253
- }
7254
- }
7255
7974
  // The toolkit slugs an app declared (any tool scope). Mirrors the server's
7256
7975
  // `manifest.personal_connector_toolkits`.
7257
7976
  function pcDeclaredToolkits(entries) {
@@ -7383,6 +8102,86 @@ async function handlePersonalConnections(ctx, req, res, path, url) {
7383
8102
  }
7384
8103
  sendJson(res, 404, { detail: "not found" });
7385
8104
  }
8105
+ // ── managed agents in dev ─────────────────────────────────────────────────────
8106
+ //
8107
+ // `agents.invoke()`/`start()`/`get()` hit the app plane in prod, bounded by the
8108
+ // app's ratified manifest. Dev has no ratified app, so it does what it already does
8109
+ // for personal connectors: reproduce that bound HERE from the local manifest.yaml
8110
+ // and forward the real work to the org/user plane as the signed-in developer. The
8111
+ // run is REAL — a real agent, real spend, real side effects — invoked under the
8112
+ // developer's own `agent:invoke` grant, so no deploy is needed first. Being the
8113
+ // developer's authority, the local gate is a DX guardrail (catch an undeclared
8114
+ // agent before deploy), not a security boundary; the bound that matters is the
8115
+ // deployed app's ratified manifest.
8116
+ const AGENT_NOT_IN_MANIFEST = (label) => ({
8117
+ detail: `agent "${label}" was not in the manifest`,
8118
+ });
8119
+ async function handleAgents(ctx, req, res, path, url) {
8120
+ // Poll a run — matched FIRST so an agent ref can't shadow it, and deliberately
8121
+ // NOT manifest-gated: upstream already scopes a run to the caller who triggered
8122
+ // it (`_run_or_404`, at the agent:read tier), which is the right bound for
8123
+ // "read back the run you just started".
8124
+ if (path.startsWith("/agents/runs/")) {
8125
+ if (req.method !== "GET")
8126
+ return sendJson(res, 405, { detail: "method not allowed" });
8127
+ return forwardCompute(ctx, req, res, path, url);
8128
+ }
8129
+ // Invoke. Only the single-segment `/agents/{ref}` exists here — the app plane's
8130
+ // artifact/KV surfaces are deliberately not proxied — so anything deeper is a 404.
8131
+ const ref = devDecodeSegment(path.slice("/agents/".length));
8132
+ if (!ref || ref.includes("/"))
8133
+ return sendJson(res, 404, { detail: "not found" });
8134
+ if (req.method !== "POST")
8135
+ return sendJson(res, 405, { detail: "method not allowed" });
8136
+ const refusal = await devAgentRefusal(ctx, ref);
8137
+ if (refusal)
8138
+ return sendJson(res, 403, refusal);
8139
+ await forwardCompute(ctx, req, res, path, url);
8140
+ }
8141
+ // The local stand-in for prod's `_agent_for_ctx` manifest check: 403 body to send,
8142
+ // or null to forward.
8143
+ async function devAgentRefusal(ctx, ref) {
8144
+ const declared = ctx.agents;
8145
+ if (declared.includes(ref))
8146
+ return null;
8147
+ // Nothing declared (no manifest.yaml, or a malformed one) ⇒ nothing can match, so
8148
+ // refuse without touching the network — the same "no declaration = refuse" answer
8149
+ // prod's chokepoint gives.
8150
+ if (declared.length === 0)
8151
+ return AGENT_NOT_IN_MANIFEST(ref);
8152
+ // The manifest lists agent NAMES, but a ref may be a uuid (the backend resolves
8153
+ // either), so an opaque ref is resolved upstream and compared by name — a declared
8154
+ // agent addressed by uuid must not 403 here. A non-uuid ref needs no lookup: it can
8155
+ // only be a name, and it is not one of the declared ones.
8156
+ if (!DEV_UUID_RE.test(ref))
8157
+ return AGENT_NOT_IN_MANIFEST(ref);
8158
+ const name = await devAgentName(ctx, ref);
8159
+ if (name === null)
8160
+ return null; // unresolvable — let upstream give its own answer
8161
+ return declared.includes(name) ? null : AGENT_NOT_IN_MANIFEST(name);
8162
+ }
8163
+ // Resolve an agent ref to its name on the org plane. Null when it can't be resolved
8164
+ // (not found, denied, not logged in, unreachable); the invoke is then forwarded so
8165
+ // upstream answers authentically (404/403, or the 503 auth fallback) — mirroring the
8166
+ // prod order, which resolves the agent (404) before checking the manifest (403).
8167
+ async function devAgentName(ctx, ref) {
8168
+ const creds = devCreds(ctx.config);
8169
+ if (!creds)
8170
+ return null;
8171
+ try {
8172
+ const resp = await fetch(`${creds.apiUrl}/api/organizations/${creds.orgUuid}/agents/${encodeURIComponent(ref)}`, {
8173
+ headers: { Authorization: `Bearer ${creds.token}`, "X-Railcode-Source": "local-dev" },
8174
+ redirect: "manual",
8175
+ });
8176
+ if (!resp.ok)
8177
+ return null;
8178
+ const body = (await resp.json());
8179
+ return typeof body.name === "string" ? body.name : null;
8180
+ }
8181
+ catch {
8182
+ return null;
8183
+ }
8184
+ }
7386
8185
  async function forwardCompute(ctx, req, res, path, url) {
7387
8186
  // Never reply 401 to the browser — the SDK reloads the page on 401 (loop).
7388
8187
  // Load-time list calls degrade to [] so dataConnectors()/savedQueries()/
@@ -7399,7 +8198,7 @@ async function forwardCompute(ctx, req, res, path, url) {
7399
8198
  return sendJson(res, 200, []);
7400
8199
  return sendJson(res, 503, {
7401
8200
  error: "not_logged_in",
7402
- message: "Run `railcode login` to use LLM, SQL, saved queries, connectors, and email in dev.",
8201
+ message: "Run `railcode login` to use LLM, SQL, saved queries, connectors, agents, and email in dev.",
7403
8202
  });
7404
8203
  }
7405
8204
  const target = devComputeTarget(creds, path, url.search);
@@ -7650,11 +8449,11 @@ function printDevBanner(ctx, config) {
7650
8449
  const creds = devCreds(config);
7651
8450
  if (creds) {
7652
8451
  console.log(` account: ${config.email ?? "?"} @ ${creds.apiUrl}`);
7653
- console.log(` compute: llm/sql/queries/connectors/email → real instance (REAL spend + data)`);
8452
+ console.log(` compute: llm/sql/queries/connectors/email/agents → real instance (REAL spend + data)`);
7654
8453
  console.log(` runs as: you — your saved token + org permissions (no deploy needed)`);
7655
8454
  }
7656
8455
  else {
7657
- console.log(` account: not logged in — llm/sql/email return 503, connectors empty`);
8456
+ console.log(` account: not logged in — llm/sql/email/agents return 503, connectors empty`);
7658
8457
  }
7659
8458
  console.log("");
7660
8459
  console.log(" Ctrl-C to stop.");