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