railcode 0.1.27 → 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/README.md CHANGED
@@ -34,6 +34,10 @@ railcode roles <list|create|update|delete|add-member|remove-member|grants|grant|
34
34
  Org roles + the granular grants table (admin)
35
35
  railcode apps <list|show|access|set-access|transfer|delete> ...
36
36
  Apps + access policy (owner/admin)
37
+ railcode app kv <collections|list|get|set|delete|drop> ...
38
+ Manage an app's KV store (owner)
39
+ railcode app files <list|download|upload|delete> ...
40
+ Manage an app's files (owner)
37
41
  railcode analytics <app> [--range 1d|7d|30d|90d] Per-app pageview analytics
38
42
  railcode logs <connector|service-connector|llm|email|agent> [filters]
39
43
  Org observability logs (admin)
@@ -131,6 +135,33 @@ API URL resolution: `--api-url` > `RAILCODE_API_URL` > saved config > prompt.
131
135
  Set `RAILCODE_API_TOKEN` to override the saved token in CI. On a `401`, the saved
132
136
  token is cleared and you're prompted to `railcode login` again.
133
137
 
138
+ ## Staying up to date
139
+
140
+ The CLI keeps itself current. When you run a command in an interactive terminal it
141
+ checks the npm registry — at most once every 6 hours — for a newer `railcode`
142
+ release **within your current major** and, if there is one, installs it globally
143
+ (with whichever package manager it detects: npm/pnpm/yarn/bun) and tells you:
144
+
145
+ ```
146
+ railcode: a new version is available — 0.1.26 → 0.1.27. Updating…
147
+ railcode: updated to 0.1.27. It takes effect on your next command.
148
+ ```
149
+
150
+ It never jumps across a major version (a breaking `2.0.0` is left for you to
151
+ install deliberately), the check is best-effort (a registry/network hiccup is
152
+ silently ignored — it never blocks your command), and it's skipped in
153
+ non-interactive/CI runs so automation never self-mutates a global install. The
154
+ `last_checked_for_updates` timestamp lives in `~/.railcode/update-check.json`, and
155
+ all of the above is printed to **stderr** so `--json` output stays clean.
156
+
157
+ Environment overrides:
158
+
159
+ - `RAILCODE_NO_UPDATE=1` — turn auto-update off entirely.
160
+ - `RAILCODE_UPDATE_DRY_RUN=1` — check and print the command it *would* run, without
161
+ installing (also forces the check even off-TTY; handy for scripts and testing).
162
+ - `RAILCODE_REGISTRY_URL` (falls back to `npm_config_registry`) — point the check
163
+ at a different npm-compatible registry.
164
+
134
165
  ## `railcode.json`
135
166
 
