railcode 0.1.2 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ mkdir my-app
|
|
|
46
46
|
cd my-app
|
|
47
47
|
railcode init my-app
|
|
48
48
|
railcode dev
|
|
49
|
-
railcode
|
|
49
|
+
railcode get design-system
|
|
50
50
|
railcode deploy
|
|
51
51
|
```
|
|
52
52
|
|
|
@@ -56,20 +56,29 @@ at the root, and deployable build output goes in `dist/`. Direct dependencies
|
|
|
56
56
|
in the starter are exact version pins.
|
|
57
57
|
|
|
58
58
|
`railcode dev` installs missing app dependencies on first run, then runs the app
|
|
59
|
-
from the current Railcode directory with no login step.
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
from the current Railcode directory with no login step. It starts on
|
|
60
|
+
`127.0.0.1:7331` and automatically uses the next available port when another dev
|
|
61
|
+
server is already running; use the printed URL. For inferred Vite apps, the
|
|
62
|
+
asset dev server likewise starts at `5173` and moves upward when needed.
|
|
63
|
+
Identity, access metadata, KV, and files run locally; backend-backed APIs such
|
|
64
|
+
as SQL and LLM are forwarded to the configured Railcode URL when that backend
|
|
65
|
+
access is available.
|
|
62
66
|
|
|
63
67
|
`railcode login` opens a browser authorization flow. Pass `--api-url` or set
|
|
64
68
|
`RAILCODE_API_URL` to choose the Railcode auth/API origin; if neither is set and
|
|
65
69
|
no saved URL exists, the CLI prompts for the domain and assumes `https://` when
|
|
66
70
|
you enter a bare domain.
|
|
67
71
|
|
|
68
|
-
`railcode design-system
|
|
72
|
+
`railcode get design-system` fetches the platform design-system markdown from
|
|
69
73
|
`/v1/config/design-system` using the saved browser-authorized API token or
|
|
70
|
-
`RAILCODE_API_TOKEN
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
`RAILCODE_API_TOKEN` and prints it to stdout.
|
|
75
|
+
|
|
76
|
+
`railcode db list` and `railcode db query "<sql>"` read the admin-configured data
|
|
77
|
+
connectors from the terminal — listing them (`GET /v1/connections`) or running a
|
|
78
|
+
read-only SQL query against one (`POST /v1/sql`). The engine is inferred from the
|
|
79
|
+
named connector (override with `--engine`); use `--connection <name>` to pick a
|
|
80
|
+
connector, `--params '<json-array>'` for `$1, $2, ...` placeholders, and `--json`
|
|
81
|
+
for the raw response. Authenticates with the saved token or `RAILCODE_API_TOKEN`.
|
|
73
82
|
|
|
74
83
|
`railcode deploy` runs from the current Railcode app directory, builds the app,
|
|
75
84
|
and publishes the inferred app output over HTTP to the canonical API host. It
|
package/dist/index.js
CHANGED
|
@@ -8,14 +8,15 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
|
8
8
|
import process from "node:process";
|
|
9
9
|
import { createInterface } from "node:readline/promises";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
const VERSION = "0.1.1";
|
|
12
11
|
const CLI_PACKAGE_NAME = "railcode";
|
|
13
12
|
const SKILL_NAME = "create-railcode-app";
|
|
14
13
|
const RAILCODE_HOME = process.env.RAILCODE_HOME || join(homedir(), ".railcode");
|
|
15
14
|
const CONFIG_PATH = join(RAILCODE_HOME, "config.json");
|
|
16
15
|
const CLI_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
-
const
|
|
18
|
-
const
|
|
16
|
+
const VERSION = readCliPackageVersion();
|
|
17
|
+
const DEFAULT_DEV_PORT = 7331;
|
|
18
|
+
const DEFAULT_ASSET_DEV_PORT = 5173;
|
|
19
|
+
const PORT_SEARCH_LIMIT = 100;
|
|
19
20
|
const CLI_AUTH_CALLBACK_PATH = "/railcode-cli/callback";
|
|
20
21
|
const CLI_AUTH_TIMEOUT_MS = 5 * 60 * 1000;
|
|
21
22
|
const HELP = `railcode ${VERSION}
|
|
@@ -23,8 +24,11 @@ const HELP = `railcode ${VERSION}
|
|
|
23
24
|
Usage:
|
|
24
25
|
railcode dev [--verbose]
|
|
25
26
|
railcode login [--api-url <url>]
|
|
26
|
-
railcode deploy
|
|
27
|
-
railcode
|
|
27
|
+
railcode deploy [--private | --public]
|
|
28
|
+
railcode access [public|private|restricted] [--users <a,b>]
|
|
29
|
+
railcode db list
|
|
30
|
+
railcode db query "<sql>" [--connection <name>] [--engine <postgres|mysql>]
|
|
31
|
+
railcode get design-system
|
|
28
32
|
railcode init <app>
|
|
29
33
|
railcode upgrade
|
|
30
34
|
railcode skill stamp [path]
|
|
@@ -54,6 +58,15 @@ async function main() {
|
|
|
54
58
|
case "deploy":
|
|
55
59
|
await commandDeploy(args);
|
|
56
60
|
return;
|
|
61
|
+
case "access":
|
|
62
|
+
await commandAccess(args);
|
|
63
|
+
return;
|
|
64
|
+
case "db":
|
|
65
|
+
await commandDb(args);
|
|
66
|
+
return;
|
|
67
|
+
case "get":
|
|
68
|
+
await commandGet(args);
|
|
69
|
+
return;
|
|
57
70
|
case "design-system":
|
|
58
71
|
await commandDesignSystem(args);
|
|
59
72
|
return;
|
|
@@ -110,6 +123,14 @@ function parseArgs(argv) {
|
|
|
110
123
|
}
|
|
111
124
|
return { command, rest, options };
|
|
112
125
|
}
|
|
126
|
+
function readCliPackageVersion() {
|
|
127
|
+
const packagePath = join(CLI_PACKAGE_ROOT, "package.json");
|
|
128
|
+
const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
129
|
+
if (typeof pkg.version !== "string" || !pkg.version) {
|
|
130
|
+
throw new Error(`Missing CLI package version in ${packagePath}`);
|
|
131
|
+
}
|
|
132
|
+
return pkg.version;
|
|
133
|
+
}
|
|
113
134
|
function toCamel(input) {
|
|
114
135
|
return input.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
115
136
|
}
|
|
@@ -125,17 +146,9 @@ async function commandDevProxy(args) {
|
|
|
125
146
|
rmSync(localDevDir(ctx.app), { recursive: true, force: true });
|
|
126
147
|
}
|
|
127
148
|
mkdirSync(localDevDir(ctx.app), { recursive: true });
|
|
128
|
-
if (ctx.
|
|
149
|
+
if (ctx.asset.kind !== "none") {
|
|
129
150
|
await ensureAppDependencies(ctx.root);
|
|
130
151
|
}
|
|
131
|
-
const assetChild = ctx.assetCommand
|
|
132
|
-
? spawn(ctx.assetCommand, {
|
|
133
|
-
cwd: ctx.root,
|
|
134
|
-
env: process.env,
|
|
135
|
-
shell: true,
|
|
136
|
-
stdio: "inherit",
|
|
137
|
-
})
|
|
138
|
-
: null;
|
|
139
152
|
const server = createServer((request, response) => {
|
|
140
153
|
handleDevRequest(ctx, request, response).catch((error) => {
|
|
141
154
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -148,11 +161,26 @@ async function commandDevProxy(args) {
|
|
|
148
161
|
server.on("upgrade", (request, socket, head) => {
|
|
149
162
|
proxyWebSocket(ctx, request, socket, head);
|
|
150
163
|
});
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
let assetProcess = null;
|
|
165
|
+
try {
|
|
166
|
+
ctx.port = await listenOnAvailablePort(server, ctx.portStart, "Railcode dev");
|
|
167
|
+
assetProcess = await startAssetDevServer(ctx);
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (server.listening)
|
|
171
|
+
await closeServer(server);
|
|
172
|
+
assetProcess?.reservation?.release();
|
|
173
|
+
if (assetProcess?.child && !assetProcess.child.killed)
|
|
174
|
+
assetProcess.child.kill("SIGTERM");
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
155
177
|
const localUrl = `http://127.0.0.1:${ctx.port}`;
|
|
178
|
+
if (ctx.port !== ctx.portStart) {
|
|
179
|
+
console.log(`Port ${ctx.portStart} was busy; using ${ctx.port}.`);
|
|
180
|
+
}
|
|
181
|
+
if (ctx.assetPort && ctx.assetPortStart && ctx.assetPort !== ctx.assetPortStart) {
|
|
182
|
+
console.log(`Asset port ${ctx.assetPortStart} was busy; using ${ctx.assetPort}.`);
|
|
183
|
+
}
|
|
156
184
|
printUrlBox(ctx.app, localUrl);
|
|
157
185
|
console.log("");
|
|
158
186
|
// Identity, KV, and files are served locally; LLM and SQL forward to the real
|
|
@@ -173,8 +201,11 @@ async function commandDevProxy(args) {
|
|
|
173
201
|
console.log(`App root: ${ctx.root}`);
|
|
174
202
|
console.log(`KV/files: ${localDevDir(ctx.app)}`);
|
|
175
203
|
console.log(`Prod APIs: ${ctx.apiBase ? `${ctx.apiBase}/v1` : "unconfigured"}`);
|
|
204
|
+
console.log(`Dev port: ${ctx.port}`);
|
|
176
205
|
if (ctx.assetCommand)
|
|
177
206
|
console.log(`App command: ${ctx.assetCommand}`);
|
|
207
|
+
if (ctx.assetTarget)
|
|
208
|
+
console.log(`Asset target: ${ctx.assetTarget}`);
|
|
178
209
|
}
|
|
179
210
|
console.log("");
|
|
180
211
|
await new Promise((resolveRun) => {
|
|
@@ -186,13 +217,20 @@ async function commandDevProxy(args) {
|
|
|
186
217
|
if (code !== 0)
|
|
187
218
|
process.exitCode = code;
|
|
188
219
|
server.close();
|
|
189
|
-
|
|
190
|
-
|
|
220
|
+
assetProcess?.reservation?.release();
|
|
221
|
+
if (assetProcess?.child && !assetProcess.child.killed)
|
|
222
|
+
assetProcess.child.kill("SIGTERM");
|
|
191
223
|
resolveRun();
|
|
192
224
|
};
|
|
193
225
|
process.once("SIGINT", () => shutdown());
|
|
194
226
|
process.once("SIGTERM", () => shutdown());
|
|
195
|
-
|
|
227
|
+
assetProcess?.child.once("error", (error) => {
|
|
228
|
+
console.error(`App command failed to start: ${error.message}`);
|
|
229
|
+
shutdown(1);
|
|
230
|
+
});
|
|
231
|
+
assetProcess?.child.once("exit", (code) => {
|
|
232
|
+
if (closed)
|
|
233
|
+
return;
|
|
196
234
|
if (code && code !== 0)
|
|
197
235
|
console.error(`App command exited with status ${code}`);
|
|
198
236
|
shutdown(code || 0);
|
|
@@ -220,20 +258,21 @@ async function resolveDevContext(args) {
|
|
|
220
258
|
}
|
|
221
259
|
assertAppName(app);
|
|
222
260
|
const root = resolveAppRoot(cwd, app, manifestInfo);
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
const
|
|
261
|
+
const portStart = parsePort(stringOption(args, "port") ?? DEFAULT_DEV_PORT, "--port");
|
|
262
|
+
const assetPortStart = parsePort(stringOption(args, "assetPort") ?? manifest?.dev?.port ?? DEFAULT_ASSET_DEV_PORT, "--asset-port");
|
|
263
|
+
const explicitAssetCommand = stringOption(args, "command") || manifest?.dev?.command;
|
|
264
|
+
const asset = resolveAssetDevConfig(root, explicitAssetCommand, assetPortStart);
|
|
227
265
|
const config = loadConfig();
|
|
228
266
|
const apiBase = apiOrigin(config, args);
|
|
229
267
|
return {
|
|
230
268
|
app,
|
|
231
269
|
root,
|
|
232
|
-
port,
|
|
270
|
+
port: portStart,
|
|
271
|
+
portStart,
|
|
233
272
|
apiBase,
|
|
234
273
|
token: process.env.RAILCODE_API_TOKEN || config.apiToken,
|
|
235
|
-
|
|
236
|
-
|
|
274
|
+
asset,
|
|
275
|
+
assetPortStart: asset.kind === "none" ? undefined : assetPortStart,
|
|
237
276
|
};
|
|
238
277
|
}
|
|
239
278
|
function readRailcodeManifest(start) {
|
|
@@ -328,18 +367,51 @@ function resolveSdkPath() {
|
|
|
328
367
|
return sourceTree;
|
|
329
368
|
throw new CliError(`Railcode SDK file not found. Expected ${bundled}`, 1);
|
|
330
369
|
}
|
|
331
|
-
function
|
|
370
|
+
function resolveAssetDevConfig(root, explicitCommand, targetPort) {
|
|
371
|
+
if (explicitCommand)
|
|
372
|
+
return { kind: "custom", command: explicitCommand, targetPort };
|
|
332
373
|
const packagePath = join(root, "package.json");
|
|
333
374
|
if (!existsSync(packagePath))
|
|
334
|
-
return
|
|
375
|
+
return { kind: "none" };
|
|
335
376
|
const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
336
377
|
if (!pkg.scripts?.dev)
|
|
337
|
-
return
|
|
378
|
+
return { kind: "none" };
|
|
338
379
|
const pm = packageManagerFor(root);
|
|
339
|
-
if (
|
|
340
|
-
return
|
|
380
|
+
if (isViteProject(root))
|
|
381
|
+
return { kind: "vite", packageManager: pm, startPort: targetPort };
|
|
382
|
+
return { kind: "script", command: runScriptCommand(pm, "dev"), targetPort };
|
|
383
|
+
}
|
|
384
|
+
function isViteProject(root) {
|
|
385
|
+
return existsSync(join(root, "vite.config.ts")) || existsSync(join(root, "vite.config.js"));
|
|
386
|
+
}
|
|
387
|
+
async function startAssetDevServer(ctx) {
|
|
388
|
+
if (ctx.asset.kind === "none")
|
|
389
|
+
return null;
|
|
390
|
+
let reservation;
|
|
391
|
+
if (ctx.asset.kind === "vite") {
|
|
392
|
+
reservation = await reserveAvailablePort(ctx.asset.startPort, "asset dev server");
|
|
393
|
+
ctx.assetPort = reservation.port;
|
|
394
|
+
ctx.assetCommand = runScriptCommand(ctx.asset.packageManager, "dev", `--host 127.0.0.1 --port ${reservation.port} --strictPort --logLevel warn`);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
ctx.assetPort = ctx.asset.targetPort;
|
|
398
|
+
ctx.assetCommand = ctx.asset.command;
|
|
341
399
|
}
|
|
342
|
-
|
|
400
|
+
ctx.assetTarget = `http://127.0.0.1:${ctx.assetPort}`;
|
|
401
|
+
return {
|
|
402
|
+
child: spawn(ctx.assetCommand, {
|
|
403
|
+
cwd: ctx.root,
|
|
404
|
+
env: {
|
|
405
|
+
...process.env,
|
|
406
|
+
PORT: String(ctx.assetPort),
|
|
407
|
+
RAILCODE_DEV_PORT: String(ctx.port),
|
|
408
|
+
RAILCODE_ASSET_PORT: String(ctx.assetPort),
|
|
409
|
+
},
|
|
410
|
+
shell: true,
|
|
411
|
+
stdio: "inherit",
|
|
412
|
+
}),
|
|
413
|
+
reservation,
|
|
414
|
+
};
|
|
343
415
|
}
|
|
344
416
|
async function ensureAppDependencies(root) {
|
|
345
417
|
if (!existsSync(join(root, "package.json")))
|
|
@@ -355,7 +427,7 @@ function dependenciesReady(root) {
|
|
|
355
427
|
const nodeModules = join(root, "node_modules");
|
|
356
428
|
if (!existsSync(nodeModules) || !statSync(nodeModules).isDirectory())
|
|
357
429
|
return false;
|
|
358
|
-
if (
|
|
430
|
+
if (isViteProject(root)) {
|
|
359
431
|
return existsSync(join(nodeModules, ".bin", process.platform === "win32" ? "vite.cmd" : "vite"));
|
|
360
432
|
}
|
|
361
433
|
return true;
|
|
@@ -380,6 +452,140 @@ function runScriptCommand(pm, script, passthrough = "") {
|
|
|
380
452
|
return `yarn ${script}${suffix}`;
|
|
381
453
|
return `${pm} run ${script}${passthrough ? ` -- ${passthrough}` : ""}`;
|
|
382
454
|
}
|
|
455
|
+
function parsePort(value, label) {
|
|
456
|
+
const port = typeof value === "number" ? value : Number(value);
|
|
457
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
458
|
+
throw new CliError(`${label} must be a TCP port from 1 to 65535.`, 2);
|
|
459
|
+
}
|
|
460
|
+
return port;
|
|
461
|
+
}
|
|
462
|
+
async function listenOnAvailablePort(server, startPort, label) {
|
|
463
|
+
for (let port = startPort, checked = 0; port <= 65535 && checked < PORT_SEARCH_LIMIT; port += 1, checked += 1) {
|
|
464
|
+
try {
|
|
465
|
+
await listenOnPort(server, port);
|
|
466
|
+
return port;
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
if (errorCode(error) !== "EADDRINUSE")
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
throw new CliError(`${label} could not find an available port starting at ${startPort}.`, 1);
|
|
474
|
+
}
|
|
475
|
+
async function reserveAvailablePort(startPort, label) {
|
|
476
|
+
for (let port = startPort, checked = 0; port <= 65535 && checked < PORT_SEARCH_LIMIT; port += 1, checked += 1) {
|
|
477
|
+
const reservation = reserveRailcodePort(port);
|
|
478
|
+
if (!reservation)
|
|
479
|
+
continue;
|
|
480
|
+
try {
|
|
481
|
+
const available = await canListenOnPort(port);
|
|
482
|
+
if (available)
|
|
483
|
+
return reservation;
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
reservation.release();
|
|
487
|
+
throw error;
|
|
488
|
+
}
|
|
489
|
+
reservation.release();
|
|
490
|
+
}
|
|
491
|
+
throw new CliError(`${label} could not find an available port starting at ${startPort}.`, 1);
|
|
492
|
+
}
|
|
493
|
+
async function canListenOnPort(port) {
|
|
494
|
+
const server = createServer();
|
|
495
|
+
try {
|
|
496
|
+
await listenOnPort(server, port);
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
if (errorCode(error) === "EADDRINUSE")
|
|
501
|
+
return false;
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
if (server.listening)
|
|
506
|
+
await closeServer(server);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
async function listenOnPort(server, port) {
|
|
510
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
511
|
+
const cleanup = () => {
|
|
512
|
+
server.off("error", onError);
|
|
513
|
+
server.off("listening", onListening);
|
|
514
|
+
};
|
|
515
|
+
const onError = (error) => {
|
|
516
|
+
cleanup();
|
|
517
|
+
rejectListen(error);
|
|
518
|
+
};
|
|
519
|
+
const onListening = () => {
|
|
520
|
+
cleanup();
|
|
521
|
+
resolveListen();
|
|
522
|
+
};
|
|
523
|
+
server.once("error", onError);
|
|
524
|
+
server.once("listening", onListening);
|
|
525
|
+
server.listen(port, "127.0.0.1");
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
function reserveRailcodePort(port) {
|
|
529
|
+
const dir = localPortLockDir(port);
|
|
530
|
+
mkdirSync(dirname(dir), { recursive: true });
|
|
531
|
+
try {
|
|
532
|
+
mkdirSync(dir);
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
if (errorCode(error) !== "EEXIST")
|
|
536
|
+
throw error;
|
|
537
|
+
if (!removeStalePortLock(dir))
|
|
538
|
+
return null;
|
|
539
|
+
try {
|
|
540
|
+
mkdirSync(dir);
|
|
541
|
+
}
|
|
542
|
+
catch (retryError) {
|
|
543
|
+
if (errorCode(retryError) === "EEXIST")
|
|
544
|
+
return null;
|
|
545
|
+
throw retryError;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
writeJsonFile(join(dir, "owner.json"), { pid: process.pid, started_at: nowIso() });
|
|
549
|
+
let released = false;
|
|
550
|
+
return {
|
|
551
|
+
port,
|
|
552
|
+
release: () => {
|
|
553
|
+
if (released)
|
|
554
|
+
return;
|
|
555
|
+
released = true;
|
|
556
|
+
const owner = readJsonFile(join(dir, "owner.json"), {});
|
|
557
|
+
if (owner.pid === process.pid)
|
|
558
|
+
rmSync(dir, { recursive: true, force: true });
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function removeStalePortLock(dir) {
|
|
563
|
+
const owner = readJsonFile(join(dir, "owner.json"), {});
|
|
564
|
+
if (typeof owner.pid === "number" && processIsRunning(owner.pid))
|
|
565
|
+
return false;
|
|
566
|
+
rmSync(dir, { recursive: true, force: true });
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
function localPortLockDir(port) {
|
|
570
|
+
return join(RAILCODE_HOME, "dev", ".ports", String(port));
|
|
571
|
+
}
|
|
572
|
+
function processIsRunning(pid) {
|
|
573
|
+
try {
|
|
574
|
+
process.kill(pid, 0);
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
return errorCode(error) === "EPERM";
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
function errorCode(error) {
|
|
582
|
+
return typeof error === "object" &&
|
|
583
|
+
error !== null &&
|
|
584
|
+
"code" in error &&
|
|
585
|
+
typeof error.code === "string"
|
|
586
|
+
? error.code
|
|
587
|
+
: undefined;
|
|
588
|
+
}
|
|
383
589
|
async function handleDevRequest(ctx, request, response) {
|
|
384
590
|
const url = new URL(request.url || "/", "http://127.0.0.1");
|
|
385
591
|
if (url.pathname.startsWith("/_api/")) {
|
|
@@ -1004,13 +1210,22 @@ function escapeHtml(value) {
|
|
|
1004
1210
|
.replaceAll('"', """)
|
|
1005
1211
|
.replaceAll("'", "'");
|
|
1006
1212
|
}
|
|
1213
|
+
const DEPLOY_HELP = `Usage:
|
|
1214
|
+
railcode deploy [--private | --public]
|
|
1215
|
+
|
|
1216
|
+
Run from a Railcode app repo. The first deploy creates the app's access policy:
|
|
1217
|
+
public (anyone signed in) by default, or owner-only with --private. You can also
|
|
1218
|
+
set it declaratively in railcode.json: { "deploy": { "access": "private" } }.
|
|
1219
|
+
Redeploys keep the existing policy; change it later with railcode access.
|
|
1220
|
+
`;
|
|
1007
1221
|
async function commandDeploy(args) {
|
|
1008
1222
|
if (booleanOption(args, "help")) {
|
|
1009
|
-
console.log(
|
|
1223
|
+
console.log(DEPLOY_HELP);
|
|
1010
1224
|
return;
|
|
1011
1225
|
}
|
|
1012
1226
|
rejectDeployArgs(args);
|
|
1013
1227
|
const ctx = resolveDeployContext();
|
|
1228
|
+
const access = resolveDeployAccess(args, ctx);
|
|
1014
1229
|
await buildApp(ctx.appRoot);
|
|
1015
1230
|
const source = resolveDeploySource(ctx);
|
|
1016
1231
|
const files = collectDeployFiles(source);
|
|
@@ -1019,7 +1234,7 @@ async function commandDeploy(args) {
|
|
|
1019
1234
|
const apiUrl = canonicalApiOrigin(authUrl);
|
|
1020
1235
|
let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Deploy");
|
|
1021
1236
|
console.log(`Uploading ${ctx.app} to ${apiUrl}...`);
|
|
1022
|
-
const response = await postDeployBundle(apiUrl, authedConfig, ctx.app, files);
|
|
1237
|
+
const response = await postDeployBundle(apiUrl, authedConfig, ctx.app, files, access);
|
|
1023
1238
|
if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
|
|
1024
1239
|
console.log("Saved login expired. Log in again to continue.");
|
|
1025
1240
|
const retryConfig = { ...authedConfig, apiUrl: authUrl };
|
|
@@ -1028,19 +1243,46 @@ async function commandDeploy(args) {
|
|
|
1028
1243
|
delete retryConfig.cookies;
|
|
1029
1244
|
saveConfig(retryConfig);
|
|
1030
1245
|
const freshConfig = await ensureApiToken(retryConfig, authUrl, "Deploy");
|
|
1031
|
-
|
|
1246
|
+
const retry = await postDeployBundle(apiUrl, freshConfig, ctx.app, files, access);
|
|
1247
|
+
await handleDeployResponse(retry, ctx.app, apiUrl, access);
|
|
1032
1248
|
return;
|
|
1033
1249
|
}
|
|
1034
|
-
await handleDeployResponse(response, ctx.app, apiUrl);
|
|
1250
|
+
await handleDeployResponse(response, ctx.app, apiUrl, access);
|
|
1035
1251
|
}
|
|
1036
1252
|
function rejectDeployArgs(args) {
|
|
1037
1253
|
if (args.rest.length > 0) {
|
|
1038
1254
|
throw new CliError("railcode deploy does not take an app argument. Run it from the Railcode app directory.", 2);
|
|
1039
1255
|
}
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1256
|
+
const allowed = new Set(["private", "public", "help"]);
|
|
1257
|
+
const unknown = Object.keys(args.options).filter((option) => !allowed.has(option));
|
|
1258
|
+
if (unknown.length > 0) {
|
|
1259
|
+
throw new CliError(`Unknown option for deploy: --${unknown[0]}. Configure the Railcode URL in railcode.json or RAILCODE_API_URL.`, 2);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
// First-deploy access: --private/--public win over railcode.json deploy.access.
|
|
1263
|
+
// Returns the canonical policy mode, or undefined to let the server default.
|
|
1264
|
+
function resolveDeployAccess(args, ctx) {
|
|
1265
|
+
const wantsPrivate = booleanOption(args, "private");
|
|
1266
|
+
const wantsPublic = booleanOption(args, "public");
|
|
1267
|
+
if (wantsPrivate && wantsPublic) {
|
|
1268
|
+
throw new CliError("Use either --private or --public, not both.", 2);
|
|
1269
|
+
}
|
|
1270
|
+
if (wantsPrivate)
|
|
1271
|
+
return "private";
|
|
1272
|
+
if (wantsPublic)
|
|
1273
|
+
return "workspace";
|
|
1274
|
+
return ctx.access ? normalizeDeployAccess(ctx.access) : undefined;
|
|
1275
|
+
}
|
|
1276
|
+
function normalizeDeployAccess(value) {
|
|
1277
|
+
const mode = value.trim().toLowerCase();
|
|
1278
|
+
if (mode === "public" || mode === "workspace")
|
|
1279
|
+
return "workspace";
|
|
1280
|
+
if (mode === "private")
|
|
1281
|
+
return "private";
|
|
1282
|
+
if (mode === "restricted") {
|
|
1283
|
+
throw new CliError("deploy.access 'restricted' is not supported at deploy. Deploy, then run railcode access restricted --users <...>.", 2);
|
|
1284
|
+
}
|
|
1285
|
+
throw new CliError(`Invalid deploy.access '${value}'. Use 'public' or 'private'.`, 2);
|
|
1044
1286
|
}
|
|
1045
1287
|
function resolveDeployContext() {
|
|
1046
1288
|
const cwd = process.cwd();
|
|
@@ -1058,6 +1300,7 @@ function resolveDeployContext() {
|
|
|
1058
1300
|
appRoot,
|
|
1059
1301
|
workspaceRoot,
|
|
1060
1302
|
apiUrl: manifest?.deploy?.apiUrl,
|
|
1303
|
+
access: manifest?.deploy?.access,
|
|
1061
1304
|
};
|
|
1062
1305
|
}
|
|
1063
1306
|
async function buildApp(appRoot) {
|
|
@@ -1149,7 +1392,7 @@ function collectDeployFiles(root) {
|
|
|
1149
1392
|
}
|
|
1150
1393
|
return files;
|
|
1151
1394
|
}
|
|
1152
|
-
async function postDeployBundle(apiUrl, config, app, files) {
|
|
1395
|
+
async function postDeployBundle(apiUrl, config, app, files, access) {
|
|
1153
1396
|
try {
|
|
1154
1397
|
return await fetch(`${apiUrl}/v1/apps/${app}/deploy`, {
|
|
1155
1398
|
method: "POST",
|
|
@@ -1157,7 +1400,7 @@ async function postDeployBundle(apiUrl, config, app, files) {
|
|
|
1157
1400
|
Authorization: `Bearer ${config.apiToken}`,
|
|
1158
1401
|
"Content-Type": "application/json",
|
|
1159
1402
|
},
|
|
1160
|
-
body: JSON.stringify({ files }),
|
|
1403
|
+
body: JSON.stringify(access ? { files, access } : { files }),
|
|
1161
1404
|
redirect: "manual",
|
|
1162
1405
|
});
|
|
1163
1406
|
}
|
|
@@ -1165,14 +1408,19 @@ async function postDeployBundle(apiUrl, config, app, files) {
|
|
|
1165
1408
|
throw new CliError(`Could not reach ${apiUrl}. Configure the Railcode URL in railcode.json deploy.apiUrl or RAILCODE_API_URL. ${error instanceof Error ? error.message : String(error)}`, 1);
|
|
1166
1409
|
}
|
|
1167
1410
|
}
|
|
1168
|
-
async function handleDeployResponse(response, app, apiUrl) {
|
|
1411
|
+
async function handleDeployResponse(response, app, apiUrl, requestedAccess) {
|
|
1169
1412
|
if (response.ok) {
|
|
1170
1413
|
const body = await response.json();
|
|
1171
1414
|
console.log(`Deployed ${app}${body.files ? ` (${body.files} files)` : ""}`);
|
|
1172
1415
|
console.log(body.url || appUrlFromOrigin(app, apiUrl));
|
|
1173
1416
|
const accessLabel = deployAccessLabel(body.access);
|
|
1174
|
-
if (accessLabel)
|
|
1417
|
+
if (accessLabel) {
|
|
1175
1418
|
console.log(`Access: ${accessLabel}`);
|
|
1419
|
+
}
|
|
1420
|
+
else if (requestedAccess && body.access === "existing_policy") {
|
|
1421
|
+
const word = requestedAccess === "private" ? "private" : "public";
|
|
1422
|
+
console.log(`Access unchanged (${app} already exists). Run 'railcode access ${word}' to change it.`);
|
|
1423
|
+
}
|
|
1176
1424
|
return;
|
|
1177
1425
|
}
|
|
1178
1426
|
const text = await response.text();
|
|
@@ -1187,8 +1435,156 @@ async function handleDeployResponse(response, app, apiUrl) {
|
|
|
1187
1435
|
function deployAccessLabel(access) {
|
|
1188
1436
|
if (access === "workspace_created")
|
|
1189
1437
|
return "public to signed-in users";
|
|
1438
|
+
if (access === "private_created")
|
|
1439
|
+
return "private (owner only)";
|
|
1190
1440
|
return undefined;
|
|
1191
1441
|
}
|
|
1442
|
+
const ACCESS_HELP = `Usage:
|
|
1443
|
+
railcode access Show the app's current access
|
|
1444
|
+
railcode access public Anyone signed in
|
|
1445
|
+
railcode access private Just you (the owner)
|
|
1446
|
+
railcode access restricted --users a@b.com,c@d.com Named users only
|
|
1447
|
+
|
|
1448
|
+
Run from a Railcode app repo. Deploy the app at least once before changing access.
|
|
1449
|
+
Only the app owner or an admin can change it. 'public' is an alias for 'workspace'.
|
|
1450
|
+
`;
|
|
1451
|
+
// Friendly CLI words mapped to the server's canonical policy modes.
|
|
1452
|
+
const ACCESS_MODES = {
|
|
1453
|
+
public: "workspace",
|
|
1454
|
+
workspace: "workspace",
|
|
1455
|
+
private: "private",
|
|
1456
|
+
restricted: "restricted",
|
|
1457
|
+
};
|
|
1458
|
+
async function commandAccess(args) {
|
|
1459
|
+
if (booleanOption(args, "help") || args.rest[0] === "help") {
|
|
1460
|
+
console.log(ACCESS_HELP);
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const { method, payload } = parseAccessArgs(args);
|
|
1464
|
+
const ctx = resolveDeployContext();
|
|
1465
|
+
const config = loadConfig();
|
|
1466
|
+
const authUrl = await resolveDeployAuthOrigin(config, ctx);
|
|
1467
|
+
const apiUrl = canonicalApiOrigin(authUrl);
|
|
1468
|
+
let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Access");
|
|
1469
|
+
let response = await sendAccessRequest(apiUrl, authedConfig, ctx.app, method, payload);
|
|
1470
|
+
if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
|
|
1471
|
+
console.log("Saved login expired. Log in again to continue.");
|
|
1472
|
+
const retryConfig = { ...authedConfig, apiUrl: authUrl };
|
|
1473
|
+
delete retryConfig.apiToken;
|
|
1474
|
+
delete retryConfig.apiTokenPrefix;
|
|
1475
|
+
delete retryConfig.cookies;
|
|
1476
|
+
saveConfig(retryConfig);
|
|
1477
|
+
authedConfig = await ensureApiToken(retryConfig, authUrl, "Access");
|
|
1478
|
+
response = await sendAccessRequest(apiUrl, authedConfig, ctx.app, method, payload);
|
|
1479
|
+
}
|
|
1480
|
+
await handleAccessResponse(response, ctx.app, method);
|
|
1481
|
+
}
|
|
1482
|
+
function parseAccessArgs(args) {
|
|
1483
|
+
const requested = args.rest[0];
|
|
1484
|
+
if (!requested) {
|
|
1485
|
+
rejectAccessOptions(args, false);
|
|
1486
|
+
return { method: "GET" };
|
|
1487
|
+
}
|
|
1488
|
+
if (args.rest.length > 1) {
|
|
1489
|
+
throw new CliError("railcode access takes a single mode argument.", 2);
|
|
1490
|
+
}
|
|
1491
|
+
const mode = ACCESS_MODES[requested.toLowerCase()];
|
|
1492
|
+
if (!mode) {
|
|
1493
|
+
throw new CliError(`Unknown access mode '${requested}'. Use public, private, or restricted.`, 2);
|
|
1494
|
+
}
|
|
1495
|
+
rejectAccessOptions(args, mode === "restricted");
|
|
1496
|
+
const payload = { mode };
|
|
1497
|
+
if (mode === "restricted") {
|
|
1498
|
+
payload.members = parseMembers(args);
|
|
1499
|
+
if (payload.members.length === 0) {
|
|
1500
|
+
throw new CliError("restricted access needs --users <a@b.com,c@d.com> of existing Railcode users.", 2);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
return { method: "PUT", payload };
|
|
1504
|
+
}
|
|
1505
|
+
function rejectAccessOptions(args, allowUsers) {
|
|
1506
|
+
const allowed = new Set(["help"]);
|
|
1507
|
+
if (allowUsers)
|
|
1508
|
+
allowed.add("users");
|
|
1509
|
+
const unknown = Object.keys(args.options).filter((option) => !allowed.has(option));
|
|
1510
|
+
if (unknown.length > 0) {
|
|
1511
|
+
const hint = unknown[0] === "users" ? " --users only applies to restricted access." : "";
|
|
1512
|
+
throw new CliError(`Unknown option for access: --${unknown[0]}.${hint}`, 2);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
function parseMembers(args) {
|
|
1516
|
+
const raw = stringOption(args, "users");
|
|
1517
|
+
if (!raw)
|
|
1518
|
+
return [];
|
|
1519
|
+
return raw
|
|
1520
|
+
.split(",")
|
|
1521
|
+
.map((member) => member.trim())
|
|
1522
|
+
.filter(Boolean);
|
|
1523
|
+
}
|
|
1524
|
+
async function sendAccessRequest(apiUrl, config, app, method, payload) {
|
|
1525
|
+
if (!config.apiToken) {
|
|
1526
|
+
throw new CliError("Missing Railcode API token. Run from a terminal once, or set RAILCODE_API_TOKEN.", 1);
|
|
1527
|
+
}
|
|
1528
|
+
const headers = { Authorization: `Bearer ${config.apiToken}` };
|
|
1529
|
+
if (method === "PUT")
|
|
1530
|
+
headers["Content-Type"] = "application/json";
|
|
1531
|
+
try {
|
|
1532
|
+
return await fetch(`${apiUrl}/v1/apps/${app}/access`, {
|
|
1533
|
+
method,
|
|
1534
|
+
headers,
|
|
1535
|
+
body: method === "PUT" ? JSON.stringify(payload) : undefined,
|
|
1536
|
+
redirect: "manual",
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
catch (error) {
|
|
1540
|
+
throw new CliError(`Could not reach ${apiUrl}. Configure the Railcode URL in railcode.json deploy.apiUrl or RAILCODE_API_URL. ${error instanceof Error ? error.message : String(error)}`, 1);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
async function handleAccessResponse(response, app, method) {
|
|
1544
|
+
if (response.ok) {
|
|
1545
|
+
printAccess((await response.json()), method === "PUT");
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
const detail = errorDetail(await response.text());
|
|
1549
|
+
if (response.status === 401) {
|
|
1550
|
+
throw new CliError("Access login was rejected. Run the command again to log in.", 1);
|
|
1551
|
+
}
|
|
1552
|
+
if (response.status === 403) {
|
|
1553
|
+
throw new CliError(`Access change denied for ${app}: ${detail}. Only the app owner or an admin can change access.`, 1);
|
|
1554
|
+
}
|
|
1555
|
+
if (response.status === 404 || response.status === 409) {
|
|
1556
|
+
throw new CliError(detail || `App ${app} not found.`, 1);
|
|
1557
|
+
}
|
|
1558
|
+
throw new CliError(`Access ${method === "PUT" ? "update" : "lookup"} failed (${response.status}): ${detail}`, 1);
|
|
1559
|
+
}
|
|
1560
|
+
function printAccess(body, changed) {
|
|
1561
|
+
console.log(`${changed ? "Access set:" : "Access:"} ${accessModeLabel(body.mode)}`);
|
|
1562
|
+
if (body.mode === "restricted" && body.members && body.members.length > 0) {
|
|
1563
|
+
console.log(`Allowed: ${body.members.join(", ")}`);
|
|
1564
|
+
}
|
|
1565
|
+
if (body.url)
|
|
1566
|
+
console.log(body.url);
|
|
1567
|
+
}
|
|
1568
|
+
function accessModeLabel(mode) {
|
|
1569
|
+
if (mode === "workspace")
|
|
1570
|
+
return "public (anyone signed in)";
|
|
1571
|
+
if (mode === "private")
|
|
1572
|
+
return "private (owner only)";
|
|
1573
|
+
if (mode === "restricted")
|
|
1574
|
+
return "restricted (named users only)";
|
|
1575
|
+
return mode || "unknown";
|
|
1576
|
+
}
|
|
1577
|
+
function errorDetail(text) {
|
|
1578
|
+
try {
|
|
1579
|
+
const parsed = JSON.parse(text);
|
|
1580
|
+
if (typeof parsed.detail === "string")
|
|
1581
|
+
return parsed.detail;
|
|
1582
|
+
}
|
|
1583
|
+
catch {
|
|
1584
|
+
// Non-JSON error body; fall through to the raw text.
|
|
1585
|
+
}
|
|
1586
|
+
return text;
|
|
1587
|
+
}
|
|
1192
1588
|
function appUrlFromOrigin(app, origin) {
|
|
1193
1589
|
const url = new URL(origin);
|
|
1194
1590
|
const [first, ...rest] = url.hostname.split(".");
|
|
@@ -1203,18 +1599,296 @@ function appUrlFromOrigin(app, origin) {
|
|
|
1203
1599
|
url.hash = "";
|
|
1204
1600
|
return url.toString();
|
|
1205
1601
|
}
|
|
1602
|
+
const DB_ENGINES = new Set(["postgres", "mysql"]);
|
|
1603
|
+
const DB_HELP = `Query Railcode data connectors.
|
|
1604
|
+
|
|
1605
|
+
Usage:
|
|
1606
|
+
railcode db list
|
|
1607
|
+
railcode db query "<sql>" [options]
|
|
1608
|
+
|
|
1609
|
+
Commands:
|
|
1610
|
+
list List configured data connectors (name + engine).
|
|
1611
|
+
query "<sql>" Run a read-only SQL query against a connector.
|
|
1612
|
+
|
|
1613
|
+
Query options:
|
|
1614
|
+
--connection <name> Connector to query (default: default).
|
|
1615
|
+
--engine <postgres|mysql> Override the engine. Inferred from the connector by default.
|
|
1616
|
+
--params '<json-array>' Params for $1, $2, ... placeholders, e.g. --params '[42,"ok"]'.
|
|
1617
|
+
--file <path> Read SQL from a file instead of an argument.
|
|
1618
|
+
--json Print raw JSON instead of a table.
|
|
1619
|
+
|
|
1620
|
+
Options:
|
|
1621
|
+
--api-url <url> Railcode auth/API URL. Defaults to saved config or railcode.json.
|
|
1622
|
+
|
|
1623
|
+
Examples:
|
|
1624
|
+
railcode db list
|
|
1625
|
+
railcode db query "select id, email from users limit 5"
|
|
1626
|
+
railcode db query "select * from orders where total > $1" --params '[100]'
|
|
1627
|
+
railcode db query "select 1" --connection analytics --engine mysql
|
|
1628
|
+
`;
|
|
1629
|
+
async function commandDb(args) {
|
|
1630
|
+
const subcommand = args.rest[0];
|
|
1631
|
+
const rest = { ...args, rest: args.rest.slice(1) };
|
|
1632
|
+
switch (subcommand) {
|
|
1633
|
+
case "list":
|
|
1634
|
+
case "ls":
|
|
1635
|
+
case "connections":
|
|
1636
|
+
await commandDbList(rest);
|
|
1637
|
+
return;
|
|
1638
|
+
case "query":
|
|
1639
|
+
case "sql":
|
|
1640
|
+
await commandDbQuery(rest);
|
|
1641
|
+
return;
|
|
1642
|
+
case undefined:
|
|
1643
|
+
case "help":
|
|
1644
|
+
console.log(DB_HELP);
|
|
1645
|
+
return;
|
|
1646
|
+
default:
|
|
1647
|
+
if (booleanOption(args, "help")) {
|
|
1648
|
+
console.log(DB_HELP);
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
throw new CliError(`Unknown db command: ${subcommand}\n\n${DB_HELP}`, 2);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
async function commandDbList(args) {
|
|
1655
|
+
if (booleanOption(args, "help") || args.rest[0] === "help") {
|
|
1656
|
+
console.log(DB_HELP);
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
rejectUnknownOptions(args, ["help", "json", "apiUrl"], "db list");
|
|
1660
|
+
if (args.rest.length > 0) {
|
|
1661
|
+
throw new CliError("railcode db list does not accept positional arguments.", 2);
|
|
1662
|
+
}
|
|
1663
|
+
const response = await dbAuthedRequest(args, "Data connectors list", fetchConnections);
|
|
1664
|
+
const connectors = (await readDbResponse(response, "Data connectors list"));
|
|
1665
|
+
printConnectors(connectors, booleanOption(args, "json"));
|
|
1666
|
+
}
|
|
1667
|
+
async function commandDbQuery(args) {
|
|
1668
|
+
if (booleanOption(args, "help") || args.rest[0] === "help") {
|
|
1669
|
+
console.log(DB_HELP);
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
rejectUnknownOptions(args, ["help", "json", "apiUrl", "connection", "engine", "params", "file"], "db query");
|
|
1673
|
+
const query = resolveQuerySql(args);
|
|
1674
|
+
const connection = (stringOption(args, "connection") || "default").trim() || "default";
|
|
1675
|
+
const params = parseSqlParams(args);
|
|
1676
|
+
const engine = parseEngineOption(args) ?? (await resolveConnectionEngine(args, connection));
|
|
1677
|
+
const body = { engine, connection, query, params };
|
|
1678
|
+
const response = await dbAuthedRequest(args, "SQL query", (apiUrl, config) => postSql(apiUrl, config, body));
|
|
1679
|
+
const result = (await readDbResponse(response, "SQL query", "Run `railcode db list` to see configured connectors."));
|
|
1680
|
+
printSqlResult(result, booleanOption(args, "json"));
|
|
1681
|
+
}
|
|
1682
|
+
function resolveQuerySql(args) {
|
|
1683
|
+
const file = stringOption(args, "file");
|
|
1684
|
+
if (file && args.rest.length > 0) {
|
|
1685
|
+
throw new CliError("Pass SQL as an argument or --file, not both.", 2);
|
|
1686
|
+
}
|
|
1687
|
+
if (file) {
|
|
1688
|
+
const path = resolve(file);
|
|
1689
|
+
if (!existsSync(path))
|
|
1690
|
+
throw new CliError(`SQL file not found: ${path}`, 1);
|
|
1691
|
+
const sql = readFileSync(path, "utf8").trim();
|
|
1692
|
+
if (!sql)
|
|
1693
|
+
throw new CliError(`SQL file is empty: ${path}`, 1);
|
|
1694
|
+
return sql;
|
|
1695
|
+
}
|
|
1696
|
+
if (args.rest.length > 1) {
|
|
1697
|
+
throw new CliError('railcode db query takes a single SQL string. Quote it: railcode db query "select 1".', 2);
|
|
1698
|
+
}
|
|
1699
|
+
const positional = args.rest[0];
|
|
1700
|
+
if (!positional || !positional.trim()) {
|
|
1701
|
+
throw new CliError('Missing SQL. Usage: railcode db query "select 1" [--connection <name>].', 2);
|
|
1702
|
+
}
|
|
1703
|
+
return positional.trim();
|
|
1704
|
+
}
|
|
1705
|
+
function parseSqlParams(args) {
|
|
1706
|
+
const raw = stringOption(args, "params");
|
|
1707
|
+
if (raw === undefined)
|
|
1708
|
+
return [];
|
|
1709
|
+
let parsed;
|
|
1710
|
+
try {
|
|
1711
|
+
parsed = JSON.parse(raw);
|
|
1712
|
+
}
|
|
1713
|
+
catch {
|
|
1714
|
+
throw new CliError('--params must be a JSON array, e.g. --params \'[42, "ok"]\'.', 2);
|
|
1715
|
+
}
|
|
1716
|
+
if (!Array.isArray(parsed)) {
|
|
1717
|
+
throw new CliError('--params must be a JSON array, e.g. --params \'[42, "ok"]\'.', 2);
|
|
1718
|
+
}
|
|
1719
|
+
return parsed;
|
|
1720
|
+
}
|
|
1721
|
+
function parseEngineOption(args) {
|
|
1722
|
+
const raw = stringOption(args, "engine");
|
|
1723
|
+
if (raw === undefined)
|
|
1724
|
+
return undefined;
|
|
1725
|
+
const engine = raw.trim().toLowerCase();
|
|
1726
|
+
if (!DB_ENGINES.has(engine)) {
|
|
1727
|
+
throw new CliError(`Unknown engine '${raw}'. Use postgres or mysql.`, 2);
|
|
1728
|
+
}
|
|
1729
|
+
return engine;
|
|
1730
|
+
}
|
|
1731
|
+
// The server requires an engine and rejects a mismatch, so when the caller omits
|
|
1732
|
+
// --engine we look the connection up in the registry and use its engine.
|
|
1733
|
+
async function resolveConnectionEngine(args, connection) {
|
|
1734
|
+
const response = await dbAuthedRequest(args, "Data connectors list", fetchConnections);
|
|
1735
|
+
const connectors = (await readDbResponse(response, "Data connectors list"));
|
|
1736
|
+
const match = connectors.find((c) => c.name === connection);
|
|
1737
|
+
if (!match) {
|
|
1738
|
+
const names = connectors.map((c) => c.name).join(", ") || "none";
|
|
1739
|
+
throw new CliError(`No data connector named '${connection}'. Configured: ${names}. Pass --engine to override.`, 1);
|
|
1740
|
+
}
|
|
1741
|
+
return match.engine;
|
|
1742
|
+
}
|
|
1743
|
+
async function dbAuthedRequest(args, action, send) {
|
|
1744
|
+
const config = loadConfig();
|
|
1745
|
+
const authUrl = await resolveDbAuthOrigin(config, args);
|
|
1746
|
+
const apiUrl = canonicalApiOrigin(authUrl);
|
|
1747
|
+
let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, action);
|
|
1748
|
+
let response = await send(apiUrl, authedConfig);
|
|
1749
|
+
if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
|
|
1750
|
+
console.log("Saved login expired. Log in again to continue.");
|
|
1751
|
+
const retryConfig = { ...authedConfig, apiUrl: authUrl };
|
|
1752
|
+
delete retryConfig.apiToken;
|
|
1753
|
+
delete retryConfig.apiTokenPrefix;
|
|
1754
|
+
delete retryConfig.cookies;
|
|
1755
|
+
saveConfig(retryConfig);
|
|
1756
|
+
authedConfig = await ensureApiToken(retryConfig, authUrl, action);
|
|
1757
|
+
response = await send(apiUrl, authedConfig);
|
|
1758
|
+
}
|
|
1759
|
+
return response;
|
|
1760
|
+
}
|
|
1761
|
+
async function resolveDbAuthOrigin(config, args) {
|
|
1762
|
+
const manifestApiUrl = readRailcodeManifest(process.cwd())?.manifest.deploy?.apiUrl;
|
|
1763
|
+
return resolveRequiredAuthOrigin({
|
|
1764
|
+
config,
|
|
1765
|
+
args,
|
|
1766
|
+
action: "Data connectors",
|
|
1767
|
+
fallbackApiUrl: manifestApiUrl,
|
|
1768
|
+
missingUrlHint: "Pass --api-url <url>, set deploy.apiUrl in railcode.json, or set RAILCODE_API_URL.",
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
function fetchConnections(apiUrl, config) {
|
|
1772
|
+
return dbFetch(apiUrl, config, "/v1/connections", { redirect: "manual" });
|
|
1773
|
+
}
|
|
1774
|
+
function postSql(apiUrl, config, body) {
|
|
1775
|
+
return dbFetch(apiUrl, config, "/v1/sql", {
|
|
1776
|
+
method: "POST",
|
|
1777
|
+
headers: { "Content-Type": "application/json" },
|
|
1778
|
+
body: JSON.stringify(body),
|
|
1779
|
+
redirect: "manual",
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
async function dbFetch(apiUrl, config, path, init) {
|
|
1783
|
+
if (!config.apiToken) {
|
|
1784
|
+
throw new CliError("Missing Railcode API token. Run from a terminal once, or set RAILCODE_API_TOKEN.", 1);
|
|
1785
|
+
}
|
|
1786
|
+
const headers = {
|
|
1787
|
+
...(init.headers ?? {}),
|
|
1788
|
+
Authorization: `Bearer ${config.apiToken}`,
|
|
1789
|
+
};
|
|
1790
|
+
try {
|
|
1791
|
+
return await fetch(`${apiUrl}${path}`, { ...init, headers });
|
|
1792
|
+
}
|
|
1793
|
+
catch (error) {
|
|
1794
|
+
throw new CliError(`Could not reach ${apiUrl}. Configure the Railcode URL in railcode.json deploy.apiUrl, RAILCODE_API_URL, or --api-url. ${error instanceof Error ? error.message : String(error)}`, 1);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
async function readDbResponse(response, action, hint) {
|
|
1798
|
+
if (response.ok)
|
|
1799
|
+
return response.json();
|
|
1800
|
+
const detail = errorDetail(await response.text());
|
|
1801
|
+
if (response.status === 401) {
|
|
1802
|
+
throw new CliError(`${action} login was rejected. Run the command again to log in.`, 1);
|
|
1803
|
+
}
|
|
1804
|
+
if (response.status === 403) {
|
|
1805
|
+
throw new CliError(`${action} denied: ${detail}`, 1);
|
|
1806
|
+
}
|
|
1807
|
+
if (response.status === 502) {
|
|
1808
|
+
throw new CliError(detail || "Database unreachable.", 1);
|
|
1809
|
+
}
|
|
1810
|
+
if (response.status === 400 || response.status === 404) {
|
|
1811
|
+
throw new CliError([detail, hint].filter(Boolean).join(" ") || `${action} failed.`, 1);
|
|
1812
|
+
}
|
|
1813
|
+
throw new CliError(`${action} failed (${response.status}): ${detail}`, 1);
|
|
1814
|
+
}
|
|
1815
|
+
function printConnectors(connectors, asJson) {
|
|
1816
|
+
if (asJson) {
|
|
1817
|
+
console.log(JSON.stringify(connectors, null, 2));
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
if (!connectors || connectors.length === 0) {
|
|
1821
|
+
console.log("No data connectors configured.");
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
console.log(formatTable(["NAME", "ENGINE"], connectors.map((c) => [c.name, c.engine])));
|
|
1825
|
+
}
|
|
1826
|
+
function printSqlResult(result, asJson) {
|
|
1827
|
+
if (asJson) {
|
|
1828
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
const columns = result.columns ?? [];
|
|
1832
|
+
const rows = result.rows ?? [];
|
|
1833
|
+
if (columns.length > 0) {
|
|
1834
|
+
console.log(formatTable(columns, rows));
|
|
1835
|
+
}
|
|
1836
|
+
const count = typeof result.rowcount === "number" ? result.rowcount : rows.length;
|
|
1837
|
+
console.log(`${columns.length > 0 ? "\n" : ""}${count} ${count === 1 ? "row" : "rows"}${result.truncated ? " (truncated to first 1000)" : ""}`);
|
|
1838
|
+
}
|
|
1839
|
+
function rejectUnknownOptions(args, allowed, label) {
|
|
1840
|
+
const set = new Set(allowed);
|
|
1841
|
+
const unknown = Object.keys(args.options).filter((option) => !set.has(option));
|
|
1842
|
+
if (unknown.length > 0) {
|
|
1843
|
+
throw new CliError(`Unknown option for ${label}: --${unknown[0]}`, 2);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
function formatTable(columns, rows) {
|
|
1847
|
+
const header = columns.map((c) => c ?? "");
|
|
1848
|
+
const body = rows.map((row) => header.map((_, i) => formatCell(row?.[i])));
|
|
1849
|
+
const widths = header.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length), 0));
|
|
1850
|
+
const renderRow = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").replace(/\s+$/, "");
|
|
1851
|
+
const lines = [renderRow(header), renderRow(widths.map((w) => "-".repeat(w)))];
|
|
1852
|
+
for (const row of body)
|
|
1853
|
+
lines.push(renderRow(row));
|
|
1854
|
+
return lines.join("\n");
|
|
1855
|
+
}
|
|
1856
|
+
function formatCell(value) {
|
|
1857
|
+
if (value === null || value === undefined)
|
|
1858
|
+
return "";
|
|
1859
|
+
const text = typeof value === "string"
|
|
1860
|
+
? value
|
|
1861
|
+
: typeof value === "object"
|
|
1862
|
+
? JSON.stringify(value)
|
|
1863
|
+
: String(value);
|
|
1864
|
+
return text.replace(/[\r\n\t]+/g, " ");
|
|
1865
|
+
}
|
|
1206
1866
|
const DESIGN_SYSTEM_HELP = `Usage:
|
|
1207
|
-
railcode design-system
|
|
1208
|
-
railcode pull design-system [output.md] [--output output.md]
|
|
1867
|
+
railcode get design-system
|
|
1209
1868
|
|
|
1210
1869
|
Options:
|
|
1211
1870
|
--api-url <url> Railcode auth/API URL. Defaults to saved config, or prompts if missing.
|
|
1212
|
-
|
|
1871
|
+
|
|
1872
|
+
Compatibility aliases:
|
|
1873
|
+
railcode design-system pull
|
|
1874
|
+
railcode pull design-system
|
|
1213
1875
|
`;
|
|
1876
|
+
async function commandGet(args) {
|
|
1877
|
+
const target = args.rest[0];
|
|
1878
|
+
if (target === "design-system") {
|
|
1879
|
+
await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
if (!target || target === "help" || booleanOption(args, "help")) {
|
|
1883
|
+
console.log(DESIGN_SYSTEM_HELP);
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
throw new CliError(`Unknown get target: ${target}\n\n${DESIGN_SYSTEM_HELP}`, 2);
|
|
1887
|
+
}
|
|
1214
1888
|
async function commandPull(args) {
|
|
1215
1889
|
const target = args.rest[0];
|
|
1216
1890
|
if (target === "design-system") {
|
|
1217
|
-
await
|
|
1891
|
+
await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
|
|
1218
1892
|
return;
|
|
1219
1893
|
}
|
|
1220
1894
|
if (!target || target === "help" || booleanOption(args, "help")) {
|
|
@@ -1226,7 +1900,11 @@ async function commandPull(args) {
|
|
|
1226
1900
|
async function commandDesignSystem(args) {
|
|
1227
1901
|
const subcommand = args.rest[0];
|
|
1228
1902
|
if (subcommand === "pull") {
|
|
1229
|
-
await
|
|
1903
|
+
await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
if (subcommand === "get") {
|
|
1907
|
+
await commandDesignSystemGet({ ...args, rest: args.rest.slice(1) });
|
|
1230
1908
|
return;
|
|
1231
1909
|
}
|
|
1232
1910
|
if (!subcommand || subcommand === "help" || booleanOption(args, "help")) {
|
|
@@ -1235,16 +1913,16 @@ async function commandDesignSystem(args) {
|
|
|
1235
1913
|
}
|
|
1236
1914
|
throw new CliError(`Unknown design-system command: ${subcommand}\n\n${DESIGN_SYSTEM_HELP}`, 2);
|
|
1237
1915
|
}
|
|
1238
|
-
async function
|
|
1916
|
+
async function commandDesignSystemGet(args) {
|
|
1239
1917
|
if (args.rest[0] === "help" || booleanOption(args, "help")) {
|
|
1240
1918
|
console.log(DESIGN_SYSTEM_HELP);
|
|
1241
1919
|
return;
|
|
1242
1920
|
}
|
|
1243
|
-
|
|
1921
|
+
rejectDesignSystemGetArgs(args);
|
|
1244
1922
|
const config = loadConfig();
|
|
1245
1923
|
const authUrl = await resolveDesignSystemAuthOrigin(config, args);
|
|
1246
1924
|
const apiUrl = canonicalApiOrigin(authUrl);
|
|
1247
|
-
let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Design system
|
|
1925
|
+
let authedConfig = await ensureApiToken({ ...config, apiUrl: authUrl }, authUrl, "Design system get");
|
|
1248
1926
|
let response = await getDesignSystem(apiUrl, authedConfig);
|
|
1249
1927
|
if (response.status === 401 && !process.env.RAILCODE_API_TOKEN && process.stdin.isTTY) {
|
|
1250
1928
|
console.log("Saved login expired. Log in again to continue.");
|
|
@@ -1253,39 +1931,27 @@ async function commandDesignSystemPull(args) {
|
|
|
1253
1931
|
delete retryConfig.apiTokenPrefix;
|
|
1254
1932
|
delete retryConfig.cookies;
|
|
1255
1933
|
saveConfig(retryConfig);
|
|
1256
|
-
authedConfig = await ensureApiToken(retryConfig, authUrl, "Design system
|
|
1934
|
+
authedConfig = await ensureApiToken(retryConfig, authUrl, "Design system get");
|
|
1257
1935
|
response = await getDesignSystem(apiUrl, authedConfig);
|
|
1258
1936
|
}
|
|
1259
|
-
|
|
1260
|
-
writeDesignSystemMarkdown(designSystem.markdown, designSystemOutput(args));
|
|
1937
|
+
writeDesignSystemMarkdown(await handleDesignSystemResponse(response));
|
|
1261
1938
|
}
|
|
1262
|
-
function
|
|
1263
|
-
const allowedOptions = new Set(["apiUrl", "help"
|
|
1939
|
+
function rejectDesignSystemGetArgs(args) {
|
|
1940
|
+
const allowedOptions = new Set(["apiUrl", "help"]);
|
|
1264
1941
|
const unknown = Object.keys(args.options).filter((option) => !allowedOptions.has(option));
|
|
1265
1942
|
if (unknown.length > 0) {
|
|
1266
|
-
throw new CliError(`Unknown option for design-system
|
|
1943
|
+
throw new CliError(`Unknown option for design-system get: --${unknown[0]}`, 2);
|
|
1267
1944
|
}
|
|
1268
|
-
if (args.rest.length >
|
|
1269
|
-
throw new CliError("design-system
|
|
1270
|
-
}
|
|
1271
|
-
if (args.rest.length === 1 && typeof args.options.output === "string") {
|
|
1272
|
-
throw new CliError("Pass the output path either positionally or with --output, not both.", 2);
|
|
1273
|
-
}
|
|
1274
|
-
if (args.options.output === true) {
|
|
1275
|
-
throw new CliError("--output requires a file path.", 2);
|
|
1945
|
+
if (args.rest.length > 0) {
|
|
1946
|
+
throw new CliError("railcode get design-system does not accept positional arguments.", 2);
|
|
1276
1947
|
}
|
|
1277
1948
|
}
|
|
1278
|
-
function designSystemOutput(args) {
|
|
1279
|
-
if (typeof args.options.output === "string")
|
|
1280
|
-
return args.options.output;
|
|
1281
|
-
return args.rest[0];
|
|
1282
|
-
}
|
|
1283
1949
|
async function resolveDesignSystemAuthOrigin(config, args) {
|
|
1284
1950
|
const manifestApiUrl = readRailcodeManifest(process.cwd())?.manifest.deploy?.apiUrl;
|
|
1285
1951
|
return resolveRequiredAuthOrigin({
|
|
1286
1952
|
config,
|
|
1287
1953
|
args,
|
|
1288
|
-
action: "Design system
|
|
1954
|
+
action: "Design system get",
|
|
1289
1955
|
fallbackApiUrl: manifestApiUrl,
|
|
1290
1956
|
missingUrlHint: "Pass --api-url <url>, set deploy.apiUrl in railcode.json, or set RAILCODE_API_URL.",
|
|
1291
1957
|
});
|
|
@@ -1306,35 +1972,30 @@ async function getDesignSystem(apiUrl, config) {
|
|
|
1306
1972
|
}
|
|
1307
1973
|
async function handleDesignSystemResponse(response) {
|
|
1308
1974
|
if (response.ok) {
|
|
1309
|
-
const
|
|
1310
|
-
|
|
1311
|
-
|
|
1975
|
+
const text = await response.text();
|
|
1976
|
+
const contentType = response.headers.get("content-type") || "";
|
|
1977
|
+
if (contentType.includes("application/json")) {
|
|
1978
|
+
const body = JSON.parse(text);
|
|
1979
|
+
if (typeof body.markdown !== "string") {
|
|
1980
|
+
throw new CliError("Design system response did not include markdown.", 1);
|
|
1981
|
+
}
|
|
1982
|
+
return body.markdown;
|
|
1312
1983
|
}
|
|
1313
|
-
return
|
|
1314
|
-
markdown: body.markdown,
|
|
1315
|
-
updated_at: body.updated_at ?? null,
|
|
1316
|
-
};
|
|
1984
|
+
return text;
|
|
1317
1985
|
}
|
|
1318
1986
|
const text = await response.text();
|
|
1319
1987
|
if (response.status === 401) {
|
|
1320
|
-
throw new CliError("Design system
|
|
1988
|
+
throw new CliError("Design system get login was rejected. Run the command again to log in.", 1);
|
|
1321
1989
|
}
|
|
1322
1990
|
if (response.status === 403) {
|
|
1323
|
-
throw new CliError(`Design system
|
|
1991
|
+
throw new CliError(`Design system get denied: ${text}`, 1);
|
|
1324
1992
|
}
|
|
1325
|
-
throw new CliError(`Design system
|
|
1993
|
+
throw new CliError(`Design system get failed (${response.status}): ${text}`, 1);
|
|
1326
1994
|
}
|
|
1327
|
-
function writeDesignSystemMarkdown(markdown
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
process.stdout.write("\n");
|
|
1332
|
-
return;
|
|
1333
|
-
}
|
|
1334
|
-
const path = resolve(output);
|
|
1335
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1336
|
-
writeFileSync(path, markdown);
|
|
1337
|
-
console.log(`Wrote ${relativeLabel(process.cwd(), path)}`);
|
|
1995
|
+
function writeDesignSystemMarkdown(markdown) {
|
|
1996
|
+
process.stdout.write(markdown);
|
|
1997
|
+
if (markdown && !markdown.endsWith("\n"))
|
|
1998
|
+
process.stdout.write("\n");
|
|
1338
1999
|
}
|
|
1339
2000
|
async function commandInit(args) {
|
|
1340
2001
|
const app = args.rest[0];
|
package/package.json
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"@tailwindcss/vite": "4.3.1",
|
|
18
|
+
"@types/node": "26.0.0",
|
|
18
19
|
"@types/react": "19.2.17",
|
|
19
20
|
"@types/react-dom": "19.2.3",
|
|
20
21
|
"@vitejs/plugin-react": "6.0.2",
|
|
@@ -779,6 +780,16 @@
|
|
|
779
780
|
"tslib": "^2.4.0"
|
|
780
781
|
}
|
|
781
782
|
},
|
|
783
|
+
"node_modules/@types/node": {
|
|
784
|
+
"version": "26.0.0",
|
|
785
|
+
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz",
|
|
786
|
+
"integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==",
|
|
787
|
+
"dev": true,
|
|
788
|
+
"license": "MIT",
|
|
789
|
+
"dependencies": {
|
|
790
|
+
"undici-types": "~8.3.0"
|
|
791
|
+
}
|
|
792
|
+
},
|
|
782
793
|
"node_modules/@types/react": {
|
|
783
794
|
"version": "19.2.17",
|
|
784
795
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
|
@@ -1397,6 +1408,13 @@
|
|
|
1397
1408
|
"node": ">=14.17"
|
|
1398
1409
|
}
|
|
1399
1410
|
},
|
|
1411
|
+
"node_modules/undici-types": {
|
|
1412
|
+
"version": "8.3.0",
|
|
1413
|
+
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
|
1414
|
+
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
|
1415
|
+
"dev": true,
|
|
1416
|
+
"license": "MIT"
|
|
1417
|
+
},
|
|
1400
1418
|
"node_modules/vite": {
|
|
1401
1419
|
"version": "8.0.16",
|
|
1402
1420
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
package/static/sdk.js
CHANGED
|
@@ -372,7 +372,16 @@
|
|
|
372
372
|
var databaseConnectors = dataConnectors;
|
|
373
373
|
|
|
374
374
|
// src/design-system.ts
|
|
375
|
-
var designSystem = () => track("design-system", "designSystem()", () =>
|
|
375
|
+
var designSystem = () => track("design-system", "designSystem()", async () => {
|
|
376
|
+
const response = await call("GET", "/config/design-system");
|
|
377
|
+
if (typeof response === "string") return response;
|
|
378
|
+
if (response && typeof response === "object") {
|
|
379
|
+
const body = response;
|
|
380
|
+
if (typeof body.markdown === "string") return body.markdown;
|
|
381
|
+
if (typeof body.text === "function") return body.text();
|
|
382
|
+
}
|
|
383
|
+
throw new Error("Design system response did not include markdown.");
|
|
384
|
+
});
|
|
376
385
|
|
|
377
386
|
// src/files.ts
|
|
378
387
|
var files = {
|