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 +31 -0
- package/dist/app-storage.js +154 -0
- package/dist/index.js +719 -29
- package/dist/orgadmin.js +20 -8
- package/dist/update.js +112 -0
- package/package.json +1 -1
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
|
-
|
|
5451
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
6445
|
-
// grants) via the org/user-scoped routes — no deploy needed. Mirrors
|
|
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:
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
}
|