136
167
  ```json
@@ -0,0 +1,154 @@
1
+ // Pure helpers behind `railcode app kv ...` / `railcode app files ...`. Kept free
2
+ // of I/O and process state so they unit-test directly (see test/app-storage.test.mjs);
3
+ // the command layer in index.ts does the HTTP, file reads, and printing.
4
+ export class StorageError extends Error {
5
+ }
6
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ // Parse + validate the shared scope flags. `allowAll` gates the cross-scope `all`
8
+ // selector to the listing commands (a record/file mutation must name one namespace).
9
+ export function parseScopeSelector(scope, user, role, opts) {
10
+ const kind = (scope ?? "shared").toLowerCase();
11
+ const allowed = opts.allowAll ? "shared, user, role, all" : "shared, user, role";
12
+ if (!["shared", "user", "role", "all"].includes(kind)) {
13
+ throw new StorageError(`Unknown --scope "${scope}". Use one of: ${allowed}.`);
14
+ }
15
+ if (kind === "all") {
16
+ if (!opts.allowAll) {
17
+ throw new StorageError("--scope all only lists (kv collections, kv list, files list); a record/file " +
18
+ "operation must target one scope.");
19
+ }
20
+ if (user || role)
21
+ throw new StorageError("--scope all takes no --user or --role.");
22
+ return { scope: "all" };
23
+ }
24
+ if (kind === "shared") {
25
+ if (user || role)
26
+ throw new StorageError("--scope shared takes no --user or --role.");
27
+ return { scope: "shared" };
28
+ }
29
+ if (kind === "user") {
30
+ if (role)
31
+ throw new StorageError("--scope user takes no --role.");
32
+ if (!user)
33
+ throw new StorageError("--scope user requires --user <member-uuid>.");
34
+ if (!UUID_RE.test(user))
35
+ throw new StorageError("--user must be a member UUID.");
36
+ return { scope: "user", user };
37
+ }
38
+ // role
39
+ if (user)
40
+ throw new StorageError("--scope role takes no --user.");
41
+ if (!role)
42
+ throw new StorageError("--scope role requires --role <role-uuid>.");
43
+ if (!UUID_RE.test(role))
44
+ throw new StorageError("--role must be a role UUID.");
45
+ return { scope: "role", role };
46
+ }
47
+ // The scope selectors as a query string, echoed onto every storage request.
48
+ export function scopeQuery(sel) {
49
+ const params = new URLSearchParams();
50
+ params.set("scope", sel.scope);
51
+ if (sel.user)
52
+ params.set("user", sel.user);
53
+ if (sel.role)
54
+ params.set("role", sel.role);
55
+ return params.toString();
56
+ }
57
+ // The value for `kv set` — inline JSON or from --file, never both, never neither.
58
+ // A KV value is JSON of any shape: an object, array, string, number, or bool.
59
+ export function parseKvValue(positional, fileText) {
60
+ const hasPos = typeof positional === "string" && positional.length > 0;
61
+ const hasFile = typeof fileText === "string";
62
+ if (hasPos && hasFile) {
63
+ throw new StorageError("Pass the value inline OR with --file, not both.");
64
+ }
65
+ if (!hasPos && !hasFile) {
66
+ throw new StorageError('Provide a JSON value: railcode app kv set <collection> <key> \'{"n":1}\' (or --file v.json).');
67
+ }
68
+ const raw = hasFile ? fileText : positional;
69
+ try {
70
+ return JSON.parse(raw);
71
+ }
72
+ catch {
73
+ throw new StorageError("Value must be valid JSON — an object, array, string, number, or bool " +
74
+ "(e.g. '{\"n\":1}', '\"hello\"', 42, true).");
75
+ }
76
+ }
77
+ // --query is a KV `where` clause: a JSON array of [field, op, value] triples, passed
78
+ // through to the same query engine the data plane exposes. Validated here for a clean
79
+ // local error; the server re-validates authoritatively.
80
+ export function parseWhere(raw) {
81
+ if (raw === undefined)
82
+ return undefined;
83
+ let parsed;
84
+ try {
85
+ parsed = JSON.parse(raw);
86
+ }
87
+ catch {
88
+ throw new StorageError('--query must be JSON, e.g. --query \'[["n","gt",1]]\'.');
89
+ }
90
+ if (!Array.isArray(parsed)) {
91
+ throw new StorageError('--query must be a JSON array of [field, op, value] clauses.');
92
+ }
93
+ return raw;
94
+ }
95
+ // --limit/--offset → the API's page/size. offset must be a whole number of pages so
96
+ // the translation is exact (the API pages, it doesn't offset).
97
+ export function pageParams(limit, offset) {
98
+ const out = {};
99
+ if (limit !== undefined) {
100
+ if (!Number.isInteger(limit) || limit <= 0) {
101
+ throw new StorageError("--limit must be a positive integer.");
102
+ }
103
+ out.size = limit;
104
+ }
105
+ if (offset !== undefined) {
106
+ if (!Number.isInteger(offset) || offset < 0) {
107
+ throw new StorageError("--offset must be a non-negative integer.");
108
+ }
109
+ if (limit === undefined)
110
+ throw new StorageError("--offset requires --limit.");
111
+ if (offset % limit !== 0) {
112
+ throw new StorageError("--offset must be a whole multiple of --limit.");
113
+ }
114
+ out.page = Math.floor(offset / limit) + 1;
115
+ }
116
+ return out;
117
+ }
118
+ // A best-effort content type from a filename extension (upload default:
119
+ // application/octet-stream). The backend forces download on serve regardless.
120
+ export function guessContentType(name) {
121
+ const ext = name.toLowerCase().split(".").pop() ?? "";
122
+ const map = {
123
+ txt: "text/plain",
124
+ md: "text/markdown",
125
+ csv: "text/csv",
126
+ json: "application/json",
127
+ html: "text/html",
128
+ css: "text/css",
129
+ js: "text/javascript",
130
+ svg: "image/svg+xml",
131
+ png: "image/png",
132
+ jpg: "image/jpeg",
133
+ jpeg: "image/jpeg",
134
+ gif: "image/gif",
135
+ webp: "image/webp",
136
+ pdf: "application/pdf",
137
+ zip: "application/zip",
138
+ };
139
+ return map[ext] ?? "application/octet-stream";
140
+ }
141
+ // Percent-encode each segment of a file name while keeping the `/` separators the
142
+ // scope-aware `{name:path}` route expects.
143
+ export function encodeFilePath(name) {
144
+ return name
145
+ .split("/")
146
+ .map((segment) => encodeURIComponent(segment))
147
+ .join("/");
148
+ }
149
+ // The owner label (member email / role name) for a listing row, or a dash.
150
+ export function ownerCell(scope, ownerLabel) {
151
+ if (scope === "shared")
152
+ return "-";
153
+ return ownerLabel ?? "-";
154
+ }
package/dist/index.js CHANGED
@@ -6,13 +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
13
  import { parse as parseYaml } from "yaml";
14
14
  import { KvQueryError, kvCount, kvQuery, } from "./dev/kv-query.js";
15
+ import { detectGlobalInstaller, fetchPackageVersions, pickLatestSameMajor, shouldCheckForUpdate, } from "./update.js";
15
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";
16
18
  import { QueryCmdError, paramSignature, parseParamDecls, parseRunParams, pickAuthoringSql, } from "./query.js";
17
19
  import { APP_MANIFEST_NAME, ManifestParseError, documentOperations, parseManifestYaml, renderManifestDeployOutcome, renderManifestSummary, } from "./manifest.js";
18
20
  import { ConnectorError, buildConnectorRequest, connectorOpenapiOutput, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderConnectorDocs, renderProxyResponse, } from "./connectors.js";
@@ -27,7 +29,11 @@ const RAILCODE_HOME = process.env.RAILCODE_HOME || join(homedir(), ".railcode");
27
29
  const CONFIG_PATH = join(RAILCODE_HOME, "config.json");
28
30
  const CLI_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
29
31
  const VERSION = readCliPackageVersion();
32
+ const PACKAGE_NAME = readCliPackageName();
30
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";
31
37
  const MANIFEST_NAME = "railcode.json";
32
38
  const CLI_AUTH_CALLBACK_PATH = "/railcode-cli/callback";
33
39
  const CLI_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
@@ -59,6 +65,8 @@ Usage:
59
65
  railcode roles <list|create|update|delete|add-member|remove-member|grants|grant|revoke|materialize|effective|catalog> ...
60
66
  Manage org roles + the granular grants table (admin)
61
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)
62
70
  railcode analytics <app> [--range 1d|7d|30d|90d] Per-app pageview analytics (admin/owner)
63
71
  railcode logs <connector|service-connector|llm|email|agent> [filters]
64
72
  Read org observability logs (admin)
@@ -74,6 +82,11 @@ the default (${DEFAULT_API_URL}) — never prompted.
74
82
  // ---------------------------------------------------------------------------
75
83
  async function main() {
76
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
+ }
77
90
  switch (args.command) {
78
91
  case "":
79
92
  case "help":
@@ -206,6 +219,15 @@ function readCliPackageVersion() {
206
219
  const pkg = JSON.parse(readFileSync(join(CLI_PACKAGE_ROOT, "package.json"), "utf8"));
207
220
  return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
208
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
+ }
209
231
  // ---------------------------------------------------------------------------
210
232
  // login
211
233
  // ---------------------------------------------------------------------------
@@ -5447,8 +5469,14 @@ function jsonBody(body) {
5447
5469
  async function fetchMembers(config) {
5448
5470
  return (await authedJson(config, `/api/organizations/${config.orgUuid}/members`, "List members"));
5449
5471
  }
5450
- async function fetchApps(config) {
5451
- 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"));
5452
5480
  }
5453
5481
  async function fetchPermissions(config) {
5454
5482
  return (await authedJson(config, `/api/organizations/${config.orgUuid}/permissions`, "List roles + grants"));
@@ -5821,21 +5849,84 @@ async function commandRolesCatalog(args) {
5821
5849
  const APPS_HELP = `railcode apps — apps + access policy
5822
5850
 
5823
5851
  Usage:
5824
- railcode apps list List apps you can see
5852
+ railcode apps list [--archived|--all] List apps you can see
5825
5853
  railcode apps show <app> Show one app's details
5826
5854
  railcode apps access <app> Show an app's access policy + grants
5827
5855
  railcode apps set-access <app> --mode organization|private|restricted
5828
5856
  [--members <email,email>] Set the policy (members: restricted only)
5829
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
5830
5860
  railcode apps delete <app> [--yes] Delete an app (deploys + data, irreversible)
5831
5861
 
5832
- <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
5833
5866
  (owner or org admin). delete prompts for confirmation on a TTY; pass --yes to skip
5834
5867
  (required in non-interactive sessions).
5835
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
+
5836
5874
  Options:
5875
+ --archived (list) Only archived apps
5876
+ --all (list) Archived and live apps
5837
5877
  --json Print raw JSON
5838
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)
5839
5930
  `;
5840
5931
  async function commandApps(args) {
5841
5932
  try {
@@ -5858,10 +5949,23 @@ async function commandApps(args) {
5858
5949
  case "transfer":
5859
5950
  await commandAppsTransfer(args);
5860
5951
  return;
5952
+ case "archive":
5953
+ await commandAppsArchive(args, true);
5954
+ return;
5955
+ case "unarchive":
5956
+ await commandAppsArchive(args, false);
5957
+ return;
5861
5958
  case "delete":
5862
5959
  case "rm":
5863
5960
  await commandAppsDelete(args);
5864
5961
  return;
5962
+ case "kv":
5963
+ await commandAppKv(args);
5964
+ return;
5965
+ case "files":
5966
+ case "file":
5967
+ await commandAppFiles(args);
5968
+ return;
5865
5969
  case "":
5866
5970
  case "help":
5867
5971
  console.log(APPS_HELP);
@@ -5871,25 +5975,396 @@ async function commandApps(args) {
5871
5975
  }
5872
5976
  }
5873
5977
  catch (error) {
5874
- if (error instanceof OrgAdminError)
5978
+ if (error instanceof OrgAdminError || error instanceof StorageError) {
5875
5979
  throw new CliError(error.message, 2);
5980
+ }
5876
5981
  throw error;
5877
5982
  }
5878
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
+ }
5879
6328
  async function commandAppsList(args) {
5880
- 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";
5881
6334
  const config = requireLogin(args);
5882
- const apps = await fetchApps(config);
6335
+ const apps = await fetchApps(config, archived);
5883
6336
  if (args.options.json) {
5884
6337
  console.log(JSON.stringify(apps, null, 2));
5885
6338
  return;
5886
6339
  }
5887
6340
  if (apps.length === 0) {
5888
- 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>.");
5889
6344
  return;
5890
6345
  }
5891
6346
  console.log(appTable(apps));
5892
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
+ }
5893
6368
  async function commandAppsShow(args) {
5894
6369
  rejectUnknownOptions(args, ["json", "apiUrl"], "apps", APPS_HELP);
5895
6370
  const ref = args.rest[1];
@@ -5910,6 +6385,8 @@ async function commandAppsShow(args) {
5910
6385
  console.log(row("your role", full.your_role ?? "-"));
5911
6386
  console.log(row("can manage", full.can_manage ? "yes" : "no"));
5912
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)));
5913
6390
  if (full.last_deployed_at)
5914
6391
  console.log(row("deployed", String(full.last_deployed_at)));
5915
6392
  }
@@ -6276,6 +6753,98 @@ function saveConfig(config) {
6276
6753
  mkdirSync(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
6277
6754
  writeFileSync(CONFIG_PATH, `${JSON.stringify(clean, null, 2)}\n`, { mode: 0o600 });
6278
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
+ }
6279
6848
  // ---------------------------------------------------------------------------
6280
6849
  // Manifest + misc helpers
6281
6850
  // ---------------------------------------------------------------------------
@@ -6441,8 +7010,9 @@ class CliError extends Error {
6441
7010
  // Serves the app on a single loopback origin and emulates the /_api/* data plane:
6442
7011
  // identity is synthetic, KV + files live on local disk (isolated per storage scope —
6443
7012
  // shared/user/role — so `db.user`/`files.role(uuid)` behave like prod). llm/sql/queries/
6444
- // connectors/email proxy to the real instance as the signed-in user (their token + org
6445
- // 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.
6446
7016
  // ---------------------------------------------------------------------------
6447
7017
  const DEFAULT_DEV_PORT = 7331;
6448
7018
  const DEFAULT_ASSET_PORT = 5173;
@@ -6475,6 +7045,7 @@ async function commandDev(args) {
6475
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);
6476
7046
  }
6477
7047
  const plan = resolveDevPlan(cwd, manifest);
7048
+ const manifestDoc = readLocalManifestDoc(cwd);
6478
7049
  const ctx = {
6479
7050
  app: manifest.app,
6480
7051
  root: plan.root,
@@ -6484,7 +7055,8 @@ async function commandDev(args) {
6484
7055
  fileStores: new Map(),
6485
7056
  port: requestedPort,
6486
7057
  config,
6487
- personalConnectors: readAppPersonalConnectors(cwd),
7058
+ personalConnectors: manifestDoc?.personal_connectors ?? [],
7059
+ agents: manifestDoc?.agents ?? [],
6488
7060
  };
6489
7061
  // Asset mode needs its deps before we bind anything; the check is cheap and
6490
7062
  // failing early avoids leaving the proxy port bound on a misconfigured app.
@@ -6989,6 +7561,10 @@ async function handleLocalApi(ctx, req, res, url) {
6989
7561
  await handlePersonalConnections(ctx, req, res, path, url);
6990
7562
  return;
6991
7563
  }
7564
+ if (path.startsWith("/agents/")) {
7565
+ await handleAgents(ctx, req, res, path, url);
7566
+ return;
7567
+ }
6992
7568
  // Proxied surfaces → forward to the real instance with the saved token.
6993
7569
  if (path === "/connections" ||
6994
7570
  path === "/queries" ||
@@ -7322,8 +7898,40 @@ export function devComputeTarget(creds, path, search) {
7322
7898
  if (path === "/email" || path === "/email/send") {
7323
7899
  return `${org}${path}${search}`;
7324
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
+ }
7325
7914
  return null;
7326
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
+ }
7327
7935
  export function devUpstreamAuthFallback(status, degradeOk) {
7328
7936
  if (status !== 401)
7329
7937
  return null;
@@ -7337,6 +7945,22 @@ export function devUpstreamAuthFallback(status, degradeOk) {
7337
7945
  },
7338
7946
  };
7339
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
+ }
7340
7964
  // ── personal connectors in dev ────────────────────────────────────────────────
7341
7965
  //
7342
7966
  // The SDK's `personalConnections.*` methods hit the app plane in prod, which is
@@ -7347,20 +7971,6 @@ export function devUpstreamAuthFallback(status, degradeOk) {
7347
7971
  // SQL and LLM. The result: declared tools run against your real connection,
7348
7972
  // undeclared ones are refused exactly as they will be in prod, so a bad call
7349
7973
  // fails locally instead of only after deploy.
7350
- function readAppPersonalConnectors(cwd) {
7351
- const path = join(cwd, APP_MANIFEST_NAME);
7352
- if (!existsSync(path))
7353
- return [];
7354
- try {
7355
- return parseManifestYaml(readFileSync(path, "utf8")).personal_connectors ?? [];
7356
- }
7357
- catch {
7358
- // A malformed manifest is caught (loudly) at deploy; dev must not crash on it.
7359
- // Treating it as "nothing declared" makes personal-connector calls 403 here,
7360
- // which is the safe direction.
7361
- return [];
7362
- }
7363
- }
7364
7974
  // The toolkit slugs an app declared (any tool scope). Mirrors the server's
7365
7975
  // `manifest.personal_connector_toolkits`.
7366
7976
  function pcDeclaredToolkits(entries) {
@@ -7492,6 +8102,86 @@ async function handlePersonalConnections(ctx, req, res, path, url) {
7492
8102
  }
7493
8103
  sendJson(res, 404, { detail: "not found" });
7494
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
+ }
7495
8185
  async function forwardCompute(ctx, req, res, path, url) {
7496
8186
  // Never reply 401 to the browser — the SDK reloads the page on 401 (loop).
7497
8187
  // Load-time list calls degrade to [] so dataConnectors()/savedQueries()/
@@ -7508,7 +8198,7 @@ async function forwardCompute(ctx, req, res, path, url) {
7508
8198
  return sendJson(res, 200, []);
7509
8199
  return sendJson(res, 503, {
7510
8200
  error: "not_logged_in",
7511
- 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.",
7512
8202
  });
7513
8203
  }
7514
8204
  const target = devComputeTarget(creds, path, url.search);
@@ -7759,11 +8449,11 @@ function printDevBanner(ctx, config) {
7759
8449
  const creds = devCreds(config);
7760
8450
  if (creds) {
7761
8451
  console.log(` account: ${config.email ?? "?"} @ ${creds.apiUrl}`);
7762
- 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)`);
7763
8453
  console.log(` runs as: you — your saved token + org permissions (no deploy needed)`);
7764
8454
  }
7765
8455
  else {
7766
- 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`);
7767
8457
  }
7768
8458
  console.log("");
7769
8459
  console.log(" Ctrl-C to stop.");
package/dist/orgadmin.js CHANGED
@@ -178,14 +178,26 @@ export function renderEffective(eff, who) {
178
178
  return lines.join("\n");
179
179
  }
180
180
  export function appTable(apps) {
181
- return formatTable(["slug", "name", "status", "access", "your_role", "manage"], apps.map((a) => [
182
- a.app_slug,
183
- a.name,
184
- a.status,
185
- a.access_mode,
186
- a.your_role ?? "-",
187
- a.can_manage ? "yes" : "no",
188
- ]));
181
+ // The `archived` column only appears when something in the list actually IS
182
+ // archived (i.e. `apps list --archived/--all`), so the default listing is
183
+ // unchanged for the many people who never archive anything.
184
+ const showArchived = apps.some((a) => a.archived_at);
185
+ const columns = ["slug", "name", "status", "access", "your_role", "manage"];
186
+ if (showArchived)
187
+ columns.push("archived");
188
+ return formatTable(columns, apps.map((a) => {
189
+ const row = [
190
+ a.app_slug,
191
+ a.name,
192
+ a.status,
193
+ a.access_mode,
194
+ a.your_role ?? "-",
195
+ a.can_manage ? "yes" : "no",
196
+ ];
197
+ if (showArchived)
198
+ row.push(a.archived_at ? "yes" : "-");
199
+ return row;
200
+ }));
189
201
  }
190
202
  // Resolve an app reference — a slug (preferred) or a UUID — against a fetched app
191
203
  // list.
package/dist/update.js ADDED
@@ -0,0 +1,112 @@
1
+ // Self-update support for the `railcode` CLI.
2
+ //
3
+ // Every command run (for a human at an interactive terminal) checks — at most
4
+ // once every 6 hours — whether a newer version is published on the npm registry
5
+ // *within the current major* and, if so, installs it globally and tells the
6
+ // user. The throttle timestamp lives in `~/.railcode/update-check.json`.
7
+ //
8
+ // This file holds only the pure, side-effect-free helpers (version math, the
9
+ // throttle predicate, installer detection) plus the registry fetch, so they can
10
+ // be unit-tested in isolation. The orchestration that reads/writes the state
11
+ // file and spawns the installer lives in `index.ts`.
12
+ export const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
13
+ // Parse a `MAJOR.MINOR.PATCH[-prerelease][+build]` string (a leading `v` is
14
+ // tolerated). Returns null for anything that isn't a plain release triple.
15
+ export function parseSemver(input) {
16
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(input.trim());
17
+ if (!match)
18
+ return null;
19
+ return {
20
+ major: Number(match[1]),
21
+ minor: Number(match[2]),
22
+ patch: Number(match[3]),
23
+ prerelease: match[4] ?? null,
24
+ };
25
+ }
26
+ // Compare release precedence by (major, minor, patch). Prerelease ordering is
27
+ // intentionally ignored — we exclude prereleases from update candidates.
28
+ export function compareSemver(a, b) {
29
+ if (a.major !== b.major)
30
+ return a.major - b.major;
31
+ if (a.minor !== b.minor)
32
+ return a.minor - b.minor;
33
+ return a.patch - b.patch;
34
+ }
35
+ // From all published versions, pick the highest stable release that shares the
36
+ // current major and is strictly newer than it. Prereleases and other majors are
37
+ // skipped, so we never auto-jump across a breaking major boundary. Returns the
38
+ // raw version string to install, or null when already up to date.
39
+ export function pickLatestSameMajor(current, versions) {
40
+ const cur = parseSemver(current);
41
+ if (!cur)
42
+ return null;
43
+ let best = null;
44
+ for (const raw of versions) {
45
+ const sv = parseSemver(raw);
46
+ if (!sv)
47
+ continue;
48
+ if (sv.prerelease)
49
+ continue; // stable releases only
50
+ if (sv.major !== cur.major)
51
+ continue; // stay within the current major
52
+ if (compareSemver(sv, cur) <= 0)
53
+ continue; // must be strictly newer
54
+ if (!best || compareSemver(sv, best.sv) > 0)
55
+ best = { raw, sv };
56
+ }
57
+ return best ? best.raw : null;
58
+ }
59
+ // Decide whether enough time has passed to check again. Missing or unparseable
60
+ // timestamps always check (and get rewritten with a valid one).
61
+ export function shouldCheckForUpdate(lastCheckedIso, nowMs, intervalMs = UPDATE_CHECK_INTERVAL_MS) {
62
+ if (!lastCheckedIso)
63
+ return true;
64
+ const last = Date.parse(lastCheckedIso);
65
+ if (Number.isNaN(last))
66
+ return true;
67
+ return nowMs - last >= intervalMs;
68
+ }
69
+ // Best-effort detection of the global package manager that owns this install, so
70
+ // the self-update uses the same tool the user installed with. We look at the
71
+ // install path first, then the `npm_config_user_agent` a manager sets when it
72
+ // spawns a script. Defaults to npm (the documented install path).
73
+ export function detectGlobalInstaller(installPath, userAgent) {
74
+ const path = installPath.replace(/\\/g, "/").toLowerCase();
75
+ const ua = (userAgent ?? "").toLowerCase();
76
+ if (ua.startsWith("pnpm") || /\/\.?pnpm\//.test(path)) {
77
+ return { manager: "pnpm", command: "pnpm", args: (spec) => ["add", "-g", spec] };
78
+ }
79
+ if (ua.startsWith("yarn") || /\/\.?yarn\//.test(path)) {
80
+ return { manager: "yarn", command: "yarn", args: (spec) => ["global", "add", spec] };
81
+ }
82
+ if (ua.startsWith("bun") || /\/\.bun\//.test(path)) {
83
+ return { manager: "bun", command: "bun", args: (spec) => ["add", "-g", spec] };
84
+ }
85
+ return { manager: "npm", command: "npm", args: (spec) => ["install", "-g", spec] };
86
+ }
87
+ // Fetch every published version of `pkg` from an npm-compatible registry. Uses
88
+ // the abbreviated packument (much smaller than the full doc) and a hard timeout,
89
+ // and returns [] on any failure — the caller treats "couldn't check" as "no
90
+ // update", never an error.
91
+ export async function fetchPackageVersions(registryBase, pkg, timeoutMs = 4000) {
92
+ const base = registryBase.replace(/\/+$/, "");
93
+ const url = `${base}/${encodeURIComponent(pkg).replace(/^%40/, "@")}`;
94
+ const controller = new AbortController();
95
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
96
+ try {
97
+ const res = await fetch(url, {
98
+ headers: { accept: "application/vnd.npm.install-v1+json" },
99
+ signal: controller.signal,
100
+ });
101
+ if (!res.ok)
102
+ return [];
103
+ const body = (await res.json());
104
+ return body.versions ? Object.keys(body.versions) : [];
105
+ }
106
+ catch {
107
+ return [];
108
+ }
109
+ finally {
110
+ clearTimeout(timer);
111
+ }
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "railcode",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "Developer CLI for the multi-tenant Railcode platform: log in, scaffold, and deploy static apps.",
5
5
  "license": "MIT",
6
6
  "type": "module",