railcode 0.1.13 → 0.1.16
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 +20 -6
- package/dist/index.js +3217 -173
- package/dist/llm.js +20 -0
- package/dist/manifest.js +695 -0
- package/dist/query.js +94 -0
- package/package.json +2 -2
- package/static/react-template-assets/bigquery.png +0 -0
- package/static/react-template-assets/connectors/clerk.png +0 -0
- package/static/react-template-assets/connectors/mixpanel.png +0 -0
- package/static/react-template-assets/connectors/posthog.svg +1 -0
- package/static/react-template-assets/connectors/resend.svg +1 -0
- package/static/react-template-assets/connectors/stripe.png +0 -0
- package/static/react-template-assets/llm/anthropic.png +0 -0
- package/static/react-template-assets/llm/bedrock.png +0 -0
- package/static/react-template-assets/llm/gemini.png +0 -0
- package/static/react-template-assets/llm/openai.svg +1 -0
- package/static/react-template-assets/postgres.svg +1 -0
- package/static/sdk.js +29 -10
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { cpSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { createServer, request as httpRequest, } from "node:http";
|
|
@@ -12,7 +12,10 @@ import { createInterface } from "node:readline/promises";
|
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { KvQueryError, kvCount, kvQuery, } from "./dev/kv-query.js";
|
|
14
14
|
import { DbError, formatTable, inferEngine, parseEngine, parseSqlParams, pickSqlSource, } from "./db.js";
|
|
15
|
+
import { QueryCmdError, paramSignature, parseParamDecls, parseRunParams, pickAuthoringSql, } from "./query.js";
|
|
16
|
+
import { APP_MANIFEST_NAME, ManifestParseError, documentOperations, parseManifestYaml, renderManifestDeployOutcome, renderManifestSummary, } from "./manifest.js";
|
|
15
17
|
import { ConnectorError, buildConnectorRequest, connectorOpenapiOutput, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderConnectorDocs, renderProxyResponse, } from "./connectors.js";
|
|
18
|
+
import { llmModelTable, llmProviderTable } from "./llm.js";
|
|
16
19
|
// ---------------------------------------------------------------------------
|
|
17
20
|
// Constants
|
|
18
21
|
// ---------------------------------------------------------------------------
|
|
@@ -30,12 +33,16 @@ Developer CLI for the multi-tenant Railcode platform.
|
|
|
30
33
|
|
|
31
34
|
Usage:
|
|
32
35
|
railcode login [--api-url <url>] Sign in and mint a personal API token
|
|
33
|
-
railcode
|
|
36
|
+
railcode login --setup-token <token> Sign in with a one-time onboarding setup token
|
|
37
|
+
railcode deploy [--private] Build (if configured) and deploy the app here
|
|
34
38
|
railcode dev [--port <n>] [--reset] Run the app locally against an emulated /_api
|
|
35
|
-
railcode init <app> [--template static
|
|
39
|
+
railcode init <app> [--template react|static]
|
|
36
40
|
railcode design-system Print your org's design-system guidance (markdown)
|
|
37
41
|
railcode db <list|query> ... List data connectors / run read-only SQL
|
|
42
|
+
railcode query <list|run|create|update|delete> ... Saved queries: invoke by name / author (admin)
|
|
38
43
|
railcode connector <list|docs|fetch> ... List connectors / show API docs / proxy a call
|
|
44
|
+
railcode llm <providers|models> List the LLM providers/models apps can call
|
|
45
|
+
railcode manifest <validate|show> ... Validate ${APP_MANIFEST_NAME} locally / show an app's ratified manifest
|
|
39
46
|
railcode --version
|
|
40
47
|
railcode --help
|
|
41
48
|
|
|
@@ -76,10 +83,20 @@ async function main() {
|
|
|
76
83
|
case "db":
|
|
77
84
|
await commandDb(args);
|
|
78
85
|
return;
|
|
86
|
+
case "query":
|
|
87
|
+
case "queries":
|
|
88
|
+
await commandQuery(args);
|
|
89
|
+
return;
|
|
79
90
|
case "connector":
|
|
80
91
|
case "connectors":
|
|
81
92
|
await commandConnector(args);
|
|
82
93
|
return;
|
|
94
|
+
case "llm":
|
|
95
|
+
await commandLlm(args);
|
|
96
|
+
return;
|
|
97
|
+
case "manifest":
|
|
98
|
+
await commandManifest(args);
|
|
99
|
+
return;
|
|
83
100
|
default:
|
|
84
101
|
throw new CliError(`Unknown command: ${args.command}\n\n${HELP}`, 2);
|
|
85
102
|
}
|
|
@@ -101,13 +118,13 @@ function parseArgs(argv) {
|
|
|
101
118
|
const withoutPrefix = item.slice(2);
|
|
102
119
|
const eq = withoutPrefix.indexOf("=");
|
|
103
120
|
if (eq !== -1) {
|
|
104
|
-
options
|
|
121
|
+
setOption(options, toCamel(withoutPrefix.slice(0, eq)), withoutPrefix.slice(eq + 1));
|
|
105
122
|
continue;
|
|
106
123
|
}
|
|
107
124
|
const key = toCamel(withoutPrefix);
|
|
108
125
|
const next = tail[i + 1];
|
|
109
126
|
if (next && !next.startsWith("--")) {
|
|
110
|
-
options
|
|
127
|
+
setOption(options, key, next);
|
|
111
128
|
i += 1;
|
|
112
129
|
}
|
|
113
130
|
else {
|
|
@@ -116,6 +133,20 @@ function parseArgs(argv) {
|
|
|
116
133
|
}
|
|
117
134
|
return { command, rest, options };
|
|
118
135
|
}
|
|
136
|
+
// A string option given twice accumulates into an array (optionString then
|
|
137
|
+
// rejects it with "can only be given once"); a repeated flag just stays true.
|
|
138
|
+
function setOption(options, key, value) {
|
|
139
|
+
const existing = options[key];
|
|
140
|
+
if (typeof existing === "string") {
|
|
141
|
+
options[key] = [existing, value];
|
|
142
|
+
}
|
|
143
|
+
else if (Array.isArray(existing)) {
|
|
144
|
+
existing.push(value);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
options[key] = value;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
119
150
|
function toCamel(input) {
|
|
120
151
|
return input.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
121
152
|
}
|
|
@@ -128,6 +159,11 @@ function readCliPackageVersion() {
|
|
|
128
159
|
// ---------------------------------------------------------------------------
|
|
129
160
|
async function commandLogin(args) {
|
|
130
161
|
const saved = loadConfig();
|
|
162
|
+
const setupToken = optionString(args, "setupToken");
|
|
163
|
+
if (setupToken) {
|
|
164
|
+
await loginWithSetupToken(args, saved, setupToken);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
131
167
|
const apiUrl = await resolveLoginApiUrl(args, saved);
|
|
132
168
|
const serverConfig = (await readJsonOrThrow(await apiFetch(apiUrl, "/api/config", {}), "Server config"));
|
|
133
169
|
const minted = await authorizeCliWithBrowser(apiUrl);
|
|
@@ -163,6 +199,41 @@ async function commandLogin(args) {
|
|
|
163
199
|
}
|
|
164
200
|
console.log(`Token ${minted.token_prefix}... saved to ${CONFIG_PATH}.`);
|
|
165
201
|
}
|
|
202
|
+
// Onboarding path: exchange a short-lived, one-time setup token (minted by the
|
|
203
|
+
// user's browser session) for a personal API token — no TTY or browser needed, so
|
|
204
|
+
// a coding agent can run it. Writes the same config.json as browser login.
|
|
205
|
+
async function loginWithSetupToken(args, saved, setupToken) {
|
|
206
|
+
const apiUrl = normalizeApiOrigin(optionApiUrl(args) || process.env.RAILCODE_API_URL || saved.apiUrl || DEFAULT_API_URL);
|
|
207
|
+
const response = await apiFetch(apiUrl, "/api/auth/setup-token/redeem", {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { "Content-Type": "application/json" },
|
|
210
|
+
body: JSON.stringify({ setup_token: setupToken }),
|
|
211
|
+
});
|
|
212
|
+
if (response.status === 400 || response.status === 401) {
|
|
213
|
+
throw new CliError(`Setup token was rejected (${await errorDetail(response)}). Setup tokens are ` +
|
|
214
|
+
"one-time and expire after ~10 minutes — generate a fresh prompt from the " +
|
|
215
|
+
"Railcode dashboard and try again.", 1);
|
|
216
|
+
}
|
|
217
|
+
const redeemed = (await readJsonOrThrow(response, "Setup token exchange"));
|
|
218
|
+
if (!redeemed.email || !redeemed.token || !redeemed.token_prefix) {
|
|
219
|
+
throw new CliError("Setup token exchange did not include an API token.", 1);
|
|
220
|
+
}
|
|
221
|
+
saveConfig({
|
|
222
|
+
...saved,
|
|
223
|
+
apiUrl,
|
|
224
|
+
email: redeemed.email,
|
|
225
|
+
apiToken: redeemed.token,
|
|
226
|
+
tokenPrefix: redeemed.token_prefix,
|
|
227
|
+
orgUuid: redeemed.organization_uuid,
|
|
228
|
+
orgSlug: redeemed.organization_slug,
|
|
229
|
+
appHostStrategy: redeemed.app_host_strategy || "two_label",
|
|
230
|
+
});
|
|
231
|
+
console.log(`Logged in as ${redeemed.email} (${apiUrl}).`);
|
|
232
|
+
if (redeemed.organization_slug) {
|
|
233
|
+
console.log(`Organization: ${redeemed.organization_slug}`);
|
|
234
|
+
}
|
|
235
|
+
console.log(`Token ${redeemed.token_prefix}... saved to ${CONFIG_PATH}.`);
|
|
236
|
+
}
|
|
166
237
|
async function authorizeCliWithBrowser(apiUrl) {
|
|
167
238
|
if (!process.stdin.isTTY) {
|
|
168
239
|
throw new CliError("Browser login requires a TTY. Set RAILCODE_API_TOKEN in non-interactive environments.", 2);
|
|
@@ -174,9 +245,15 @@ async function authorizeCliWithBrowser(apiUrl) {
|
|
|
174
245
|
redirect_uri: callback.redirectUri,
|
|
175
246
|
state,
|
|
176
247
|
})}`;
|
|
177
|
-
console.log("Open this link to authorize the Railcode CLI:");
|
|
178
|
-
console.log(authorizeUrl);
|
|
179
248
|
console.log("");
|
|
249
|
+
printUrlBox("Railcode login", authorizeUrl);
|
|
250
|
+
console.log("");
|
|
251
|
+
if (openBrowser(authorizeUrl)) {
|
|
252
|
+
console.log("Opening your browser. If it does not open, paste the URL above.");
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
console.log("Paste the URL above into your browser to continue.");
|
|
256
|
+
}
|
|
180
257
|
console.log("Waiting for browser authorization...");
|
|
181
258
|
const code = await callback.code;
|
|
182
259
|
return await exchangeCliAuthCode(apiUrl, code);
|
|
@@ -267,6 +344,25 @@ async function startCliAuthCallback(expectedState) {
|
|
|
267
344
|
},
|
|
268
345
|
};
|
|
269
346
|
}
|
|
347
|
+
function openBrowser(url) {
|
|
348
|
+
const opener = process.platform === "darwin"
|
|
349
|
+
? { command: "open", args: [url] }
|
|
350
|
+
: process.platform === "win32"
|
|
351
|
+
? { command: "cmd", args: ["/c", "start", "", url] }
|
|
352
|
+
: { command: "xdg-open", args: [url] };
|
|
353
|
+
try {
|
|
354
|
+
const child = spawn(opener.command, opener.args, {
|
|
355
|
+
detached: true,
|
|
356
|
+
stdio: "ignore",
|
|
357
|
+
});
|
|
358
|
+
child.on("error", () => { });
|
|
359
|
+
child.unref();
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
270
366
|
function sendBrowserCallback(response, status, message) {
|
|
271
367
|
response.writeHead(status, { "Content-Type": "text/html" });
|
|
272
368
|
response.end(`<!doctype html><title>Railcode CLI</title><p>${escapeHtml(message)}</p>`);
|
|
@@ -324,36 +420,90 @@ async function commandDeploy(args) {
|
|
|
324
420
|
await runShell(deployPlan.build, cwd);
|
|
325
421
|
}
|
|
326
422
|
const files = collectDeployFiles(deployPlan.root, { appRoot: cwd });
|
|
327
|
-
|
|
328
|
-
|
|
423
|
+
includeAppManifest(files, cwd);
|
|
424
|
+
validateBundledManifest(files);
|
|
329
425
|
console.log(`Deploying ${files.length} file(s) to ${manifest.app}...`);
|
|
330
|
-
let response = await
|
|
426
|
+
let response = await postDeployBySlug(config, manifest.app, files);
|
|
331
427
|
if (response.status === 401) {
|
|
332
428
|
config = await reLoginOrThrow();
|
|
333
|
-
|
|
334
|
-
response = await postDeploy(config, fresh.uuid, files);
|
|
429
|
+
response = await postDeployBySlug(config, manifest.app, files);
|
|
335
430
|
}
|
|
336
431
|
if (!response.ok) {
|
|
337
432
|
throw new CliError(`Deploy failed (${response.status}): ${await errorDetail(response)}`, 1);
|
|
338
433
|
}
|
|
434
|
+
const result = (await response.json().catch(() => ({})));
|
|
435
|
+
// One-shot: --private only sets access at this deploy, never persisted —
|
|
436
|
+
// access is otherwise managed entirely in the dashboard ("Manage access"),
|
|
437
|
+
// so a plain `railcode deploy` must never silently re-assert a stale mode.
|
|
438
|
+
const wantsPrivate = Boolean(args.options.private);
|
|
439
|
+
if (wantsPrivate) {
|
|
440
|
+
const appUuid = result.app_uuid ?? (await resolveExistingApp(config, manifest.app))?.uuid;
|
|
441
|
+
if (!appUuid) {
|
|
442
|
+
throw new CliError("Deployed, but could not resolve the app to set private access.", 1);
|
|
443
|
+
}
|
|
444
|
+
await setAppAccessPrivate(config, appUuid);
|
|
445
|
+
}
|
|
339
446
|
const liveUrl = appLiveUrl(config, manifest.app);
|
|
340
447
|
console.log(`Deployed ${manifest.app}.`);
|
|
448
|
+
if (result.manifest) {
|
|
449
|
+
for (const line of renderManifestDeployOutcome(result.manifest))
|
|
450
|
+
console.log(line);
|
|
451
|
+
}
|
|
452
|
+
if (wantsPrivate) {
|
|
453
|
+
console.log("Access: private (only you can open it).");
|
|
454
|
+
}
|
|
341
455
|
console.log(liveUrl);
|
|
342
456
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const created = await authedFetch(config, orgPath, {
|
|
350
|
-
method: "POST",
|
|
457
|
+
// Apps are created with organization-wide access; privacy is a separate
|
|
458
|
+
// access-control call (`PUT .../apps/{uuid}/access`), same one the "Manage
|
|
459
|
+
// access" dashboard button uses.
|
|
460
|
+
async function setAppAccessPrivate(config, appUuid) {
|
|
461
|
+
const response = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/access`, {
|
|
462
|
+
method: "PUT",
|
|
351
463
|
headers: { "Content-Type": "application/json" },
|
|
352
|
-
body: JSON.stringify({
|
|
464
|
+
body: JSON.stringify({ mode: "private", members: [] }),
|
|
353
465
|
});
|
|
354
|
-
|
|
466
|
+
if (!response.ok) {
|
|
467
|
+
throw new CliError(`Deployed, but failed to set private access (${response.status}): ${await errorDetail(response)}`, 1);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// The app manifest lives at the APP root (next to railcode.json), but built
|
|
471
|
+
// apps upload their dist tree — carry manifest.yaml into the bundle root so
|
|
472
|
+
// the server sees it either way. An explicit dist copy wins.
|
|
473
|
+
function includeAppManifest(files, appRoot) {
|
|
474
|
+
if (files.some((file) => file.path === APP_MANIFEST_NAME))
|
|
475
|
+
return;
|
|
476
|
+
const source = join(appRoot, APP_MANIFEST_NAME);
|
|
477
|
+
if (!existsSync(source) || !statSync(source).isFile())
|
|
478
|
+
return;
|
|
479
|
+
files.push({
|
|
480
|
+
path: APP_MANIFEST_NAME,
|
|
481
|
+
contentType: "application/yaml",
|
|
482
|
+
data: readFileSync(source),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
// Fast local failure with the named line — the server re-validates
|
|
486
|
+
// authoritatively (full YAML + org-resource existence).
|
|
487
|
+
function validateBundledManifest(files) {
|
|
488
|
+
const bundled = files.find((file) => file.path === APP_MANIFEST_NAME);
|
|
489
|
+
if (!bundled)
|
|
490
|
+
return;
|
|
491
|
+
try {
|
|
492
|
+
parseManifestYaml(bundled.data.toString("utf8"));
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
if (error instanceof ManifestParseError) {
|
|
496
|
+
throw new CliError(`Invalid ${APP_MANIFEST_NAME}: ${error.message}`, 2);
|
|
497
|
+
}
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
async function resolveExistingApp(config, slug) {
|
|
502
|
+
const orgPath = `/api/organizations/${config.orgUuid}/apps`;
|
|
503
|
+
const list = (await readJsonOrThrow(await authedFetch(config, orgPath), "List apps"));
|
|
504
|
+
return list.find((app) => app.app_slug === slug) ?? null;
|
|
355
505
|
}
|
|
356
|
-
async function
|
|
506
|
+
async function postDeployBySlug(config, appSlug, files) {
|
|
357
507
|
// The deploy API is multipart: one part named "files" per file, with the
|
|
358
508
|
// relative path carried as the part filename.
|
|
359
509
|
const form = new FormData();
|
|
@@ -361,7 +511,7 @@ async function postDeploy(config, appUuid, files) {
|
|
|
361
511
|
const bytes = new Uint8Array(file.data);
|
|
362
512
|
form.append("files", new Blob([bytes], { type: file.contentType }), file.path);
|
|
363
513
|
}
|
|
364
|
-
return authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${
|
|
514
|
+
return authedFetch(config, `/api/organizations/${config.orgUuid}/apps/by-slug/${encodeURIComponent(appSlug)}/deploy`, { method: "POST", body: form });
|
|
365
515
|
}
|
|
366
516
|
function collectDeployFiles(root, options) {
|
|
367
517
|
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
@@ -492,7 +642,7 @@ async function commandInit(args) {
|
|
|
492
642
|
function initTemplate(args) {
|
|
493
643
|
const value = args.options.template;
|
|
494
644
|
if (value === undefined)
|
|
495
|
-
return "
|
|
645
|
+
return "react";
|
|
496
646
|
if (value === true)
|
|
497
647
|
throw new CliError("--template requires static or react.", 2);
|
|
498
648
|
if (value === "static" || value === "react")
|
|
@@ -511,8 +661,14 @@ function writeReactStarter(target, app) {
|
|
|
511
661
|
writeFileSync(join(target, "vite.config.ts"), reactViteConfig());
|
|
512
662
|
mkdirSync(join(target, "src"), { recursive: true });
|
|
513
663
|
writeFileSync(join(target, "src", "main.tsx"), reactMainTsx());
|
|
664
|
+
writeFileSync(join(target, "src", "railcode.d.ts"), reactRailcodeTypes());
|
|
665
|
+
writeFileSync(join(target, "src", "vite-env.d.ts"), reactViteEnvTypes());
|
|
514
666
|
writeFileSync(join(target, "src", "App.tsx"), reactAppTsx(app));
|
|
515
667
|
writeFileSync(join(target, "src", "styles.css"), reactStylesCss());
|
|
668
|
+
const assetsDir = resolveReactTemplateAssetsDir();
|
|
669
|
+
if (assetsDir) {
|
|
670
|
+
cpSync(assetsDir, join(target, "src", "assets"), { recursive: true });
|
|
671
|
+
}
|
|
516
672
|
}
|
|
517
673
|
function starterIndexHtml(app) {
|
|
518
674
|
// Plain HTML/JS starter — no build step. It loads the data-plane SDK and
|
|
@@ -563,16 +719,16 @@ function reactPackageJson(app) {
|
|
|
563
719
|
preview: "vite preview",
|
|
564
720
|
},
|
|
565
721
|
dependencies: {
|
|
566
|
-
react: "^19.
|
|
567
|
-
"react-dom": "^19.
|
|
722
|
+
react: "^19.2.0",
|
|
723
|
+
"react-dom": "^19.2.0",
|
|
568
724
|
zustand: "^5.0.0",
|
|
569
725
|
},
|
|
570
726
|
devDependencies: {
|
|
571
|
-
"@vitejs/plugin-react": "^
|
|
727
|
+
"@vitejs/plugin-react": "^6.0.0",
|
|
572
728
|
"@types/react": "^19.0.0",
|
|
573
729
|
"@types/react-dom": "^19.0.0",
|
|
574
|
-
typescript: "^
|
|
575
|
-
vite: "^
|
|
730
|
+
typescript: "^6.0.0",
|
|
731
|
+
vite: "^8.0.0",
|
|
576
732
|
},
|
|
577
733
|
}, null, 2)}\n`;
|
|
578
734
|
}
|
|
@@ -595,9 +751,9 @@ function reactIndexHtml(app) {
|
|
|
595
751
|
function reactTsConfig() {
|
|
596
752
|
return `${JSON.stringify({
|
|
597
753
|
compilerOptions: {
|
|
598
|
-
target: "
|
|
754
|
+
target: "ES2022",
|
|
599
755
|
useDefineForClassFields: true,
|
|
600
|
-
lib: ["DOM", "DOM.Iterable", "
|
|
756
|
+
lib: ["DOM", "DOM.Iterable", "ES2022"],
|
|
601
757
|
allowJs: false,
|
|
602
758
|
skipLibCheck: true,
|
|
603
759
|
esModuleInterop: true,
|
|
@@ -605,7 +761,7 @@ function reactTsConfig() {
|
|
|
605
761
|
strict: true,
|
|
606
762
|
forceConsistentCasingInFileNames: true,
|
|
607
763
|
module: "ESNext",
|
|
608
|
-
moduleResolution: "
|
|
764
|
+
moduleResolution: "Bundler",
|
|
609
765
|
resolveJsonModule: true,
|
|
610
766
|
isolatedModules: true,
|
|
611
767
|
noEmit: true,
|
|
@@ -615,114 +771,2569 @@ function reactTsConfig() {
|
|
|
615
771
|
references: [],
|
|
616
772
|
}, null, 2)}\n`;
|
|
617
773
|
}
|
|
618
|
-
function reactViteConfig() {
|
|
619
|
-
return `import { defineConfig } from "vite";
|
|
620
|
-
import react from "@vitejs/plugin-react";
|
|
774
|
+
function reactViteConfig() {
|
|
775
|
+
return `import { defineConfig } from "vite";
|
|
776
|
+
import react from "@vitejs/plugin-react";
|
|
777
|
+
|
|
778
|
+
export default defineConfig({
|
|
779
|
+
plugins: [react()],
|
|
780
|
+
build: {
|
|
781
|
+
outDir: "dist",
|
|
782
|
+
},
|
|
783
|
+
});
|
|
784
|
+
`;
|
|
785
|
+
}
|
|
786
|
+
function reactRailcodeTypes() {
|
|
787
|
+
return `type Me = {
|
|
788
|
+
user: { uuid: string; name: string; email: string };
|
|
789
|
+
app: { uuid: string; slug: string; name: string };
|
|
790
|
+
org: { uuid: string; slug: string; name: string };
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
type AppUser = {
|
|
794
|
+
uuid: string;
|
|
795
|
+
name: string;
|
|
796
|
+
email: string;
|
|
797
|
+
role: string | null;
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
type KvRecord<T = unknown> = {
|
|
801
|
+
key: string;
|
|
802
|
+
value: T;
|
|
803
|
+
updated_at: string;
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
type WhereOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in";
|
|
807
|
+
|
|
808
|
+
type KvQuery<T = unknown> = {
|
|
809
|
+
where(field: string, op: WhereOp, value: unknown): KvQuery<T>;
|
|
810
|
+
prefix(value: string): KvQuery<T>;
|
|
811
|
+
updatedSince(value: string | Date): KvQuery<T>;
|
|
812
|
+
updatedBefore(value: string | Date): KvQuery<T>;
|
|
813
|
+
orderBy(field: string, direction?: "asc" | "desc"): KvQuery<T>;
|
|
814
|
+
page(page?: number, size?: number): Promise<KvRecord<T>[]>;
|
|
815
|
+
first(): Promise<KvRecord<T> | null>;
|
|
816
|
+
count(): Promise<number>;
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
type Collection<T = unknown> = {
|
|
820
|
+
get(key: string): Promise<T | null>;
|
|
821
|
+
put(key: string, value: T): Promise<T>;
|
|
822
|
+
delete(key: string): Promise<void>;
|
|
823
|
+
list(): Promise<KvRecord<T>[]>;
|
|
824
|
+
query(): KvQuery<T>;
|
|
825
|
+
where(field: string, op: WhereOp, value: unknown): KvQuery<T>;
|
|
826
|
+
prefix(value: string): KvQuery<T>;
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
type FileMeta = {
|
|
830
|
+
name: string;
|
|
831
|
+
content_type: string;
|
|
832
|
+
size: number;
|
|
833
|
+
updated_at: string;
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
type SqlRows = Array<Record<string, unknown>> & {
|
|
837
|
+
columns?: string[];
|
|
838
|
+
rowcount?: number;
|
|
839
|
+
truncated?: boolean;
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
type DataConnectorInfo = {
|
|
843
|
+
engine: "postgres" | "bigquery" | "snowflake" | string;
|
|
844
|
+
name: string;
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
type DatabaseHandle = {
|
|
848
|
+
runSQL(query: string, params?: unknown[]): Promise<SqlRows>;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
type DatabaseNamespace = ((connection: string) => DatabaseHandle) & DatabaseHandle;
|
|
852
|
+
|
|
853
|
+
type SavedQueryInfo = {
|
|
854
|
+
name: string;
|
|
855
|
+
description: string;
|
|
856
|
+
params: { name: string; type: string }[];
|
|
857
|
+
version: number;
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
type ServiceConnectorInfo = {
|
|
861
|
+
name: string;
|
|
862
|
+
description: string | null;
|
|
863
|
+
auth_type: string;
|
|
864
|
+
allowed_methods: string[];
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
type ServiceConnectorResponse = {
|
|
868
|
+
status: number;
|
|
869
|
+
ok: boolean;
|
|
870
|
+
headers: Record<string, string>;
|
|
871
|
+
truncated: boolean;
|
|
872
|
+
text(): Promise<string>;
|
|
873
|
+
json<T = unknown>(): Promise<T>;
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
|
|
877
|
+
|
|
878
|
+
type LlmOutputSpec = { type?: "text" } | { type: "json"; schema: Record<string, unknown> };
|
|
879
|
+
|
|
880
|
+
type LlmOptions = {
|
|
881
|
+
/** A catalog model name (see llmProviders()); its provider is implied. */
|
|
882
|
+
model?: string;
|
|
883
|
+
/** A provider name; without a model, routes to that provider's default model. */
|
|
884
|
+
provider?: string;
|
|
885
|
+
system?: string;
|
|
886
|
+
output?: LlmOutputSpec;
|
|
887
|
+
temperature?: number;
|
|
888
|
+
maxOutputTokens?: number;
|
|
889
|
+
metadata?: Record<string, unknown>;
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
type LlmUsage = { inputTokens: number; outputTokens: number; totalTokens: number };
|
|
893
|
+
|
|
894
|
+
type LlmResult = {
|
|
895
|
+
text: string;
|
|
896
|
+
output: unknown | null;
|
|
897
|
+
usage: LlmUsage;
|
|
898
|
+
cost: string | null;
|
|
899
|
+
provider: string;
|
|
900
|
+
model: string;
|
|
901
|
+
finishReason: string | null;
|
|
902
|
+
requestId: string;
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
type LlmStreamEvent =
|
|
906
|
+
| { type: "text"; text: string }
|
|
907
|
+
| {
|
|
908
|
+
type: "done";
|
|
909
|
+
usage: LlmUsage;
|
|
910
|
+
cost: string | null;
|
|
911
|
+
provider: string;
|
|
912
|
+
model: string;
|
|
913
|
+
finishReason: string | null;
|
|
914
|
+
requestId: string;
|
|
915
|
+
}
|
|
916
|
+
| { type: "error"; error: string; message: string; requestId: string };
|
|
917
|
+
|
|
918
|
+
type LlmModelInfo = { model: string; default: boolean };
|
|
919
|
+
|
|
920
|
+
type LlmProviderInfo = { provider: string; default: boolean; models: LlmModelInfo[] };
|
|
921
|
+
|
|
922
|
+
declare const me: () => Promise<Me>;
|
|
923
|
+
declare const appUsers: () => Promise<AppUser[]>;
|
|
924
|
+
declare const designSystem: () => Promise<string>;
|
|
925
|
+
declare const db: { collection<T = unknown>(name: string): Collection<T> };
|
|
926
|
+
declare const files: {
|
|
927
|
+
upload(name: string, data: Blob | ArrayBuffer | ArrayBufferView, contentType?: string): Promise<FileMeta>;
|
|
928
|
+
url(name: string): string;
|
|
929
|
+
list(): Promise<FileMeta[]>;
|
|
930
|
+
delete(name: string): Promise<void>;
|
|
931
|
+
};
|
|
932
|
+
declare const data: DatabaseNamespace;
|
|
933
|
+
declare const postgres: DatabaseNamespace;
|
|
934
|
+
declare const bigquery: DatabaseNamespace;
|
|
935
|
+
declare const snowflake: DatabaseNamespace;
|
|
936
|
+
declare const dataConnectors: () => Promise<DataConnectorInfo[]>;
|
|
937
|
+
declare const savedQueries: () => Promise<SavedQueryInfo[]>;
|
|
938
|
+
declare const query: (name: string, params?: Record<string, unknown>) => Promise<SqlRows>;
|
|
939
|
+
declare const serviceConnectors: () => Promise<ServiceConnectorInfo[]>;
|
|
940
|
+
declare const connector: (name: string) => {
|
|
941
|
+
fetch(path: string, opts?: { method?: string; body?: string }): Promise<ServiceConnectorResponse>;
|
|
942
|
+
};
|
|
943
|
+
declare const llm: {
|
|
944
|
+
generate(input: string | LlmMessage[], opts?: LlmOptions): Promise<LlmResult>;
|
|
945
|
+
stream(input: string | LlmMessage[], opts?: LlmOptions): AsyncIterable<LlmStreamEvent>;
|
|
946
|
+
};
|
|
947
|
+
declare const llmProviders: () => Promise<LlmProviderInfo[]>;
|
|
948
|
+
`;
|
|
949
|
+
}
|
|
950
|
+
function reactViteEnvTypes() {
|
|
951
|
+
return `/// <reference types="vite/client" />
|
|
952
|
+
`;
|
|
953
|
+
}
|
|
954
|
+
function reactMainTsx() {
|
|
955
|
+
return `import React from "react";
|
|
956
|
+
import ReactDOM from "react-dom/client";
|
|
957
|
+
import { App } from "./App";
|
|
958
|
+
import "./styles.css";
|
|
959
|
+
|
|
960
|
+
ReactDOM.createRoot(document.getElementById("root")!).render(
|
|
961
|
+
<React.StrictMode>
|
|
962
|
+
<App />
|
|
963
|
+
</React.StrictMode>,
|
|
964
|
+
);
|
|
965
|
+
`;
|
|
966
|
+
}
|
|
967
|
+
function reactAppTsx(app) {
|
|
968
|
+
return `import {
|
|
969
|
+
useEffect,
|
|
970
|
+
useRef,
|
|
971
|
+
useState,
|
|
972
|
+
type ChangeEvent,
|
|
973
|
+
type FormEvent,
|
|
974
|
+
type ReactNode,
|
|
975
|
+
} from "react";
|
|
976
|
+
import { create } from "zustand";
|
|
977
|
+
import postgresLogo from "./assets/postgres.svg";
|
|
978
|
+
import bigqueryLogo from "./assets/bigquery.png";
|
|
979
|
+
import stripeLogo from "./assets/connectors/stripe.png";
|
|
980
|
+
import posthogLogo from "./assets/connectors/posthog.svg";
|
|
981
|
+
import mixpanelLogo from "./assets/connectors/mixpanel.png";
|
|
982
|
+
import resendLogo from "./assets/connectors/resend.svg";
|
|
983
|
+
import clerkLogo from "./assets/connectors/clerk.png";
|
|
984
|
+
import openaiLogo from "./assets/llm/openai.svg";
|
|
985
|
+
import anthropicLogo from "./assets/llm/anthropic.png";
|
|
986
|
+
import geminiLogo from "./assets/llm/gemini.png";
|
|
987
|
+
import bedrockLogo from "./assets/llm/bedrock.png";
|
|
988
|
+
|
|
989
|
+
const APP_SLUG = "${app}";
|
|
990
|
+
|
|
991
|
+
type TodoValue = {
|
|
992
|
+
text: string;
|
|
993
|
+
done: boolean;
|
|
994
|
+
authorName: string;
|
|
995
|
+
authorEmail: string;
|
|
996
|
+
updatedAt: string;
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
type AppState = {
|
|
1000
|
+
loading: boolean;
|
|
1001
|
+
error: string | null;
|
|
1002
|
+
identity: Me | null;
|
|
1003
|
+
users: AppUser[];
|
|
1004
|
+
todos: KvRecord<TodoValue>[];
|
|
1005
|
+
fileList: FileMeta[];
|
|
1006
|
+
dataConnectorList: DataConnectorInfo[];
|
|
1007
|
+
savedQueryList: SavedQueryInfo[];
|
|
1008
|
+
serviceConnectorList: ServiceConnectorInfo[];
|
|
1009
|
+
load: () => Promise<void>;
|
|
1010
|
+
addTodo: (text: string) => Promise<void>;
|
|
1011
|
+
toggleTodo: (row: KvRecord<TodoValue>) => Promise<void>;
|
|
1012
|
+
deleteTodo: (key: string) => Promise<void>;
|
|
1013
|
+
uploadFile: (file: File) => Promise<void>;
|
|
1014
|
+
deleteFile: (name: string) => Promise<void>;
|
|
1015
|
+
clearError: () => void;
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
function message(error: unknown): string {
|
|
1019
|
+
return error instanceof Error ? error.message : String(error);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function truncate(value: string, max: number): string {
|
|
1023
|
+
return value.length > max ? value.slice(0, max - 1) + "…" : value;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function formatBytes(value: number | undefined): string {
|
|
1027
|
+
if (!value) return "0 B";
|
|
1028
|
+
if (value < 1024) return value + " B";
|
|
1029
|
+
if (value < 1024 * 1024) return (value / 1024).toFixed(1) + " KB";
|
|
1030
|
+
return (value / (1024 * 1024)).toFixed(1) + " MB";
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function firstName(name: string): string {
|
|
1034
|
+
const trimmed = name.trim();
|
|
1035
|
+
const space = trimmed.indexOf(" ");
|
|
1036
|
+
return space === -1 ? trimmed : trimmed.slice(0, space);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function initials(name: string): string {
|
|
1040
|
+
const parts = name.trim().split(/\\s+/);
|
|
1041
|
+
const first = parts[0]?.[0] || "";
|
|
1042
|
+
const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
|
|
1043
|
+
return (first + last).toUpperCase();
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function relativeTime(iso: string): string {
|
|
1047
|
+
const then = new Date(iso).getTime();
|
|
1048
|
+
if (Number.isNaN(then)) return "";
|
|
1049
|
+
const diffMs = Date.now() - then;
|
|
1050
|
+
const minute = 60000;
|
|
1051
|
+
const hour = 60 * minute;
|
|
1052
|
+
const day = 24 * hour;
|
|
1053
|
+
if (diffMs < minute) return "just now";
|
|
1054
|
+
if (diffMs < hour) return Math.round(diffMs / minute) + "m ago";
|
|
1055
|
+
if (diffMs < day) return Math.round(diffMs / hour) + "h ago";
|
|
1056
|
+
return Math.round(diffMs / day) + "d ago";
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function refreshTodos() {
|
|
1060
|
+
return db.collection<TodoValue>("todos").query().orderBy("updatedAt", "desc").page(1, 20);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const useAppStore = create<AppState>((set, get) => ({
|
|
1064
|
+
loading: false,
|
|
1065
|
+
error: null,
|
|
1066
|
+
identity: null,
|
|
1067
|
+
users: [],
|
|
1068
|
+
todos: [],
|
|
1069
|
+
fileList: [],
|
|
1070
|
+
dataConnectorList: [],
|
|
1071
|
+
savedQueryList: [],
|
|
1072
|
+
serviceConnectorList: [],
|
|
1073
|
+
|
|
1074
|
+
async load() {
|
|
1075
|
+
set({ loading: true, error: null });
|
|
1076
|
+
const results = await Promise.allSettled([
|
|
1077
|
+
me(),
|
|
1078
|
+
appUsers(),
|
|
1079
|
+
refreshTodos(),
|
|
1080
|
+
files.list(),
|
|
1081
|
+
dataConnectors(),
|
|
1082
|
+
savedQueries(),
|
|
1083
|
+
serviceConnectors(),
|
|
1084
|
+
]);
|
|
1085
|
+
|
|
1086
|
+
const failures = results
|
|
1087
|
+
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
|
|
1088
|
+
.map((result) => message(result.reason));
|
|
1089
|
+
|
|
1090
|
+
set({
|
|
1091
|
+
loading: false,
|
|
1092
|
+
error: failures[0] || null,
|
|
1093
|
+
identity: results[0].status === "fulfilled" ? results[0].value : get().identity,
|
|
1094
|
+
users: results[1].status === "fulfilled" ? results[1].value : get().users,
|
|
1095
|
+
todos: results[2].status === "fulfilled" ? results[2].value : get().todos,
|
|
1096
|
+
fileList: results[3].status === "fulfilled" ? results[3].value : get().fileList,
|
|
1097
|
+
dataConnectorList:
|
|
1098
|
+
results[4].status === "fulfilled" ? results[4].value : get().dataConnectorList,
|
|
1099
|
+
savedQueryList: results[5].status === "fulfilled" ? results[5].value : get().savedQueryList,
|
|
1100
|
+
serviceConnectorList:
|
|
1101
|
+
results[6].status === "fulfilled" ? results[6].value : get().serviceConnectorList,
|
|
1102
|
+
});
|
|
1103
|
+
},
|
|
1104
|
+
|
|
1105
|
+
async addTodo(text) {
|
|
1106
|
+
try {
|
|
1107
|
+
const identity = get().identity || (await me());
|
|
1108
|
+
const id = crypto.randomUUID();
|
|
1109
|
+
const value: TodoValue = {
|
|
1110
|
+
text,
|
|
1111
|
+
done: false,
|
|
1112
|
+
authorName: identity.user.name,
|
|
1113
|
+
authorEmail: identity.user.email,
|
|
1114
|
+
updatedAt: new Date().toISOString(),
|
|
1115
|
+
};
|
|
1116
|
+
await db.collection<TodoValue>("todos").put(id, value);
|
|
1117
|
+
set({ todos: await refreshTodos(), identity, error: null });
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
set({ error: message(error) });
|
|
1120
|
+
}
|
|
1121
|
+
},
|
|
1122
|
+
|
|
1123
|
+
async toggleTodo(row) {
|
|
1124
|
+
try {
|
|
1125
|
+
const next: TodoValue = { ...row.value, done: !row.value.done, updatedAt: new Date().toISOString() };
|
|
1126
|
+
await db.collection<TodoValue>("todos").put(row.key, next);
|
|
1127
|
+
set({ todos: await refreshTodos(), error: null });
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
set({ error: message(error) });
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
|
|
1133
|
+
async deleteTodo(key) {
|
|
1134
|
+
try {
|
|
1135
|
+
await db.collection<TodoValue>("todos").delete(key);
|
|
1136
|
+
set({ todos: await refreshTodos(), error: null });
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
set({ error: message(error) });
|
|
1139
|
+
}
|
|
1140
|
+
},
|
|
1141
|
+
|
|
1142
|
+
async uploadFile(file) {
|
|
1143
|
+
try {
|
|
1144
|
+
const contentType = file.type || "application/octet-stream";
|
|
1145
|
+
await files.upload(file.name, file, contentType);
|
|
1146
|
+
set({ fileList: await files.list(), error: null });
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
set({ error: message(error) });
|
|
1149
|
+
}
|
|
1150
|
+
},
|
|
1151
|
+
|
|
1152
|
+
async deleteFile(name) {
|
|
1153
|
+
try {
|
|
1154
|
+
await files.delete(name);
|
|
1155
|
+
set({ fileList: await files.list(), error: null });
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
set({ error: message(error) });
|
|
1158
|
+
}
|
|
1159
|
+
},
|
|
1160
|
+
|
|
1161
|
+
clearError() {
|
|
1162
|
+
set({ error: null });
|
|
1163
|
+
},
|
|
1164
|
+
}));
|
|
1165
|
+
|
|
1166
|
+
function usePrefersReducedMotion(): boolean {
|
|
1167
|
+
const [reduced, setReduced] = useState(false);
|
|
1168
|
+
useEffect(() => {
|
|
1169
|
+
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
1170
|
+
setReduced(mql.matches);
|
|
1171
|
+
const handler = () => setReduced(mql.matches);
|
|
1172
|
+
mql.addEventListener("change", handler);
|
|
1173
|
+
return () => mql.removeEventListener("change", handler);
|
|
1174
|
+
}, []);
|
|
1175
|
+
return reduced;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function Icon({ name, className }: { name: string; className?: string }) {
|
|
1179
|
+
return (
|
|
1180
|
+
<svg className={className ? "icon " + className : "icon"}>
|
|
1181
|
+
<use href={"#i-" + name}></use>
|
|
1182
|
+
</svg>
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
type CodeLanguage = "js" | "sql";
|
|
1187
|
+
type CodeTokenType = "string" | "keyword" | "number" | "call" | "punct" | "comment";
|
|
1188
|
+
|
|
1189
|
+
const CODE_TOKEN_RE =
|
|
1190
|
+
/('[^']*')|(\\bawait\\b)|(\\b\\d+\\b)|([A-Za-z_$][\\w$]*(?=\\())|([^\\s'A-Za-z0-9_$]+)|(\\s+)|([A-Za-z_$][\\w$]*)/g;
|
|
1191
|
+
const SQL_TOKEN_RE =
|
|
1192
|
+
/(--[^\\n]*)|('[^']*')|(\\b\\d+\\b)|(\\b(?:select|from|where|order|by|limit|group|having|join|left|right|inner|outer|on|as|and|or|desc|asc|insert|update|delete|values|set|create|table|view)\\b)|([A-Za-z_][\\w$]*)|([^\\s'A-Za-z0-9_]+)|(\\s+)/gi;
|
|
1193
|
+
|
|
1194
|
+
function tokenizeCode(code: string): { type: CodeTokenType | null; text: string }[] {
|
|
1195
|
+
const tokens: { type: CodeTokenType | null; text: string }[] = [];
|
|
1196
|
+
const types: (CodeTokenType | null)[] = ["string", "keyword", "number", "call", "punct", null, null];
|
|
1197
|
+
let match: RegExpExecArray | null;
|
|
1198
|
+
CODE_TOKEN_RE.lastIndex = 0;
|
|
1199
|
+
while ((match = CODE_TOKEN_RE.exec(code))) {
|
|
1200
|
+
const groupIndex = match.slice(1).findIndex((group) => group !== undefined);
|
|
1201
|
+
tokens.push({ type: types[groupIndex], text: match[groupIndex + 1] });
|
|
1202
|
+
}
|
|
1203
|
+
return tokens;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function tokenizeSql(code: string): { type: CodeTokenType | null; text: string }[] {
|
|
1207
|
+
const tokens: { type: CodeTokenType | null; text: string }[] = [];
|
|
1208
|
+
const types: (CodeTokenType | null)[] = [
|
|
1209
|
+
"comment",
|
|
1210
|
+
"string",
|
|
1211
|
+
"number",
|
|
1212
|
+
"keyword",
|
|
1213
|
+
null,
|
|
1214
|
+
"punct",
|
|
1215
|
+
null,
|
|
1216
|
+
];
|
|
1217
|
+
let match: RegExpExecArray | null;
|
|
1218
|
+
SQL_TOKEN_RE.lastIndex = 0;
|
|
1219
|
+
while ((match = SQL_TOKEN_RE.exec(code))) {
|
|
1220
|
+
const groupIndex = match.slice(1).findIndex((group) => group !== undefined);
|
|
1221
|
+
tokens.push({ type: types[groupIndex], text: match[groupIndex + 1] });
|
|
1222
|
+
}
|
|
1223
|
+
return tokens;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function CodeHighlight({ code, language = "js" }: { code: string; language?: CodeLanguage }) {
|
|
1227
|
+
const tokens = language === "sql" ? tokenizeSql(code) : tokenizeCode(code);
|
|
1228
|
+
return (
|
|
1229
|
+
<>
|
|
1230
|
+
{tokens.map((token, index) =>
|
|
1231
|
+
token.type ? (
|
|
1232
|
+
<span className={"code-" + token.type} key={index}>
|
|
1233
|
+
{token.text}
|
|
1234
|
+
</span>
|
|
1235
|
+
) : (
|
|
1236
|
+
token.text
|
|
1237
|
+
),
|
|
1238
|
+
)}
|
|
1239
|
+
</>
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function Reveal({ code, below, children }: { code: string; below?: boolean; children: ReactNode }) {
|
|
1244
|
+
return (
|
|
1245
|
+
<span className="reveal" tabIndex={0} aria-label={"SDK call: " + code}>
|
|
1246
|
+
{children}
|
|
1247
|
+
<span className={below ? "reveal-tip is-below" : "reveal-tip"}>
|
|
1248
|
+
<CodeHighlight code={code} />
|
|
1249
|
+
</span>
|
|
1250
|
+
</span>
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function ActionTip({
|
|
1255
|
+
code,
|
|
1256
|
+
className,
|
|
1257
|
+
children,
|
|
1258
|
+
}: {
|
|
1259
|
+
code: string;
|
|
1260
|
+
className?: string;
|
|
1261
|
+
children: ReactNode;
|
|
1262
|
+
}) {
|
|
1263
|
+
return (
|
|
1264
|
+
<span className={className ? "action-tip " + className : "action-tip"}>
|
|
1265
|
+
{children}
|
|
1266
|
+
<span className="action-tip-pop">
|
|
1267
|
+
<CodeHighlight code={code} />
|
|
1268
|
+
</span>
|
|
1269
|
+
</span>
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function ThemeToggle() {
|
|
1274
|
+
const [theme, setTheme] = useState<"light" | "dark" | null>(null);
|
|
1275
|
+
|
|
1276
|
+
useEffect(() => {
|
|
1277
|
+
const stored = localStorage.getItem("theme");
|
|
1278
|
+
if (stored === "light" || stored === "dark") {
|
|
1279
|
+
setTheme(stored);
|
|
1280
|
+
document.documentElement.setAttribute("data-theme", stored);
|
|
1281
|
+
}
|
|
1282
|
+
}, []);
|
|
1283
|
+
|
|
1284
|
+
function toggle() {
|
|
1285
|
+
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
|
1286
|
+
const current = theme || (mql.matches ? "dark" : "light");
|
|
1287
|
+
const next = current === "dark" ? "light" : "dark";
|
|
1288
|
+
setTheme(next);
|
|
1289
|
+
document.documentElement.setAttribute("data-theme", next);
|
|
1290
|
+
localStorage.setItem("theme", next);
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
return (
|
|
1294
|
+
<button className="theme-toggle" type="button" onClick={toggle} aria-label="Toggle color theme">
|
|
1295
|
+
<Icon name="sun" className="icon-sun" />
|
|
1296
|
+
<Icon name="moon" className="icon-moon" />
|
|
1297
|
+
</button>
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function Hero() {
|
|
1302
|
+
const { identity, users } = useAppStore();
|
|
1303
|
+
const name = identity ? firstName(identity.user.name) : "…";
|
|
1304
|
+
const teammates = users.filter((user) => user.uuid !== identity?.user.uuid);
|
|
1305
|
+
|
|
1306
|
+
return (
|
|
1307
|
+
<section className="hero">
|
|
1308
|
+
<h1>
|
|
1309
|
+
Welcome to Railcode, <Reveal code="await me()" below>{name}</Reveal>!
|
|
1310
|
+
</h1>
|
|
1311
|
+
<p className="hero-sub">
|
|
1312
|
+
This is an interactive guide showing a little bit of what Railcode apps can do. But
|
|
1313
|
+
don't worry about memorizing anything, your coding agent will know what to do.
|
|
1314
|
+
</p>
|
|
1315
|
+
<div className="team-row">
|
|
1316
|
+
<div className="avatar-stack">
|
|
1317
|
+
{identity ? (
|
|
1318
|
+
<div className="avatar is-you" title={identity.user.name + " · you"}>
|
|
1319
|
+
{initials(identity.user.name)}
|
|
1320
|
+
</div>
|
|
1321
|
+
) : null}
|
|
1322
|
+
{teammates.slice(0, 4).map((user) => (
|
|
1323
|
+
<div className="avatar" key={user.uuid} title={user.name + (user.role ? " · " + user.role : "")}>
|
|
1324
|
+
{initials(user.name)}
|
|
1325
|
+
</div>
|
|
1326
|
+
))}
|
|
1327
|
+
</div>
|
|
1328
|
+
<Reveal code="await appUsers()" below>
|
|
1329
|
+
<span className="team-caption">
|
|
1330
|
+
{users.length || 1} teammate{users.length === 1 ? "" : "s"} in {identity?.org.name || "your org"}
|
|
1331
|
+
</span>
|
|
1332
|
+
</Reveal>
|
|
1333
|
+
</div>
|
|
1334
|
+
</section>
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function GuideSection({
|
|
1339
|
+
title,
|
|
1340
|
+
body,
|
|
1341
|
+
children,
|
|
1342
|
+
}: {
|
|
1343
|
+
title: string;
|
|
1344
|
+
body: string;
|
|
1345
|
+
children: ReactNode;
|
|
1346
|
+
}) {
|
|
1347
|
+
return (
|
|
1348
|
+
<section className="guide-section">
|
|
1349
|
+
<div className="guide-copy">
|
|
1350
|
+
<h2>{title}</h2>
|
|
1351
|
+
<p>{body}</p>
|
|
1352
|
+
</div>
|
|
1353
|
+
<div className="guide-demo">{children}</div>
|
|
1354
|
+
</section>
|
|
1355
|
+
);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function TodosCard() {
|
|
1359
|
+
const { todos, addTodo, toggleTodo, deleteTodo } = useAppStore();
|
|
1360
|
+
const [text, setText] = useState("");
|
|
1361
|
+
|
|
1362
|
+
function submit(event: FormEvent<HTMLFormElement>) {
|
|
1363
|
+
event.preventDefault();
|
|
1364
|
+
const value = text.trim();
|
|
1365
|
+
if (!value) return;
|
|
1366
|
+
setText("");
|
|
1367
|
+
void addTodo(value);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return (
|
|
1371
|
+
<article className="card">
|
|
1372
|
+
<div className="card-head">
|
|
1373
|
+
<div>
|
|
1374
|
+
<div className="card-title-row">
|
|
1375
|
+
<Icon name="checklist" />
|
|
1376
|
+
<span className="card-title">
|
|
1377
|
+
<Reveal
|
|
1378
|
+
code={\`await db
|
|
1379
|
+
.collection('todos')
|
|
1380
|
+
.query()
|
|
1381
|
+
.orderBy('updatedAt', 'desc')
|
|
1382
|
+
.page(1, 20)\`}
|
|
1383
|
+
>
|
|
1384
|
+
To-dos
|
|
1385
|
+
</Reveal>
|
|
1386
|
+
</span>
|
|
1387
|
+
</div>
|
|
1388
|
+
</div>
|
|
1389
|
+
</div>
|
|
1390
|
+
|
|
1391
|
+
<form className="add-row" onSubmit={submit}>
|
|
1392
|
+
<input
|
|
1393
|
+
className="field"
|
|
1394
|
+
type="text"
|
|
1395
|
+
placeholder="Add a to-do…"
|
|
1396
|
+
maxLength={80}
|
|
1397
|
+
autoComplete="off"
|
|
1398
|
+
value={text}
|
|
1399
|
+
onChange={(event) => setText(event.target.value)}
|
|
1400
|
+
/>
|
|
1401
|
+
<ActionTip code={\`await db.collection('todos').put(id, {
|
|
1402
|
+
text,
|
|
1403
|
+
done: false
|
|
1404
|
+
})\`}
|
|
1405
|
+
>
|
|
1406
|
+
<button className="btn btn-primary" type="submit">
|
|
1407
|
+
<Icon name="plus" />
|
|
1408
|
+
Add
|
|
1409
|
+
</button>
|
|
1410
|
+
</ActionTip>
|
|
1411
|
+
</form>
|
|
1412
|
+
|
|
1413
|
+
<div className="todo-list" aria-live="polite">
|
|
1414
|
+
{todos.length ? (
|
|
1415
|
+
todos.map((row) => (
|
|
1416
|
+
<div className={row.value.done ? "todo-row is-done" : "todo-row"} key={row.key}>
|
|
1417
|
+
<ActionTip code={\`await db.collection('todos').put(key, {
|
|
1418
|
+
...todo,
|
|
1419
|
+
done: \${!row.value.done}
|
|
1420
|
+
})\`}
|
|
1421
|
+
>
|
|
1422
|
+
<button
|
|
1423
|
+
className="checkbox"
|
|
1424
|
+
type="button"
|
|
1425
|
+
aria-label={row.value.done ? "Mark not done" : "Mark done"}
|
|
1426
|
+
aria-pressed={row.value.done}
|
|
1427
|
+
onClick={() => void toggleTodo(row)}
|
|
1428
|
+
>
|
|
1429
|
+
<Icon name="check" />
|
|
1430
|
+
</button>
|
|
1431
|
+
</ActionTip>
|
|
1432
|
+
<div className="todo-body">
|
|
1433
|
+
<div className="todo-text">{row.value.text}</div>
|
|
1434
|
+
<div className="todo-meta">
|
|
1435
|
+
Added by {row.value.authorName} · {relativeTime(row.updated_at)}
|
|
1436
|
+
</div>
|
|
1437
|
+
</div>
|
|
1438
|
+
<ActionTip code="await db.collection('todos').delete(key)">
|
|
1439
|
+
<button
|
|
1440
|
+
className="row-delete"
|
|
1441
|
+
type="button"
|
|
1442
|
+
aria-label="Delete to-do"
|
|
1443
|
+
onClick={() => void deleteTodo(row.key)}
|
|
1444
|
+
>
|
|
1445
|
+
<Icon name="x" />
|
|
1446
|
+
</button>
|
|
1447
|
+
</ActionTip>
|
|
1448
|
+
</div>
|
|
1449
|
+
))
|
|
1450
|
+
) : (
|
|
1451
|
+
<p className="empty-note">No to-dos yet — add the first one.</p>
|
|
1452
|
+
)}
|
|
1453
|
+
</div>
|
|
1454
|
+
</article>
|
|
1455
|
+
);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function FilesCard() {
|
|
1459
|
+
const { fileList, uploadFile, deleteFile } = useAppStore();
|
|
1460
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
1461
|
+
const [uploading, setUploading] = useState(false);
|
|
1462
|
+
|
|
1463
|
+
async function handleFile(event: ChangeEvent<HTMLInputElement>) {
|
|
1464
|
+
const file = event.target.files?.[0];
|
|
1465
|
+
event.target.value = "";
|
|
1466
|
+
if (!file) return;
|
|
1467
|
+
setUploading(true);
|
|
1468
|
+
await uploadFile(file);
|
|
1469
|
+
setUploading(false);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
return (
|
|
1473
|
+
<article className="card">
|
|
1474
|
+
<div className="card-head">
|
|
1475
|
+
<div>
|
|
1476
|
+
<div className="card-title-row">
|
|
1477
|
+
<Icon name="file" />
|
|
1478
|
+
<span className="card-title">
|
|
1479
|
+
<Reveal code="await files.list()">Files</Reveal>
|
|
1480
|
+
</span>
|
|
1481
|
+
</div>
|
|
1482
|
+
</div>
|
|
1483
|
+
</div>
|
|
1484
|
+
|
|
1485
|
+
<div className="file-list" aria-live="polite">
|
|
1486
|
+
{fileList.length ? (
|
|
1487
|
+
fileList.map((file) => (
|
|
1488
|
+
<div className="file-row" key={file.name}>
|
|
1489
|
+
<div className="file-icon">
|
|
1490
|
+
<Icon name="file" />
|
|
1491
|
+
</div>
|
|
1492
|
+
<div className="file-body">
|
|
1493
|
+
<a className="file-name" href={files.url(file.name)} target="_blank" rel="noreferrer">
|
|
1494
|
+
{file.name}
|
|
1495
|
+
</a>
|
|
1496
|
+
<div className="file-meta tabular">
|
|
1497
|
+
{formatBytes(file.size)} · {file.content_type}
|
|
1498
|
+
</div>
|
|
1499
|
+
</div>
|
|
1500
|
+
<ActionTip code="await files.delete(file.name)">
|
|
1501
|
+
<button
|
|
1502
|
+
className="row-delete"
|
|
1503
|
+
type="button"
|
|
1504
|
+
aria-label="Delete file"
|
|
1505
|
+
onClick={() => void deleteFile(file.name)}
|
|
1506
|
+
>
|
|
1507
|
+
<Icon name="x" />
|
|
1508
|
+
</button>
|
|
1509
|
+
</ActionTip>
|
|
1510
|
+
</div>
|
|
1511
|
+
))
|
|
1512
|
+
) : (
|
|
1513
|
+
<p className="empty-note">No files uploaded yet.</p>
|
|
1514
|
+
)}
|
|
1515
|
+
</div>
|
|
1516
|
+
|
|
1517
|
+
<input ref={inputRef} type="file" className="file-input-hidden" onChange={handleFile} />
|
|
1518
|
+
<ActionTip code="await files.upload(file.name, file, file.type)" className="upload-tip">
|
|
1519
|
+
<button
|
|
1520
|
+
className="btn btn-ghost upload-btn"
|
|
1521
|
+
type="button"
|
|
1522
|
+
disabled={uploading}
|
|
1523
|
+
onClick={() => inputRef.current?.click()}
|
|
1524
|
+
>
|
|
1525
|
+
{uploading ? <span className="spinner" /> : <Icon name="upload" />}
|
|
1526
|
+
{uploading ? "Uploading…" : "Upload a file"}
|
|
1527
|
+
</button>
|
|
1528
|
+
</ActionTip>
|
|
1529
|
+
</article>
|
|
1530
|
+
);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function ResultTable({ columns, rows, caption }: { columns: string[]; rows: string[][]; caption: string }) {
|
|
1534
|
+
return (
|
|
1535
|
+
<>
|
|
1536
|
+
<div className="result-status">
|
|
1537
|
+
<span>{caption}</span>
|
|
1538
|
+
<span className="tabular">{rows.length} rows</span>
|
|
1539
|
+
</div>
|
|
1540
|
+
<div className="table-wrap">
|
|
1541
|
+
<table>
|
|
1542
|
+
<thead>
|
|
1543
|
+
<tr>
|
|
1544
|
+
{columns.map((column) => (
|
|
1545
|
+
<th key={column}>{column}</th>
|
|
1546
|
+
))}
|
|
1547
|
+
</tr>
|
|
1548
|
+
</thead>
|
|
1549
|
+
<tbody>
|
|
1550
|
+
{rows.map((row, index) => (
|
|
1551
|
+
<tr key={index}>
|
|
1552
|
+
{row.map((cell, cellIndex) => (
|
|
1553
|
+
<td className="tabular" key={cellIndex}>
|
|
1554
|
+
{cell}
|
|
1555
|
+
</td>
|
|
1556
|
+
))}
|
|
1557
|
+
</tr>
|
|
1558
|
+
))}
|
|
1559
|
+
</tbody>
|
|
1560
|
+
</table>
|
|
1561
|
+
</div>
|
|
1562
|
+
</>
|
|
1563
|
+
);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const MOCK_EVENTS = {
|
|
1567
|
+
columns: ["id", "event", "created_at"],
|
|
1568
|
+
rows: [
|
|
1569
|
+
["1042", "app.viewed", "2026-07-03 14:02"],
|
|
1570
|
+
["1041", "note.created", "2026-07-03 11:47"],
|
|
1571
|
+
["1040", "file.uploaded", "2026-07-02 19:03"],
|
|
1572
|
+
["1039", "note.created", "2026-07-02 16:21"],
|
|
1573
|
+
["1038", "session.started", "2026-07-02 09:14"],
|
|
1574
|
+
],
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
const MOCK_CUSTOMERS = {
|
|
1578
|
+
columns: ["customer", "region", "lifetime_spend"],
|
|
1579
|
+
rows: [
|
|
1580
|
+
["Meridian Foods", "emea", "$84,200"],
|
|
1581
|
+
["Nordic Rail Co.", "emea", "$61,750"],
|
|
1582
|
+
["Atlas Freight", "emea", "$58,900"],
|
|
1583
|
+
["Solvane Group", "emea", "$52,100"],
|
|
1584
|
+
],
|
|
1585
|
+
};
|
|
1586
|
+
|
|
1587
|
+
type LogoItem = {
|
|
1588
|
+
name: string;
|
|
1589
|
+
src?: string;
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1592
|
+
function titleCase(value: string): string {
|
|
1593
|
+
return value
|
|
1594
|
+
.replace(/[-_]+/g, " ")
|
|
1595
|
+
.replace(/\\b\\w/g, (char) => char.toUpperCase())
|
|
1596
|
+
.trim();
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
const LLM_LOGOS: LogoItem[] = [
|
|
1600
|
+
{ name: "OpenAI", src: openaiLogo },
|
|
1601
|
+
{ name: "Anthropic", src: anthropicLogo },
|
|
1602
|
+
{ name: "Gemini", src: geminiLogo },
|
|
1603
|
+
{ name: "Bedrock", src: bedrockLogo },
|
|
1604
|
+
];
|
|
1605
|
+
|
|
1606
|
+
const DATA_LOGO_ITEMS: LogoItem[] = [
|
|
1607
|
+
{ name: "postgres", src: postgresLogo },
|
|
1608
|
+
{ name: "bigquery", src: bigqueryLogo },
|
|
1609
|
+
{ name: "snowflake" },
|
|
1610
|
+
{ name: "saved queries" },
|
|
1611
|
+
];
|
|
1612
|
+
|
|
1613
|
+
const CONNECTOR_LOGO_ITEMS: LogoItem[] = [
|
|
1614
|
+
{ name: "stripe", src: stripeLogo },
|
|
1615
|
+
{ name: "posthog", src: posthogLogo },
|
|
1616
|
+
{ name: "mixpanel", src: mixpanelLogo },
|
|
1617
|
+
{ name: "resend", src: resendLogo },
|
|
1618
|
+
{ name: "clerk", src: clerkLogo },
|
|
1619
|
+
];
|
|
1620
|
+
|
|
1621
|
+
function logoInitials(name: string): string {
|
|
1622
|
+
const normalized = titleCase(name);
|
|
1623
|
+
const words = normalized.split(/\\s+/).filter(Boolean);
|
|
1624
|
+
if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase();
|
|
1625
|
+
return normalized.slice(0, 2).toUpperCase();
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
function LogoMark({ item }: { item: LogoItem }) {
|
|
1629
|
+
return (
|
|
1630
|
+
<div className={item.src ? "logo-mark has-image" : "logo-mark"} title={titleCase(item.name)}>
|
|
1631
|
+
{item.src ? <img src={item.src} alt="" aria-hidden="true" /> : logoInitials(item.name)}
|
|
1632
|
+
</div>
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
function LogoStrip({ items, logoOnly }: { items: LogoItem[]; logoOnly?: boolean }) {
|
|
1637
|
+
return (
|
|
1638
|
+
<div className={logoOnly ? "logo-strip is-logo-only" : "logo-strip"} aria-label="Available services">
|
|
1639
|
+
{items.map((item, index) => (
|
|
1640
|
+
<div className="logo-chip" key={item.name + "-" + index}>
|
|
1641
|
+
<LogoMark item={item} />
|
|
1642
|
+
{logoOnly ? null : <span>{titleCase(item.name)}</span>}
|
|
1643
|
+
</div>
|
|
1644
|
+
))}
|
|
1645
|
+
</div>
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function DataCard() {
|
|
1650
|
+
return (
|
|
1651
|
+
<article className="card">
|
|
1652
|
+
<div className="card-head">
|
|
1653
|
+
<div>
|
|
1654
|
+
<div className="card-title-row">
|
|
1655
|
+
<Icon name="database" />
|
|
1656
|
+
<span className="card-title">
|
|
1657
|
+
<Reveal code="dataConnectors() · savedQueries()">Data</Reveal>
|
|
1658
|
+
</span>
|
|
1659
|
+
</div>
|
|
1660
|
+
</div>
|
|
1661
|
+
</div>
|
|
1662
|
+
|
|
1663
|
+
<LogoStrip items={DATA_LOGO_ITEMS} logoOnly />
|
|
1664
|
+
|
|
1665
|
+
<pre className="sql-preview">
|
|
1666
|
+
<CodeHighlight
|
|
1667
|
+
language="sql"
|
|
1668
|
+
code={\`-- Mock SQL preview
|
|
1669
|
+
select id, event, created_at
|
|
1670
|
+
from app_events
|
|
1671
|
+
order by created_at desc
|
|
1672
|
+
limit 5;\`}
|
|
1673
|
+
/>
|
|
1674
|
+
</pre>
|
|
1675
|
+
<p className="mock-note">
|
|
1676
|
+
This is mock data. A coding agent can swap this for <code>data('name').runSQL(...)</code>
|
|
1677
|
+
once an admin connects a source.
|
|
1678
|
+
</p>
|
|
1679
|
+
|
|
1680
|
+
<ResultTable columns={MOCK_EVENTS.columns} rows={MOCK_EVENTS.rows} caption="mock response" />
|
|
1681
|
+
</article>
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
type ConnResult = { status: string; text: string } | null;
|
|
1686
|
+
|
|
1687
|
+
const DEFAULT_CONNECTOR_REQUEST = \`{
|
|
1688
|
+
"path": "/",
|
|
1689
|
+
"body": {}
|
|
1690
|
+
}\`;
|
|
1691
|
+
|
|
1692
|
+
function ConnectorsCard() {
|
|
1693
|
+
const { serviceConnectorList } = useAppStore();
|
|
1694
|
+
const [active, setActive] = useState(serviceConnectorList[0]?.name || "");
|
|
1695
|
+
const [method, setMethod] = useState("GET");
|
|
1696
|
+
const [requestJson, setRequestJson] = useState(DEFAULT_CONNECTOR_REQUEST);
|
|
1697
|
+
const [result, setResult] = useState<ConnResult>(null);
|
|
1698
|
+
const [busy, setBusy] = useState(false);
|
|
1699
|
+
const [requestError, setRequestError] = useState<string | null>(null);
|
|
1700
|
+
|
|
1701
|
+
const activeConnector = serviceConnectorList.find((item) => item.name === active);
|
|
1702
|
+
const methods = activeConnector?.allowed_methods?.length ? activeConnector.allowed_methods : ["GET", "POST"];
|
|
1703
|
+
|
|
1704
|
+
useEffect(() => {
|
|
1705
|
+
const next = serviceConnectorList[0]?.name || "";
|
|
1706
|
+
setActive(next);
|
|
1707
|
+
setMethod(serviceConnectorList[0]?.allowed_methods?.[0] || "GET");
|
|
1708
|
+
setResult(null);
|
|
1709
|
+
setRequestError(null);
|
|
1710
|
+
}, [serviceConnectorList]);
|
|
1711
|
+
|
|
1712
|
+
async function fetchActive() {
|
|
1713
|
+
if (!activeConnector) return;
|
|
1714
|
+
setBusy(true);
|
|
1715
|
+
setRequestError(null);
|
|
1716
|
+
try {
|
|
1717
|
+
const request = JSON.parse(requestJson) as { path?: unknown; body?: unknown };
|
|
1718
|
+
const path = typeof request.path === "string" && request.path.trim() ? request.path : "/";
|
|
1719
|
+
const body =
|
|
1720
|
+
method === "GET" || method === "HEAD"
|
|
1721
|
+
? undefined
|
|
1722
|
+
: JSON.stringify(request.body ?? {});
|
|
1723
|
+
const response = await connector(active).fetch(path, { method, body });
|
|
1724
|
+
const text = await response.text();
|
|
1725
|
+
setResult({
|
|
1726
|
+
status: response.status + (response.ok ? " OK" : ""),
|
|
1727
|
+
text: truncate(text, 900) || "[empty response]",
|
|
1728
|
+
});
|
|
1729
|
+
} catch (error) {
|
|
1730
|
+
if (error instanceof SyntaxError) {
|
|
1731
|
+
setRequestError("Request JSON is not valid.");
|
|
1732
|
+
} else {
|
|
1733
|
+
setRequestError(message(error));
|
|
1734
|
+
}
|
|
1735
|
+
setResult(null);
|
|
1736
|
+
} finally {
|
|
1737
|
+
setBusy(false);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
return (
|
|
1742
|
+
<article className="card">
|
|
1743
|
+
<div className="card-head">
|
|
1744
|
+
<div>
|
|
1745
|
+
<div className="card-title-row">
|
|
1746
|
+
<Icon name="link" />
|
|
1747
|
+
<span className="card-title">
|
|
1748
|
+
<Reveal code="await serviceConnectors()">Connectors</Reveal>
|
|
1749
|
+
</span>
|
|
1750
|
+
</div>
|
|
1751
|
+
</div>
|
|
1752
|
+
</div>
|
|
1753
|
+
|
|
1754
|
+
<LogoStrip items={CONNECTOR_LOGO_ITEMS} logoOnly />
|
|
1755
|
+
|
|
1756
|
+
{serviceConnectorList.length ? (
|
|
1757
|
+
<>
|
|
1758
|
+
<div className="request-grid">
|
|
1759
|
+
<label className="control-field">
|
|
1760
|
+
<span>Connector</span>
|
|
1761
|
+
<select
|
|
1762
|
+
value={active}
|
|
1763
|
+
onChange={(event) => {
|
|
1764
|
+
const next = serviceConnectorList.find((item) => item.name === event.target.value);
|
|
1765
|
+
setActive(event.target.value);
|
|
1766
|
+
setMethod(next?.allowed_methods?.[0] || "GET");
|
|
1767
|
+
setResult(null);
|
|
1768
|
+
setRequestError(null);
|
|
1769
|
+
}}
|
|
1770
|
+
>
|
|
1771
|
+
{serviceConnectorList.map((item) => (
|
|
1772
|
+
<option value={item.name} key={item.name}>
|
|
1773
|
+
{item.name}
|
|
1774
|
+
</option>
|
|
1775
|
+
))}
|
|
1776
|
+
</select>
|
|
1777
|
+
</label>
|
|
1778
|
+
<label className="control-field">
|
|
1779
|
+
<span>Request type</span>
|
|
1780
|
+
<select value={method} onChange={(event) => setMethod(event.target.value)}>
|
|
1781
|
+
{methods.map((item) => (
|
|
1782
|
+
<option value={item} key={item}>
|
|
1783
|
+
{item}
|
|
1784
|
+
</option>
|
|
1785
|
+
))}
|
|
1786
|
+
</select>
|
|
1787
|
+
</label>
|
|
1788
|
+
</div>
|
|
1789
|
+
|
|
1790
|
+
<label className="json-editor">
|
|
1791
|
+
<span>Request JSON</span>
|
|
1792
|
+
<textarea
|
|
1793
|
+
className="field"
|
|
1794
|
+
rows={5}
|
|
1795
|
+
spellCheck={false}
|
|
1796
|
+
value={requestJson}
|
|
1797
|
+
onChange={(event) => {
|
|
1798
|
+
setRequestJson(event.target.value);
|
|
1799
|
+
setRequestError(null);
|
|
1800
|
+
}}
|
|
1801
|
+
/>
|
|
1802
|
+
</label>
|
|
1803
|
+
|
|
1804
|
+
{requestError ? <p className="field-error">{requestError}</p> : null}
|
|
1805
|
+
|
|
1806
|
+
<ActionTip code={\`await connector(name).fetch(path, {
|
|
1807
|
+
method,
|
|
1808
|
+
body
|
|
1809
|
+
})\`} className="upload-tip">
|
|
1810
|
+
<button className="btn btn-primary" type="button" disabled={busy} onClick={() => void fetchActive()}>
|
|
1811
|
+
{busy ? <span className="spinner" /> : <Icon name="link" />}
|
|
1812
|
+
{busy ? "Sending..." : "Send request"}
|
|
1813
|
+
</button>
|
|
1814
|
+
</ActionTip>
|
|
1815
|
+
|
|
1816
|
+
<div className="response-panel">
|
|
1817
|
+
<div className="result-status">
|
|
1818
|
+
<span>Response</span>
|
|
1819
|
+
<span>{result?.status || "Waiting"}</span>
|
|
1820
|
+
</div>
|
|
1821
|
+
<pre className="preview">{result?.text || "Send a request to see the connector response."}</pre>
|
|
1822
|
+
</div>
|
|
1823
|
+
</>
|
|
1824
|
+
) : (
|
|
1825
|
+
<p className="empty-note admin-empty">Connect a service in the Admin dashboard to try connectors.</p>
|
|
1826
|
+
)}
|
|
1827
|
+
</article>
|
|
1828
|
+
);
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
const AI_SAMPLE_PROMPT = "Summarize what this Railcode template demonstrates in one sentence.";
|
|
1832
|
+
const AI_SAMPLE_RESPONSE =
|
|
1833
|
+
"This template shows a live Railcode app reading identity, storing notes in KV, uploading files, querying Postgres, invoking a saved query, calling Stripe through a service connector, and generating text — all without handling a single credential in the browser.";
|
|
1834
|
+
|
|
1835
|
+
function AiCard() {
|
|
1836
|
+
const [prompt, setPrompt] = useState(AI_SAMPLE_PROMPT);
|
|
1837
|
+
const [response, setResponse] = useState("");
|
|
1838
|
+
const [live, setLive] = useState<boolean | null>(null);
|
|
1839
|
+
const [busy, setBusy] = useState(false);
|
|
1840
|
+
const reduceMotion = usePrefersReducedMotion();
|
|
1841
|
+
|
|
1842
|
+
function revealText(text: string) {
|
|
1843
|
+
if (reduceMotion) {
|
|
1844
|
+
setResponse(text);
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
let i = 0;
|
|
1848
|
+
const interval = setInterval(() => {
|
|
1849
|
+
i += 3;
|
|
1850
|
+
setResponse(text.slice(0, i));
|
|
1851
|
+
if (i >= text.length) clearInterval(interval);
|
|
1852
|
+
}, 12);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
async function generate() {
|
|
1856
|
+
if (live === false) return;
|
|
1857
|
+
const value = prompt.trim();
|
|
1858
|
+
if (!value) return;
|
|
1859
|
+
setBusy(true);
|
|
1860
|
+
setResponse("");
|
|
1861
|
+
try {
|
|
1862
|
+
const result = await llm.generate(value, { metadata: { feature: "starter-home" } });
|
|
1863
|
+
setLive(true);
|
|
1864
|
+
revealText(result.text || AI_SAMPLE_RESPONSE);
|
|
1865
|
+
} catch {
|
|
1866
|
+
setLive(false);
|
|
1867
|
+
revealText(AI_SAMPLE_RESPONSE);
|
|
1868
|
+
} finally {
|
|
1869
|
+
setBusy(false);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
const note =
|
|
1874
|
+
live === true
|
|
1875
|
+
? " "
|
|
1876
|
+
: live === false
|
|
1877
|
+
? "No LLM connected yet — showing a sample response."
|
|
1878
|
+
: "Falls back to a sample response if no LLM is connected.";
|
|
1879
|
+
|
|
1880
|
+
return (
|
|
1881
|
+
<article className="card">
|
|
1882
|
+
<div className="card-head">
|
|
1883
|
+
<div>
|
|
1884
|
+
<div className="card-title-row">
|
|
1885
|
+
<Icon name="chat" />
|
|
1886
|
+
<span className="card-title">
|
|
1887
|
+
<Reveal code="await llm.generate(prompt, opts)">AI</Reveal>
|
|
1888
|
+
</span>
|
|
1889
|
+
</div>
|
|
1890
|
+
</div>
|
|
1891
|
+
</div>
|
|
1892
|
+
|
|
1893
|
+
<div className="ai-body">
|
|
1894
|
+
<LogoStrip items={LLM_LOGOS} logoOnly />
|
|
1895
|
+
<textarea
|
|
1896
|
+
className="field"
|
|
1897
|
+
rows={2}
|
|
1898
|
+
disabled={live === false}
|
|
1899
|
+
value={prompt}
|
|
1900
|
+
onChange={(event) => setPrompt(event.target.value)}
|
|
1901
|
+
/>
|
|
1902
|
+
<div className="ai-foot-row">
|
|
1903
|
+
<span className="ai-note">{note}</span>
|
|
1904
|
+
<ActionTip code="await llm.generate(prompt, opts)">
|
|
1905
|
+
<button className="btn btn-primary" type="button" disabled={busy || live === false} onClick={() => void generate()}>
|
|
1906
|
+
{busy ? "Generating…" : "Generate"}
|
|
1907
|
+
</button>
|
|
1908
|
+
</ActionTip>
|
|
1909
|
+
</div>
|
|
1910
|
+
{response ? (
|
|
1911
|
+
<div className="ai-response is-visible">
|
|
1912
|
+
{response}
|
|
1913
|
+
{busy ? <span className="cursor" /> : null}
|
|
1914
|
+
</div>
|
|
1915
|
+
) : null}
|
|
1916
|
+
</div>
|
|
1917
|
+
</article>
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
function IconSprite() {
|
|
1922
|
+
return (
|
|
1923
|
+
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
|
|
1924
|
+
<symbol id="i-sun" viewBox="0 0 24 24">
|
|
1925
|
+
<circle cx="12" cy="12" r="5" />
|
|
1926
|
+
<line x1="12" y1="1" x2="12" y2="3" />
|
|
1927
|
+
<line x1="12" y1="21" x2="12" y2="23" />
|
|
1928
|
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
|
1929
|
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
|
1930
|
+
<line x1="1" y1="12" x2="3" y2="12" />
|
|
1931
|
+
<line x1="21" y1="12" x2="23" y2="12" />
|
|
1932
|
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
|
1933
|
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
|
1934
|
+
</symbol>
|
|
1935
|
+
<symbol id="i-moon" viewBox="0 0 24 24">
|
|
1936
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
|
1937
|
+
</symbol>
|
|
1938
|
+
<symbol id="i-plus" viewBox="0 0 24 24">
|
|
1939
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
1940
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
1941
|
+
</symbol>
|
|
1942
|
+
<symbol id="i-x" viewBox="0 0 24 24">
|
|
1943
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
1944
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
1945
|
+
</symbol>
|
|
1946
|
+
<symbol id="i-check" viewBox="0 0 24 24">
|
|
1947
|
+
<polyline points="20 6 9 17 4 12" />
|
|
1948
|
+
</symbol>
|
|
1949
|
+
<symbol id="i-checklist" viewBox="0 0 24 24">
|
|
1950
|
+
<polyline points="9 11 12 14 22 4" />
|
|
1951
|
+
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
|
1952
|
+
</symbol>
|
|
1953
|
+
<symbol id="i-upload" viewBox="0 0 24 24">
|
|
1954
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
1955
|
+
<polyline points="17 8 12 3 7 8" />
|
|
1956
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
1957
|
+
</symbol>
|
|
1958
|
+
<symbol id="i-file" viewBox="0 0 24 24">
|
|
1959
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
1960
|
+
<polyline points="14 2 14 8 20 8" />
|
|
1961
|
+
</symbol>
|
|
1962
|
+
<symbol id="i-database" viewBox="0 0 24 24">
|
|
1963
|
+
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
|
1964
|
+
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
|
|
1965
|
+
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
|
|
1966
|
+
</symbol>
|
|
1967
|
+
<symbol id="i-link" viewBox="0 0 24 24">
|
|
1968
|
+
<path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3" />
|
|
1969
|
+
<line x1="8" y1="12" x2="16" y2="12" />
|
|
1970
|
+
</symbol>
|
|
1971
|
+
<symbol id="i-chat" viewBox="0 0 24 24">
|
|
1972
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
1973
|
+
</symbol>
|
|
1974
|
+
<symbol id="i-code" viewBox="0 0 24 24">
|
|
1975
|
+
<polyline points="16 18 22 12 16 6" />
|
|
1976
|
+
<polyline points="8 6 2 12 8 18" />
|
|
1977
|
+
</symbol>
|
|
1978
|
+
</svg>
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
export function App() {
|
|
1983
|
+
const state = useAppStore();
|
|
1984
|
+
|
|
1985
|
+
useEffect(() => {
|
|
1986
|
+
void state.load();
|
|
1987
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1988
|
+
}, []);
|
|
1989
|
+
|
|
1990
|
+
return (
|
|
1991
|
+
<>
|
|
1992
|
+
<IconSprite />
|
|
1993
|
+
<header className="topbar">
|
|
1994
|
+
<div className="topbar-inner">
|
|
1995
|
+
<div className="brand">
|
|
1996
|
+
<div className="brand-text">
|
|
1997
|
+
<span className="brand-app">{APP_SLUG}</span>
|
|
1998
|
+
</div>
|
|
1999
|
+
</div>
|
|
2000
|
+
<ThemeToggle />
|
|
2001
|
+
</div>
|
|
2002
|
+
</header>
|
|
2003
|
+
|
|
2004
|
+
<div className="shell">
|
|
2005
|
+
{state.error ? (
|
|
2006
|
+
<div className="error-banner">
|
|
2007
|
+
<span>{state.error}</span>
|
|
2008
|
+
<button type="button" onClick={state.clearError}>
|
|
2009
|
+
Dismiss
|
|
2010
|
+
</button>
|
|
2011
|
+
</div>
|
|
2012
|
+
) : null}
|
|
2013
|
+
|
|
2014
|
+
<Hero />
|
|
2015
|
+
|
|
2016
|
+
<div className="guide-flow">
|
|
2017
|
+
<GuideSection
|
|
2018
|
+
title="Railcode apps can store data"
|
|
2019
|
+
body="A data store is available for every Railcode app. Store data that everyone in the team can see and use, or make the data private to each app user."
|
|
2020
|
+
>
|
|
2021
|
+
<TodosCard />
|
|
2022
|
+
</GuideSection>
|
|
2023
|
+
|
|
2024
|
+
<GuideSection
|
|
2025
|
+
title="They can store files too"
|
|
2026
|
+
body="Uploads are scoped to the app and served back through Railcode, so teams can attach docs, exports, screenshots, and other working files without wiring storage."
|
|
2027
|
+
>
|
|
2028
|
+
<FilesCard />
|
|
2029
|
+
</GuideSection>
|
|
2030
|
+
|
|
2031
|
+
<GuideSection
|
|
2032
|
+
title="They can query data sources"
|
|
2033
|
+
body="Connect all your data sources and build interactive visualizations, run integrated analyses, or build tools to manage operations."
|
|
2034
|
+
>
|
|
2035
|
+
<DataCard />
|
|
2036
|
+
</GuideSection>
|
|
2037
|
+
|
|
2038
|
+
<GuideSection
|
|
2039
|
+
title="They can connect to external services"
|
|
2040
|
+
body="Railcode apps can securely connect to your SaaS services so you can manage refunds, send emails, view data, and anything else you need."
|
|
2041
|
+
>
|
|
2042
|
+
<ConnectorsCard />
|
|
2043
|
+
</GuideSection>
|
|
2044
|
+
|
|
2045
|
+
<GuideSection
|
|
2046
|
+
title="They can use AI"
|
|
2047
|
+
body="Build AI-native apps that use LLMs to enable powerful workflows."
|
|
2048
|
+
>
|
|
2049
|
+
<AiCard />
|
|
2050
|
+
</GuideSection>
|
|
2051
|
+
|
|
2052
|
+
</div>
|
|
2053
|
+
|
|
2054
|
+
<footer>
|
|
2055
|
+
<span>
|
|
2056
|
+
Scaffolded with <code>railcode init --template react</code>
|
|
2057
|
+
</span>
|
|
2058
|
+
</footer>
|
|
2059
|
+
</div>
|
|
2060
|
+
</>
|
|
2061
|
+
);
|
|
2062
|
+
}
|
|
2063
|
+
`;
|
|
2064
|
+
}
|
|
2065
|
+
function reactStylesCss() {
|
|
2066
|
+
return `:root {
|
|
2067
|
+
--bg: #ffffff;
|
|
2068
|
+
--bg-inset: #f5f6f8;
|
|
2069
|
+
--card: #ffffff;
|
|
2070
|
+
--ink-1: #1a1f36;
|
|
2071
|
+
--ink-2: #4b5563;
|
|
2072
|
+
--ink-3: #687385;
|
|
2073
|
+
--ink-4: #9aa3b2;
|
|
2074
|
+
--line-quiet: rgba(26, 31, 54, 0.05);
|
|
2075
|
+
--line: rgba(26, 31, 54, 0.1);
|
|
2076
|
+
--line-strong: rgba(26, 31, 54, 0.2);
|
|
2077
|
+
--primary: #2563eb;
|
|
2078
|
+
--primary-ink: #ffffff;
|
|
2079
|
+
--primary-wash: rgba(37, 99, 235, 0.08);
|
|
2080
|
+
--solid: #1a1f36;
|
|
2081
|
+
--solid-ink: #ffffff;
|
|
2082
|
+
--ring: #2563eb;
|
|
2083
|
+
--code-bg: #161b2c;
|
|
2084
|
+
--code-ink: #d7deed;
|
|
2085
|
+
--code-keyword: #c4b5fd;
|
|
2086
|
+
--code-call: #7dd3fc;
|
|
2087
|
+
--code-string: #86efac;
|
|
2088
|
+
--code-number: #fbbf67;
|
|
2089
|
+
--code-punct: rgba(215, 222, 237, 0.5);
|
|
2090
|
+
--radius-sm: 4px;
|
|
2091
|
+
--radius-md: 6px;
|
|
2092
|
+
--radius-lg: 8px;
|
|
2093
|
+
--radius-xl: 12px;
|
|
2094
|
+
--space-2: 8px;
|
|
2095
|
+
--space-3: 12px;
|
|
2096
|
+
--space-4: 16px;
|
|
2097
|
+
--space-5: 20px;
|
|
2098
|
+
--space-6: 24px;
|
|
2099
|
+
--space-10: 40px;
|
|
2100
|
+
--space-12: 48px;
|
|
2101
|
+
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
2102
|
+
--font-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
|
|
2103
|
+
color-scheme: light;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
@media (prefers-color-scheme: dark) {
|
|
2107
|
+
:root {
|
|
2108
|
+
--bg: #18181b;
|
|
2109
|
+
--bg-inset: #232327;
|
|
2110
|
+
--card: #1f1f23;
|
|
2111
|
+
--ink-1: #ededee;
|
|
2112
|
+
--ink-2: #c2c2c8;
|
|
2113
|
+
--ink-3: #9b9ba3;
|
|
2114
|
+
--ink-4: #6b6b73;
|
|
2115
|
+
--line-quiet: rgba(255, 255, 255, 0.05);
|
|
2116
|
+
--line: rgba(255, 255, 255, 0.09);
|
|
2117
|
+
--line-strong: rgba(255, 255, 255, 0.16);
|
|
2118
|
+
--primary: #60a5fa;
|
|
2119
|
+
--primary-ink: #0f1115;
|
|
2120
|
+
--primary-wash: rgba(96, 165, 250, 0.12);
|
|
2121
|
+
--solid: #ededee;
|
|
2122
|
+
--solid-ink: #18181b;
|
|
2123
|
+
--ring: #60a5fa;
|
|
2124
|
+
color-scheme: dark;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
:root[data-theme="dark"] {
|
|
2129
|
+
--bg: #18181b;
|
|
2130
|
+
--bg-inset: #232327;
|
|
2131
|
+
--card: #1f1f23;
|
|
2132
|
+
--ink-1: #ededee;
|
|
2133
|
+
--ink-2: #c2c2c8;
|
|
2134
|
+
--ink-3: #9b9ba3;
|
|
2135
|
+
--ink-4: #6b6b73;
|
|
2136
|
+
--line-quiet: rgba(255, 255, 255, 0.05);
|
|
2137
|
+
--line: rgba(255, 255, 255, 0.09);
|
|
2138
|
+
--line-strong: rgba(255, 255, 255, 0.16);
|
|
2139
|
+
--primary: #60a5fa;
|
|
2140
|
+
--primary-ink: #0f1115;
|
|
2141
|
+
--primary-wash: rgba(96, 165, 250, 0.12);
|
|
2142
|
+
--solid: #ededee;
|
|
2143
|
+
--solid-ink: #18181b;
|
|
2144
|
+
--ring: #60a5fa;
|
|
2145
|
+
color-scheme: dark;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
:root[data-theme="light"] {
|
|
2149
|
+
--bg: #ffffff;
|
|
2150
|
+
--bg-inset: #f5f6f8;
|
|
2151
|
+
--card: #ffffff;
|
|
2152
|
+
--ink-1: #1a1f36;
|
|
2153
|
+
--ink-2: #4b5563;
|
|
2154
|
+
--ink-3: #687385;
|
|
2155
|
+
--ink-4: #9aa3b2;
|
|
2156
|
+
--line-quiet: rgba(26, 31, 54, 0.05);
|
|
2157
|
+
--line: rgba(26, 31, 54, 0.1);
|
|
2158
|
+
--line-strong: rgba(26, 31, 54, 0.2);
|
|
2159
|
+
--primary: #2563eb;
|
|
2160
|
+
--primary-ink: #ffffff;
|
|
2161
|
+
--primary-wash: rgba(37, 99, 235, 0.08);
|
|
2162
|
+
--solid: #1a1f36;
|
|
2163
|
+
--solid-ink: #ffffff;
|
|
2164
|
+
--ring: #2563eb;
|
|
2165
|
+
color-scheme: light;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
* {
|
|
2169
|
+
box-sizing: border-box;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
body {
|
|
2173
|
+
margin: 0;
|
|
2174
|
+
min-width: 320px;
|
|
2175
|
+
background: var(--bg);
|
|
2176
|
+
color: var(--ink-1);
|
|
2177
|
+
font-family: var(--font-sans);
|
|
2178
|
+
font-size: 14px;
|
|
2179
|
+
line-height: 1.5;
|
|
2180
|
+
-webkit-font-smoothing: antialiased;
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
h1,
|
|
2184
|
+
h2,
|
|
2185
|
+
h3,
|
|
2186
|
+
p {
|
|
2187
|
+
margin: 0;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
button,
|
|
2191
|
+
input,
|
|
2192
|
+
textarea {
|
|
2193
|
+
font: inherit;
|
|
2194
|
+
color: inherit;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
button {
|
|
2198
|
+
cursor: pointer;
|
|
2199
|
+
border: 0;
|
|
2200
|
+
background: transparent;
|
|
2201
|
+
padding: 0;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
button:disabled {
|
|
2205
|
+
cursor: not-allowed;
|
|
2206
|
+
opacity: 0.5;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
:focus-visible {
|
|
2210
|
+
outline: 2px solid var(--ring);
|
|
2211
|
+
outline-offset: 2px;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
.tabular {
|
|
2215
|
+
font-variant-numeric: tabular-nums;
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
.icon {
|
|
2219
|
+
width: 16px;
|
|
2220
|
+
height: 16px;
|
|
2221
|
+
stroke: currentColor;
|
|
2222
|
+
fill: none;
|
|
2223
|
+
stroke-width: 1.8;
|
|
2224
|
+
stroke-linecap: round;
|
|
2225
|
+
stroke-linejoin: round;
|
|
2226
|
+
flex-shrink: 0;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
.shell {
|
|
2230
|
+
max-width: 1180px;
|
|
2231
|
+
margin: 0 auto;
|
|
2232
|
+
padding: 0 24px 96px;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
/* ---------- topbar ---------- */
|
|
2236
|
+
|
|
2237
|
+
.topbar {
|
|
2238
|
+
border-bottom: 1px solid var(--line);
|
|
2239
|
+
margin-bottom: var(--space-10);
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
.topbar-inner {
|
|
2243
|
+
max-width: 1180px;
|
|
2244
|
+
margin: 0 auto;
|
|
2245
|
+
padding: 18px 24px;
|
|
2246
|
+
display: flex;
|
|
2247
|
+
align-items: center;
|
|
2248
|
+
justify-content: space-between;
|
|
2249
|
+
gap: var(--space-4);
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
.brand {
|
|
2253
|
+
min-width: 0;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
.brand-text {
|
|
2257
|
+
display: flex;
|
|
2258
|
+
align-items: baseline;
|
|
2259
|
+
gap: 8px;
|
|
2260
|
+
min-width: 0;
|
|
2261
|
+
font-size: 13px;
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
.brand-app {
|
|
2265
|
+
font-weight: 600;
|
|
2266
|
+
color: var(--ink-1);
|
|
2267
|
+
font-family: var(--font-mono);
|
|
2268
|
+
white-space: nowrap;
|
|
2269
|
+
overflow: hidden;
|
|
2270
|
+
text-overflow: ellipsis;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
.theme-toggle {
|
|
2274
|
+
width: 32px;
|
|
2275
|
+
height: 32px;
|
|
2276
|
+
border-radius: var(--radius-md);
|
|
2277
|
+
border: 1px solid var(--line);
|
|
2278
|
+
background: transparent;
|
|
2279
|
+
display: grid;
|
|
2280
|
+
place-items: center;
|
|
2281
|
+
color: var(--ink-3);
|
|
2282
|
+
transition:
|
|
2283
|
+
background-color 120ms ease,
|
|
2284
|
+
color 120ms ease;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
.theme-toggle:hover {
|
|
2288
|
+
background: var(--bg-inset);
|
|
2289
|
+
color: var(--ink-1);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
.theme-toggle .icon-moon {
|
|
2293
|
+
display: none;
|
|
2294
|
+
}
|
|
2295
|
+
:root[data-theme="dark"] .theme-toggle .icon-sun {
|
|
2296
|
+
display: none;
|
|
2297
|
+
}
|
|
2298
|
+
:root[data-theme="dark"] .theme-toggle .icon-moon {
|
|
2299
|
+
display: block;
|
|
2300
|
+
}
|
|
2301
|
+
@media (prefers-color-scheme: dark) {
|
|
2302
|
+
:root:not([data-theme="light"]) .theme-toggle .icon-sun {
|
|
2303
|
+
display: none;
|
|
2304
|
+
}
|
|
2305
|
+
:root:not([data-theme="light"]) .theme-toggle .icon-moon {
|
|
2306
|
+
display: block;
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/* ---------- hero ---------- */
|
|
2311
|
+
|
|
2312
|
+
.hero {
|
|
2313
|
+
margin-bottom: var(--space-12);
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
.hero h1 {
|
|
2317
|
+
font-size: 30px;
|
|
2318
|
+
font-weight: 650;
|
|
2319
|
+
letter-spacing: -0.01em;
|
|
2320
|
+
line-height: 1.2;
|
|
2321
|
+
text-wrap: balance;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
.hero-sub {
|
|
2325
|
+
margin-top: var(--space-2);
|
|
2326
|
+
color: var(--ink-3);
|
|
2327
|
+
font-size: 15px;
|
|
2328
|
+
max-width: 72ch;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2331
|
+
.team-row {
|
|
2332
|
+
margin-top: var(--space-6);
|
|
2333
|
+
display: flex;
|
|
2334
|
+
align-items: center;
|
|
2335
|
+
gap: var(--space-3);
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
.avatar-stack {
|
|
2339
|
+
display: flex;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
.avatar {
|
|
2343
|
+
width: 28px;
|
|
2344
|
+
height: 28px;
|
|
2345
|
+
border-radius: 50%;
|
|
2346
|
+
border: 2px solid var(--bg);
|
|
2347
|
+
background: var(--bg-inset);
|
|
2348
|
+
color: var(--ink-2);
|
|
2349
|
+
display: grid;
|
|
2350
|
+
place-items: center;
|
|
2351
|
+
font-size: 11px;
|
|
2352
|
+
font-weight: 650;
|
|
2353
|
+
margin-left: -6px;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
.avatar:first-child {
|
|
2357
|
+
margin-left: 0;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
.avatar.is-you {
|
|
2361
|
+
background: var(--primary-wash);
|
|
2362
|
+
color: var(--primary);
|
|
2363
|
+
box-shadow: 0 0 0 1px var(--primary) inset;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
.team-caption {
|
|
2367
|
+
color: var(--ink-4);
|
|
2368
|
+
font-size: 13px;
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
/* ---------- guide flow ---------- */
|
|
2372
|
+
|
|
2373
|
+
.guide-flow {
|
|
2374
|
+
display: flex;
|
|
2375
|
+
flex-direction: column;
|
|
2376
|
+
gap: var(--space-12);
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
.guide-section {
|
|
2380
|
+
display: grid;
|
|
2381
|
+
grid-template-columns: minmax(0, 0.72fr) minmax(420px, 1fr);
|
|
2382
|
+
gap: var(--space-10);
|
|
2383
|
+
align-items: start;
|
|
2384
|
+
padding-top: var(--space-10);
|
|
2385
|
+
border-top: 1px solid var(--line);
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
.guide-section:first-child {
|
|
2389
|
+
padding-top: 0;
|
|
2390
|
+
border-top: 0;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
.guide-copy {
|
|
2394
|
+
position: sticky;
|
|
2395
|
+
top: 24px;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
.guide-copy h2 {
|
|
2399
|
+
font-size: 22px;
|
|
2400
|
+
font-weight: 650;
|
|
2401
|
+
line-height: 1.25;
|
|
2402
|
+
text-wrap: balance;
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
.guide-copy p {
|
|
2406
|
+
margin-top: var(--space-3);
|
|
2407
|
+
color: var(--ink-3);
|
|
2408
|
+
font-size: 14px;
|
|
2409
|
+
line-height: 1.65;
|
|
2410
|
+
max-width: 42ch;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
.guide-demo {
|
|
2414
|
+
min-width: 0;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
/* ---------- card ---------- */
|
|
2418
|
+
|
|
2419
|
+
.card {
|
|
2420
|
+
background: var(--card);
|
|
2421
|
+
border: 1px solid var(--line);
|
|
2422
|
+
border-radius: var(--radius-xl);
|
|
2423
|
+
padding: var(--space-5);
|
|
2424
|
+
display: flex;
|
|
2425
|
+
flex-direction: column;
|
|
2426
|
+
opacity: 0;
|
|
2427
|
+
transform: translateY(6px);
|
|
2428
|
+
animation: rise 420ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
.card:nth-of-type(1) {
|
|
2432
|
+
animation-delay: 40ms;
|
|
2433
|
+
}
|
|
2434
|
+
.card:nth-of-type(2) {
|
|
2435
|
+
animation-delay: 80ms;
|
|
2436
|
+
}
|
|
2437
|
+
.card:nth-of-type(3) {
|
|
2438
|
+
animation-delay: 120ms;
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
@keyframes rise {
|
|
2442
|
+
to {
|
|
2443
|
+
opacity: 1;
|
|
2444
|
+
transform: translateY(0);
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
.card-head {
|
|
2449
|
+
display: flex;
|
|
2450
|
+
align-items: flex-start;
|
|
2451
|
+
justify-content: space-between;
|
|
2452
|
+
gap: var(--space-3);
|
|
2453
|
+
margin-bottom: var(--space-4);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
.card-title-row {
|
|
2457
|
+
display: flex;
|
|
2458
|
+
align-items: center;
|
|
2459
|
+
gap: 8px;
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
.card-title-row .icon {
|
|
2463
|
+
color: var(--ink-3);
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
.card-title {
|
|
2467
|
+
font-size: 14px;
|
|
2468
|
+
font-weight: 650;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
.pill {
|
|
2472
|
+
flex: none;
|
|
2473
|
+
display: inline-flex;
|
|
2474
|
+
align-items: center;
|
|
2475
|
+
gap: 5px;
|
|
2476
|
+
padding: 3px 9px;
|
|
2477
|
+
border-radius: 100px;
|
|
2478
|
+
font-size: 11px;
|
|
2479
|
+
font-weight: 650;
|
|
2480
|
+
letter-spacing: 0.01em;
|
|
2481
|
+
white-space: nowrap;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
/* ---------- buttons & inputs ---------- */
|
|
2485
|
+
|
|
2486
|
+
.btn {
|
|
2487
|
+
display: inline-flex;
|
|
2488
|
+
align-items: center;
|
|
2489
|
+
justify-content: center;
|
|
2490
|
+
gap: 6px;
|
|
2491
|
+
border-radius: var(--radius-md);
|
|
2492
|
+
border: 1px solid transparent;
|
|
2493
|
+
padding: 8px 13px;
|
|
2494
|
+
font-size: 13px;
|
|
2495
|
+
font-weight: 600;
|
|
2496
|
+
transition:
|
|
2497
|
+
background-color 120ms ease,
|
|
2498
|
+
border-color 120ms ease,
|
|
2499
|
+
color 120ms ease,
|
|
2500
|
+
transform 80ms ease;
|
|
2501
|
+
white-space: nowrap;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
.btn:active {
|
|
2505
|
+
transform: scale(0.98);
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
.btn-primary {
|
|
2509
|
+
background: var(--primary);
|
|
2510
|
+
color: var(--primary-ink);
|
|
2511
|
+
}
|
|
2512
|
+
|
|
2513
|
+
.btn-primary:hover {
|
|
2514
|
+
filter: brightness(1.06);
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
.btn-ghost {
|
|
2518
|
+
background: transparent;
|
|
2519
|
+
border-color: var(--line);
|
|
2520
|
+
color: var(--ink-2);
|
|
2521
|
+
}
|
|
2522
|
+
|
|
2523
|
+
.btn-ghost:hover {
|
|
2524
|
+
background: var(--bg-inset);
|
|
2525
|
+
border-color: var(--line-strong);
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
.btn-ghost.is-active {
|
|
2529
|
+
background: var(--primary-wash);
|
|
2530
|
+
border-color: var(--primary);
|
|
2531
|
+
color: var(--primary);
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
.field {
|
|
2535
|
+
flex: 1;
|
|
2536
|
+
background: var(--bg-inset);
|
|
2537
|
+
border: 1px solid transparent;
|
|
2538
|
+
border-radius: var(--radius-md);
|
|
2539
|
+
padding: 8px 11px;
|
|
2540
|
+
font-size: 13px;
|
|
2541
|
+
color: var(--ink-1);
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
.field::placeholder {
|
|
2545
|
+
color: var(--ink-4);
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
.field:focus {
|
|
2549
|
+
background: var(--card);
|
|
2550
|
+
border-color: var(--primary);
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
textarea.field {
|
|
2554
|
+
resize: vertical;
|
|
2555
|
+
min-height: 64px;
|
|
2556
|
+
line-height: 1.5;
|
|
2557
|
+
width: 100%;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
.chip-row {
|
|
2561
|
+
display: flex;
|
|
2562
|
+
flex-wrap: wrap;
|
|
2563
|
+
gap: 8px;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/* ---------- logos ---------- */
|
|
2567
|
+
|
|
2568
|
+
.logo-strip {
|
|
2569
|
+
display: flex;
|
|
2570
|
+
flex-wrap: wrap;
|
|
2571
|
+
gap: 8px;
|
|
2572
|
+
margin-bottom: var(--space-4);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
.logo-chip {
|
|
2576
|
+
min-width: 0;
|
|
2577
|
+
display: inline-flex;
|
|
2578
|
+
align-items: center;
|
|
2579
|
+
gap: 7px;
|
|
2580
|
+
padding: 5px 9px 5px 6px;
|
|
2581
|
+
border: 1px solid var(--line);
|
|
2582
|
+
border-radius: var(--radius-md);
|
|
2583
|
+
color: var(--ink-2);
|
|
2584
|
+
background: var(--bg);
|
|
2585
|
+
font-size: 12px;
|
|
2586
|
+
font-weight: 600;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
.logo-chip span {
|
|
2590
|
+
white-space: nowrap;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
.logo-strip.is-logo-only {
|
|
2594
|
+
gap: 10px;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
.logo-strip.is-logo-only .logo-chip {
|
|
2598
|
+
padding: 0;
|
|
2599
|
+
border: 0;
|
|
2600
|
+
background: transparent;
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
.logo-strip.is-logo-only .logo-mark {
|
|
2604
|
+
width: 30px;
|
|
2605
|
+
height: 30px;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
.logo-strip.is-logo-only .logo-mark.has-image {
|
|
2609
|
+
border: 0;
|
|
2610
|
+
padding: 3px;
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
.logo-mark {
|
|
2614
|
+
width: 24px;
|
|
2615
|
+
height: 24px;
|
|
2616
|
+
border-radius: var(--radius-sm);
|
|
2617
|
+
display: grid;
|
|
2618
|
+
place-items: center;
|
|
2619
|
+
background: var(--solid);
|
|
2620
|
+
color: var(--solid-ink);
|
|
2621
|
+
font-family: var(--font-mono);
|
|
2622
|
+
font-size: 9px;
|
|
2623
|
+
font-weight: 750;
|
|
2624
|
+
letter-spacing: 0;
|
|
2625
|
+
overflow: hidden;
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
.logo-mark.has-image {
|
|
2629
|
+
background: #fff;
|
|
2630
|
+
border: 1px solid var(--line);
|
|
2631
|
+
padding: 4px;
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
.logo-mark img {
|
|
2635
|
+
display: block;
|
|
2636
|
+
width: 100%;
|
|
2637
|
+
height: 100%;
|
|
2638
|
+
object-fit: contain;
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
/* ---------- to-dos ---------- */
|
|
2642
|
+
|
|
2643
|
+
.add-row {
|
|
2644
|
+
display: flex;
|
|
2645
|
+
gap: 8px;
|
|
2646
|
+
margin-bottom: var(--space-4);
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
.todo-list {
|
|
2650
|
+
display: flex;
|
|
2651
|
+
flex-direction: column;
|
|
2652
|
+
gap: 2px;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
.todo-row {
|
|
2656
|
+
display: flex;
|
|
2657
|
+
align-items: flex-start;
|
|
2658
|
+
gap: 10px;
|
|
2659
|
+
padding: 9px 6px;
|
|
2660
|
+
border-radius: var(--radius-md);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
.todo-row:hover {
|
|
2664
|
+
background: var(--bg-inset);
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
.checkbox {
|
|
2668
|
+
padding: 0;
|
|
2669
|
+
margin-top: 1px;
|
|
2670
|
+
width: 17px;
|
|
2671
|
+
height: 17px;
|
|
2672
|
+
border-radius: 5px;
|
|
2673
|
+
border: 1.5px solid var(--line-strong);
|
|
2674
|
+
background: var(--card);
|
|
2675
|
+
display: grid;
|
|
2676
|
+
place-items: center;
|
|
2677
|
+
flex: none;
|
|
2678
|
+
color: transparent;
|
|
2679
|
+
transition:
|
|
2680
|
+
background-color 120ms ease,
|
|
2681
|
+
border-color 120ms ease;
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
.checkbox .icon {
|
|
2685
|
+
width: 11px;
|
|
2686
|
+
height: 11px;
|
|
2687
|
+
stroke-width: 3;
|
|
2688
|
+
pointer-events: none;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
.todo-row.is-done .checkbox {
|
|
2692
|
+
background: var(--solid);
|
|
2693
|
+
border-color: var(--solid);
|
|
2694
|
+
color: var(--solid-ink);
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
.todo-body {
|
|
2698
|
+
flex: 1;
|
|
2699
|
+
min-width: 0;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
.todo-text {
|
|
2703
|
+
font-size: 13.5px;
|
|
2704
|
+
word-break: break-word;
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
.todo-row.is-done .todo-text {
|
|
2708
|
+
color: var(--ink-4);
|
|
2709
|
+
text-decoration: line-through;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
.todo-meta {
|
|
2713
|
+
margin-top: 2px;
|
|
2714
|
+
color: var(--ink-4);
|
|
2715
|
+
font-size: 12px;
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
.row-delete {
|
|
2719
|
+
flex: none;
|
|
2720
|
+
width: 24px;
|
|
2721
|
+
height: 24px;
|
|
2722
|
+
border-radius: var(--radius-sm);
|
|
2723
|
+
display: grid;
|
|
2724
|
+
place-items: center;
|
|
2725
|
+
color: var(--ink-4);
|
|
2726
|
+
opacity: 0.55;
|
|
2727
|
+
transition:
|
|
2728
|
+
opacity 120ms ease,
|
|
2729
|
+
background-color 120ms ease,
|
|
2730
|
+
color 120ms ease;
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
.todo-row:hover .row-delete,
|
|
2734
|
+
.todo-row:focus-within .row-delete,
|
|
2735
|
+
.file-row:hover .row-delete,
|
|
2736
|
+
.file-row:focus-within .row-delete {
|
|
2737
|
+
opacity: 1;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
.row-delete:hover {
|
|
2741
|
+
background: var(--bg-inset);
|
|
2742
|
+
color: var(--ink-1);
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
.row-delete .icon {
|
|
2746
|
+
width: 14px;
|
|
2747
|
+
height: 14px;
|
|
2748
|
+
pointer-events: none;
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
.empty-note {
|
|
2752
|
+
color: var(--ink-4);
|
|
2753
|
+
font-size: 12.5px;
|
|
2754
|
+
padding: 10px 6px;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/* ---------- files ---------- */
|
|
2758
|
+
|
|
2759
|
+
.file-list {
|
|
2760
|
+
display: flex;
|
|
2761
|
+
flex-direction: column;
|
|
2762
|
+
gap: 2px;
|
|
2763
|
+
margin-bottom: var(--space-4);
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
.file-row {
|
|
2767
|
+
display: flex;
|
|
2768
|
+
align-items: center;
|
|
2769
|
+
gap: 10px;
|
|
2770
|
+
padding: 8px 6px;
|
|
2771
|
+
border-radius: var(--radius-md);
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
.file-row:hover {
|
|
2775
|
+
background: var(--bg-inset);
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
.file-icon {
|
|
2779
|
+
width: 28px;
|
|
2780
|
+
height: 28px;
|
|
2781
|
+
border-radius: var(--radius-sm);
|
|
2782
|
+
background: var(--bg-inset);
|
|
2783
|
+
display: grid;
|
|
2784
|
+
place-items: center;
|
|
2785
|
+
color: var(--ink-3);
|
|
2786
|
+
flex: none;
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2789
|
+
.file-icon .icon {
|
|
2790
|
+
width: 14px;
|
|
2791
|
+
height: 14px;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
.file-body {
|
|
2795
|
+
flex: 1;
|
|
2796
|
+
min-width: 0;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
.file-name {
|
|
2800
|
+
display: block;
|
|
2801
|
+
font-size: 13px;
|
|
2802
|
+
font-weight: 550;
|
|
2803
|
+
color: var(--ink-1);
|
|
2804
|
+
text-decoration: none;
|
|
2805
|
+
white-space: nowrap;
|
|
2806
|
+
overflow: hidden;
|
|
2807
|
+
text-overflow: ellipsis;
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
.file-name:hover {
|
|
2811
|
+
text-decoration: underline;
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
.file-meta {
|
|
2815
|
+
color: var(--ink-4);
|
|
2816
|
+
font-size: 11.5px;
|
|
2817
|
+
font-family: var(--font-mono);
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
.file-input-hidden {
|
|
2821
|
+
display: none;
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
.upload-btn {
|
|
2825
|
+
margin-top: auto;
|
|
2826
|
+
align-self: flex-start;
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
/* ---------- data / connectors result areas ---------- */
|
|
2830
|
+
|
|
2831
|
+
.result-area {
|
|
2832
|
+
margin-top: var(--space-4);
|
|
2833
|
+
min-height: 22px;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
.result-placeholder {
|
|
2837
|
+
color: var(--ink-4);
|
|
2838
|
+
font-size: 12.5px;
|
|
2839
|
+
padding-top: 2px;
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
.sql-preview {
|
|
2843
|
+
margin: 0 0 var(--space-3);
|
|
2844
|
+
background: var(--bg-inset);
|
|
2845
|
+
border-radius: var(--radius-md);
|
|
2846
|
+
padding: 11px 12px;
|
|
2847
|
+
font-family: var(--font-mono);
|
|
2848
|
+
font-size: 12px;
|
|
2849
|
+
line-height: 1.55;
|
|
2850
|
+
color: var(--ink-2);
|
|
2851
|
+
white-space: pre-wrap;
|
|
2852
|
+
overflow-x: auto;
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
.mock-note {
|
|
2856
|
+
color: var(--ink-4);
|
|
2857
|
+
font-size: 12.5px;
|
|
2858
|
+
line-height: 1.55;
|
|
2859
|
+
margin-bottom: var(--space-4);
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
.mock-note code {
|
|
2863
|
+
font-family: var(--font-mono);
|
|
2864
|
+
color: var(--ink-2);
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
.request-grid {
|
|
2868
|
+
display: grid;
|
|
2869
|
+
grid-template-columns: minmax(0, 1fr) minmax(120px, 0.42fr);
|
|
2870
|
+
gap: var(--space-3);
|
|
2871
|
+
margin-bottom: var(--space-3);
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
.control-field,
|
|
2875
|
+
.json-editor {
|
|
2876
|
+
display: flex;
|
|
2877
|
+
flex-direction: column;
|
|
2878
|
+
gap: 6px;
|
|
2879
|
+
min-width: 0;
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
.control-field span,
|
|
2883
|
+
.json-editor span {
|
|
2884
|
+
color: var(--ink-4);
|
|
2885
|
+
font-size: 11.5px;
|
|
2886
|
+
font-weight: 650;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
.control-field select {
|
|
2890
|
+
width: 100%;
|
|
2891
|
+
min-height: 36px;
|
|
2892
|
+
border: 1px solid var(--line);
|
|
2893
|
+
border-radius: var(--radius-md);
|
|
2894
|
+
background: var(--bg-inset);
|
|
2895
|
+
color: var(--ink-1);
|
|
2896
|
+
padding: 7px 10px;
|
|
2897
|
+
font: inherit;
|
|
2898
|
+
font-size: 13px;
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
.json-editor {
|
|
2902
|
+
margin-bottom: var(--space-3);
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
.json-editor textarea {
|
|
2906
|
+
font-family: var(--font-mono);
|
|
2907
|
+
font-size: 12px;
|
|
2908
|
+
min-height: 124px;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
.field-error {
|
|
2912
|
+
color: #b42318;
|
|
2913
|
+
font-size: 12.5px;
|
|
2914
|
+
margin-bottom: var(--space-3);
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
.response-panel {
|
|
2918
|
+
margin-top: var(--space-4);
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
.admin-empty {
|
|
2922
|
+
padding: var(--space-4);
|
|
2923
|
+
border: 1px solid var(--line);
|
|
2924
|
+
border-radius: var(--radius-md);
|
|
2925
|
+
background: var(--bg-inset);
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
.table-wrap {
|
|
2929
|
+
overflow-x: auto;
|
|
2930
|
+
border: 1px solid var(--line);
|
|
2931
|
+
border-radius: var(--radius-md);
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
table {
|
|
2935
|
+
width: 100%;
|
|
2936
|
+
border-collapse: collapse;
|
|
2937
|
+
font-size: 12.5px;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
th,
|
|
2941
|
+
td {
|
|
2942
|
+
text-align: left;
|
|
2943
|
+
padding: 7px 10px;
|
|
2944
|
+
white-space: nowrap;
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
th {
|
|
2948
|
+
background: var(--bg-inset);
|
|
2949
|
+
color: var(--ink-4);
|
|
2950
|
+
font-weight: 650;
|
|
2951
|
+
font-size: 10.5px;
|
|
2952
|
+
text-transform: uppercase;
|
|
2953
|
+
letter-spacing: 0.04em;
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
td {
|
|
2957
|
+
color: var(--ink-2);
|
|
2958
|
+
font-family: var(--font-mono);
|
|
2959
|
+
border-top: 1px solid var(--line);
|
|
2960
|
+
}
|
|
2961
|
+
td:first-child,
|
|
2962
|
+
th:first-child {
|
|
2963
|
+
color: var(--ink-1);
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
.result-status {
|
|
2967
|
+
display: flex;
|
|
2968
|
+
align-items: center;
|
|
2969
|
+
justify-content: space-between;
|
|
2970
|
+
gap: 8px;
|
|
2971
|
+
margin-bottom: 8px;
|
|
2972
|
+
font-size: 11.5px;
|
|
2973
|
+
color: var(--ink-4);
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
.conn-list {
|
|
2977
|
+
display: flex;
|
|
2978
|
+
flex-direction: column;
|
|
2979
|
+
gap: 2px;
|
|
2980
|
+
border: 1px solid var(--line);
|
|
2981
|
+
border-radius: var(--radius-md);
|
|
2982
|
+
overflow: hidden;
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
.conn-row {
|
|
2986
|
+
display: flex;
|
|
2987
|
+
align-items: center;
|
|
2988
|
+
justify-content: space-between;
|
|
2989
|
+
gap: 10px;
|
|
2990
|
+
padding: 8px 10px;
|
|
2991
|
+
border-top: 1px solid var(--line);
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
.conn-row:first-child {
|
|
2995
|
+
border-top: none;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
.conn-row-main {
|
|
2999
|
+
min-width: 0;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
.conn-row-title {
|
|
3003
|
+
font-size: 12.5px;
|
|
3004
|
+
font-weight: 600;
|
|
3005
|
+
white-space: nowrap;
|
|
3006
|
+
overflow: hidden;
|
|
3007
|
+
text-overflow: ellipsis;
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
.conn-row-sub {
|
|
3011
|
+
font-size: 11.5px;
|
|
3012
|
+
color: var(--ink-4);
|
|
3013
|
+
font-family: var(--font-mono);
|
|
3014
|
+
white-space: nowrap;
|
|
3015
|
+
overflow: hidden;
|
|
3016
|
+
text-overflow: ellipsis;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
.conn-row-tag {
|
|
3020
|
+
flex: none;
|
|
3021
|
+
font-size: 11px;
|
|
3022
|
+
color: var(--ink-3);
|
|
3023
|
+
font-family: var(--font-mono);
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
.preview {
|
|
3027
|
+
margin-top: 4px;
|
|
3028
|
+
background: var(--bg-inset);
|
|
3029
|
+
border-radius: var(--radius-md);
|
|
3030
|
+
padding: 10px 12px;
|
|
3031
|
+
font-family: var(--font-mono);
|
|
3032
|
+
font-size: 12px;
|
|
3033
|
+
line-height: 1.55;
|
|
3034
|
+
white-space: pre-wrap;
|
|
3035
|
+
word-break: break-word;
|
|
3036
|
+
max-height: 200px;
|
|
3037
|
+
overflow: auto;
|
|
3038
|
+
color: var(--ink-2);
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
.loading-row {
|
|
3042
|
+
display: flex;
|
|
3043
|
+
align-items: center;
|
|
3044
|
+
gap: 8px;
|
|
3045
|
+
color: var(--ink-4);
|
|
3046
|
+
font-size: 12.5px;
|
|
3047
|
+
padding: 4px 0;
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
.spinner {
|
|
3051
|
+
width: 12px;
|
|
3052
|
+
height: 12px;
|
|
3053
|
+
border-radius: 50%;
|
|
3054
|
+
border: 1.6px solid var(--line-strong);
|
|
3055
|
+
border-top-color: var(--ink-3);
|
|
3056
|
+
animation: spin 700ms linear infinite;
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
@keyframes spin {
|
|
3060
|
+
to {
|
|
3061
|
+
transform: rotate(360deg);
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
/* ---------- reveal + action tooltips ---------- */
|
|
3066
|
+
|
|
3067
|
+
.reveal {
|
|
3068
|
+
position: relative;
|
|
3069
|
+
display: inline;
|
|
3070
|
+
background: var(--primary-wash);
|
|
3071
|
+
border-radius: var(--radius-sm);
|
|
3072
|
+
padding: 1px 6px;
|
|
3073
|
+
margin: 0 -2px;
|
|
3074
|
+
cursor: help;
|
|
3075
|
+
-webkit-tap-highlight-color: transparent;
|
|
3076
|
+
transition: background-color 120ms ease;
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
.reveal:hover,
|
|
3080
|
+
.reveal:focus-visible {
|
|
3081
|
+
background: color-mix(in srgb, var(--primary-wash) 55%, var(--primary) 22%);
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
.reveal-tip {
|
|
3085
|
+
position: absolute;
|
|
3086
|
+
left: 50%;
|
|
3087
|
+
bottom: calc(100% + 9px);
|
|
3088
|
+
transform: translateX(-50%) translateY(4px);
|
|
3089
|
+
background: var(--code-bg);
|
|
3090
|
+
color: var(--code-ink);
|
|
3091
|
+
font-family: var(--font-mono);
|
|
3092
|
+
font-size: 11.5px;
|
|
3093
|
+
white-space: pre-wrap;
|
|
3094
|
+
text-align: left;
|
|
3095
|
+
max-width: min(420px, calc(100vw - 24px));
|
|
3096
|
+
width: max-content;
|
|
3097
|
+
line-height: 1.5;
|
|
3098
|
+
padding: 7px 10px;
|
|
3099
|
+
border-radius: var(--radius-md);
|
|
3100
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
|
3101
|
+
opacity: 0;
|
|
3102
|
+
visibility: hidden;
|
|
3103
|
+
pointer-events: none;
|
|
3104
|
+
transition:
|
|
3105
|
+
opacity 140ms ease,
|
|
3106
|
+
transform 140ms ease;
|
|
3107
|
+
z-index: 20;
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
.reveal-tip::after {
|
|
3111
|
+
content: "";
|
|
3112
|
+
position: absolute;
|
|
3113
|
+
top: 100%;
|
|
3114
|
+
left: 50%;
|
|
3115
|
+
transform: translateX(-50%);
|
|
3116
|
+
border: 5px solid transparent;
|
|
3117
|
+
border-top-color: var(--code-bg);
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
.reveal-tip.is-below {
|
|
3121
|
+
bottom: auto;
|
|
3122
|
+
top: calc(100% + 9px);
|
|
3123
|
+
transform: translateX(-50%) translateY(-4px);
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
.reveal-tip.is-below::after {
|
|
3127
|
+
top: auto;
|
|
3128
|
+
bottom: 100%;
|
|
3129
|
+
border-top-color: transparent;
|
|
3130
|
+
border-bottom-color: var(--code-bg);
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
.code-string {
|
|
3134
|
+
color: var(--code-string);
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
.code-keyword {
|
|
3138
|
+
color: var(--code-keyword);
|
|
3139
|
+
font-weight: 600;
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
.code-number {
|
|
3143
|
+
color: var(--code-number);
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
.code-call {
|
|
3147
|
+
color: var(--code-call);
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
.code-punct {
|
|
3151
|
+
color: var(--code-punct);
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
.code-comment {
|
|
3155
|
+
color: var(--ink-4);
|
|
3156
|
+
}
|
|
621
3157
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
});
|
|
628
|
-
`;
|
|
3158
|
+
.reveal:hover .reveal-tip,
|
|
3159
|
+
.reveal:focus-visible .reveal-tip {
|
|
3160
|
+
opacity: 1;
|
|
3161
|
+
visibility: visible;
|
|
3162
|
+
transform: translateX(-50%) translateY(0);
|
|
629
3163
|
}
|
|
630
|
-
function reactMainTsx() {
|
|
631
|
-
return `import React from "react";
|
|
632
|
-
import ReactDOM from "react-dom/client";
|
|
633
|
-
import { App } from "./App";
|
|
634
|
-
import "./styles.css";
|
|
635
3164
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
`;
|
|
3165
|
+
.action-tip {
|
|
3166
|
+
position: relative;
|
|
3167
|
+
display: inline-flex;
|
|
3168
|
+
align-items: center;
|
|
3169
|
+
flex: none;
|
|
642
3170
|
}
|
|
643
|
-
function reactAppTsx(app) {
|
|
644
|
-
return `import { useEffect } from "react";
|
|
645
|
-
import { create } from "zustand";
|
|
646
3171
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
get: (key: string) => Promise<unknown>;
|
|
652
|
-
};
|
|
653
|
-
};
|
|
3172
|
+
.upload-tip {
|
|
3173
|
+
margin-top: auto;
|
|
3174
|
+
align-self: flex-start;
|
|
3175
|
+
}
|
|
654
3176
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
3177
|
+
.action-tip-pop {
|
|
3178
|
+
position: absolute;
|
|
3179
|
+
left: 50%;
|
|
3180
|
+
bottom: calc(100% + 9px);
|
|
3181
|
+
transform: translateX(-50%) translateY(4px);
|
|
3182
|
+
background: var(--code-bg);
|
|
3183
|
+
color: var(--code-ink);
|
|
3184
|
+
font-family: var(--font-mono);
|
|
3185
|
+
font-size: 11.5px;
|
|
3186
|
+
white-space: pre-wrap;
|
|
3187
|
+
text-align: left;
|
|
3188
|
+
max-width: min(360px, calc(100vw - 24px));
|
|
3189
|
+
width: max-content;
|
|
3190
|
+
line-height: 1.5;
|
|
3191
|
+
padding: 7px 10px;
|
|
3192
|
+
border-radius: var(--radius-md);
|
|
3193
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
|
3194
|
+
opacity: 0;
|
|
3195
|
+
visibility: hidden;
|
|
3196
|
+
pointer-events: none;
|
|
3197
|
+
transition:
|
|
3198
|
+
opacity 140ms ease,
|
|
3199
|
+
transform 140ms ease;
|
|
3200
|
+
z-index: 25;
|
|
3201
|
+
}
|
|
661
3202
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
3203
|
+
.action-tip-pop::after {
|
|
3204
|
+
content: "";
|
|
3205
|
+
position: absolute;
|
|
3206
|
+
top: 100%;
|
|
3207
|
+
left: 50%;
|
|
3208
|
+
transform: translateX(-50%);
|
|
3209
|
+
border: 5px solid transparent;
|
|
3210
|
+
border-top-color: var(--code-bg);
|
|
3211
|
+
}
|
|
668
3212
|
|
|
669
|
-
|
|
670
|
-
|
|
3213
|
+
.action-tip:hover .action-tip-pop,
|
|
3214
|
+
.action-tip:focus-within .action-tip-pop {
|
|
3215
|
+
opacity: 1;
|
|
3216
|
+
visibility: visible;
|
|
3217
|
+
transform: translateX(-50%) translateY(0);
|
|
3218
|
+
}
|
|
671
3219
|
|
|
672
|
-
|
|
673
|
-
async function load() {
|
|
674
|
-
const identity = await me();
|
|
675
|
-
setStatus("Signed in as " + (identity.user?.email || "unknown"));
|
|
3220
|
+
/* ---------- AI card ---------- */
|
|
676
3221
|
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
3222
|
+
.ai-body {
|
|
3223
|
+
display: flex;
|
|
3224
|
+
flex-direction: column;
|
|
3225
|
+
gap: var(--space-3);
|
|
3226
|
+
}
|
|
682
3227
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
3228
|
+
.ai-response {
|
|
3229
|
+
margin-top: var(--space-2);
|
|
3230
|
+
padding: 12px 14px;
|
|
3231
|
+
background: var(--bg-inset);
|
|
3232
|
+
border-radius: var(--radius-md);
|
|
3233
|
+
font-size: 13px;
|
|
3234
|
+
line-height: 1.6;
|
|
3235
|
+
color: var(--ink-1);
|
|
3236
|
+
}
|
|
687
3237
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
3238
|
+
.cursor {
|
|
3239
|
+
display: inline-block;
|
|
3240
|
+
width: 2px;
|
|
3241
|
+
height: 14px;
|
|
3242
|
+
background: var(--ink-2);
|
|
3243
|
+
vertical-align: -2px;
|
|
3244
|
+
margin-left: 1px;
|
|
3245
|
+
animation: blink 900ms step-end infinite;
|
|
695
3246
|
}
|
|
696
|
-
|
|
3247
|
+
|
|
3248
|
+
@keyframes blink {
|
|
3249
|
+
50% {
|
|
3250
|
+
opacity: 0;
|
|
3251
|
+
}
|
|
697
3252
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
3253
|
+
|
|
3254
|
+
.ai-foot-row {
|
|
3255
|
+
display: flex;
|
|
3256
|
+
align-items: center;
|
|
3257
|
+
justify-content: space-between;
|
|
3258
|
+
gap: 10px;
|
|
704
3259
|
}
|
|
705
3260
|
|
|
706
|
-
|
|
707
|
-
|
|
3261
|
+
.ai-note {
|
|
3262
|
+
color: var(--ink-4);
|
|
3263
|
+
font-size: 12px;
|
|
708
3264
|
}
|
|
709
3265
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
3266
|
+
/* ---------- error banner ---------- */
|
|
3267
|
+
|
|
3268
|
+
.error-banner {
|
|
3269
|
+
display: flex;
|
|
3270
|
+
justify-content: space-between;
|
|
3271
|
+
align-items: center;
|
|
714
3272
|
gap: 12px;
|
|
715
|
-
|
|
3273
|
+
margin-bottom: var(--space-6);
|
|
3274
|
+
padding: 12px 14px;
|
|
3275
|
+
border: 1px solid var(--line-strong);
|
|
3276
|
+
border-radius: var(--radius-lg);
|
|
3277
|
+
background: var(--bg-inset);
|
|
3278
|
+
color: var(--ink-1);
|
|
3279
|
+
font-size: 13px;
|
|
716
3280
|
}
|
|
717
3281
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
3282
|
+
.error-banner button {
|
|
3283
|
+
border: 0;
|
|
3284
|
+
background: transparent;
|
|
3285
|
+
color: var(--ink-3);
|
|
3286
|
+
font-weight: 600;
|
|
3287
|
+
font-size: 12.5px;
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
.error-banner button:hover {
|
|
3291
|
+
color: var(--ink-1);
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
footer {
|
|
3295
|
+
max-width: 1180px;
|
|
3296
|
+
margin: var(--space-12) auto 0;
|
|
3297
|
+
padding: var(--space-6) 24px 0;
|
|
3298
|
+
border-top: 1px solid var(--line);
|
|
3299
|
+
color: var(--ink-4);
|
|
3300
|
+
font-size: 12px;
|
|
3301
|
+
display: flex;
|
|
3302
|
+
justify-content: flex-start;
|
|
3303
|
+
gap: var(--space-4);
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
footer code {
|
|
3307
|
+
font-family: var(--font-mono);
|
|
3308
|
+
color: var(--ink-3);
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
@media (max-width: 760px) {
|
|
3312
|
+
.guide-section {
|
|
3313
|
+
grid-template-columns: 1fr;
|
|
3314
|
+
gap: var(--space-5);
|
|
3315
|
+
}
|
|
3316
|
+
.guide-copy {
|
|
3317
|
+
position: static;
|
|
3318
|
+
}
|
|
3319
|
+
.hero h1 {
|
|
3320
|
+
font-size: 26px;
|
|
3321
|
+
}
|
|
721
3322
|
}
|
|
722
3323
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
3324
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3325
|
+
.card {
|
|
3326
|
+
animation: none !important;
|
|
3327
|
+
opacity: 1 !important;
|
|
3328
|
+
transform: none !important;
|
|
3329
|
+
}
|
|
3330
|
+
.cursor {
|
|
3331
|
+
animation: none;
|
|
3332
|
+
opacity: 1;
|
|
3333
|
+
}
|
|
3334
|
+
.spinner {
|
|
3335
|
+
animation-duration: 1400ms;
|
|
3336
|
+
}
|
|
726
3337
|
}
|
|
727
3338
|
`;
|
|
728
3339
|
}
|
|
@@ -779,11 +3390,121 @@ async function commandDesignSystem(args) {
|
|
|
779
3390
|
process.stdout.write(markdown.endsWith("\n") || markdown === "" ? markdown : `${markdown}\n`);
|
|
780
3391
|
}
|
|
781
3392
|
// ---------------------------------------------------------------------------
|
|
3393
|
+
// manifest — validate manifest.yaml locally / show an app's manifest state
|
|
3394
|
+
// ---------------------------------------------------------------------------
|
|
3395
|
+
const MANIFEST_HELP = `railcode manifest — the app authority manifest (${APP_MANIFEST_NAME})
|
|
3396
|
+
|
|
3397
|
+
Usage:
|
|
3398
|
+
railcode manifest validate [path] Strict local parse (default ./${APP_MANIFEST_NAME})
|
|
3399
|
+
railcode manifest show <app> Ratified doc + pending diff for an app (by slug)
|
|
3400
|
+
|
|
3401
|
+
Show options:
|
|
3402
|
+
--json Print the raw manifest envelope
|
|
3403
|
+
|
|
3404
|
+
The manifest declares the operations an app performs (saved queries, connector
|
|
3405
|
+
endpoints, llm, ad-hoc SQL) and whose authority they run under (run_as). The
|
|
3406
|
+
server-side RATIFIED copy is authoritative; a deploy whose manifest adds
|
|
3407
|
+
operations the deployer doesn't hold lands as a pending diff awaiting approval.
|
|
3408
|
+
No ${APP_MANIFEST_NAME} = run_as: user (pass-through, exactly the pre-manifest
|
|
3409
|
+
behavior).
|
|
3410
|
+
`;
|
|
3411
|
+
async function commandManifest(args) {
|
|
3412
|
+
const sub = args.rest[0] ?? "";
|
|
3413
|
+
switch (sub) {
|
|
3414
|
+
case "validate":
|
|
3415
|
+
await commandManifestValidate(args);
|
|
3416
|
+
return;
|
|
3417
|
+
case "show":
|
|
3418
|
+
await commandManifestShow(args);
|
|
3419
|
+
return;
|
|
3420
|
+
case "":
|
|
3421
|
+
case "help":
|
|
3422
|
+
console.log(MANIFEST_HELP);
|
|
3423
|
+
return;
|
|
3424
|
+
default:
|
|
3425
|
+
throw new CliError(`Unknown manifest subcommand: ${sub}\n\n${MANIFEST_HELP}`, 2);
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
async function commandManifestValidate(args) {
|
|
3429
|
+
const path = resolve(process.cwd(), args.rest[1] ?? APP_MANIFEST_NAME);
|
|
3430
|
+
if (!existsSync(path)) {
|
|
3431
|
+
throw new CliError(`No manifest at ${path}. Without a ${APP_MANIFEST_NAME} the app deploys as run_as: user (pass-through).`, 2);
|
|
3432
|
+
}
|
|
3433
|
+
let doc;
|
|
3434
|
+
try {
|
|
3435
|
+
doc = parseManifestYaml(readFileSync(path, "utf8"));
|
|
3436
|
+
}
|
|
3437
|
+
catch (error) {
|
|
3438
|
+
if (error instanceof ManifestParseError) {
|
|
3439
|
+
throw new CliError(`Invalid ${APP_MANIFEST_NAME}: ${error.message}`, 1);
|
|
3440
|
+
}
|
|
3441
|
+
throw error;
|
|
3442
|
+
}
|
|
3443
|
+
console.log(`${relativeLabel(process.cwd(), path)} is valid.`);
|
|
3444
|
+
console.log(renderManifestSummary(doc));
|
|
3445
|
+
console.log("Note: resource names (saved queries, connectors, connections) are checked against your org at deploy.");
|
|
3446
|
+
}
|
|
3447
|
+
async function commandManifestShow(args) {
|
|
3448
|
+
const slug = args.rest[1];
|
|
3449
|
+
if (!slug)
|
|
3450
|
+
throw new CliError("Usage: railcode manifest show <app> [--json]", 2);
|
|
3451
|
+
const config = requireLogin(args);
|
|
3452
|
+
const list = (await readJsonOrThrow(await authedFetch(config, `/api/organizations/${config.orgUuid}/apps`), "List apps"));
|
|
3453
|
+
const app = list.find((a) => a.app_slug === slug);
|
|
3454
|
+
if (!app)
|
|
3455
|
+
throw new CliError(`No app "${slug}" in your org.`, 1);
|
|
3456
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${app.uuid}/manifest`);
|
|
3457
|
+
if (resp.status === 401)
|
|
3458
|
+
await reLoginOrThrow();
|
|
3459
|
+
if (resp.status === 403) {
|
|
3460
|
+
throw new CliError("You don't have access to view this app's manifest.", 1);
|
|
3461
|
+
}
|
|
3462
|
+
if (!resp.ok) {
|
|
3463
|
+
throw new CliError(`Could not fetch the manifest (${resp.status}): ${await errorDetail(resp)}`, 1);
|
|
3464
|
+
}
|
|
3465
|
+
const manifest = (await resp.json());
|
|
3466
|
+
if (args.options.json === true) {
|
|
3467
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
3468
|
+
return;
|
|
3469
|
+
}
|
|
3470
|
+
console.log(`Manifest for ${slug}`);
|
|
3471
|
+
if (!manifest.ratified && !manifest.pending) {
|
|
3472
|
+
console.log("No manifest — pass-through (runs as each caller, run_as: user).");
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3475
|
+
if (manifest.ratified) {
|
|
3476
|
+
const r = manifest.ratified;
|
|
3477
|
+
const by = r.ratified_by_email ?? "unknown";
|
|
3478
|
+
const when = r.ratified_at ? ` on ${r.ratified_at.slice(0, 10)}` : "";
|
|
3479
|
+
console.log(`Ratified by ${by}${when} (sha256 ${r.content_hash.slice(0, 12)}…)`);
|
|
3480
|
+
console.log(renderManifestSummary(r.document));
|
|
3481
|
+
}
|
|
3482
|
+
else {
|
|
3483
|
+
console.log("Nothing ratified yet — the app runs pass-through until approval.");
|
|
3484
|
+
}
|
|
3485
|
+
if (manifest.pending) {
|
|
3486
|
+
const p = manifest.pending;
|
|
3487
|
+
console.log(`Pending approval (deployed by ${p.created_by_email ?? "unknown"} on ${p.created_at.slice(0, 10)}):`);
|
|
3488
|
+
if (manifest.pending_added.length > 0) {
|
|
3489
|
+
for (const op of manifest.pending_added) {
|
|
3490
|
+
const label = documentOperations(p.document).find((o) => o.type === op.resource_type && o.id === op.resource_id)
|
|
3491
|
+
?.label ?? `${op.resource_type} ${op.resource_id}`;
|
|
3492
|
+
console.log(` + ${label}`);
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
else {
|
|
3496
|
+
console.log(" (no new operations)");
|
|
3497
|
+
}
|
|
3498
|
+
console.log(manifest.can_approve
|
|
3499
|
+
? "You hold these operations — approve/reject from the app's Manifest panel."
|
|
3500
|
+
: "Approval needs an org admin (or any member holding these operations).");
|
|
3501
|
+
}
|
|
3502
|
+
}
|
|
3503
|
+
// ---------------------------------------------------------------------------
|
|
782
3504
|
// db — list data connectors + run read-only SQL (`railcode db list|query`)
|
|
783
3505
|
//
|
|
784
|
-
// Data connectors are org-wide
|
|
785
|
-
//
|
|
786
|
-
// bearer, app-scoped endpoints `railcode dev` already forwards to.
|
|
3506
|
+
// Data connectors are org-wide, so these commands use the app-less org data
|
|
3507
|
+
// plane. They never resolve or create an app.
|
|
787
3508
|
// ---------------------------------------------------------------------------
|
|
788
3509
|
const DB_HELP = `railcode db — data connectors
|
|
789
3510
|
|
|
@@ -856,9 +3577,9 @@ async function commandDbQuery(args) {
|
|
|
856
3577
|
if (args.rest.length > 2) {
|
|
857
3578
|
throw new CliError('railcode db query takes a single SQL string — quote it: railcode db query "select 1".', 2);
|
|
858
3579
|
}
|
|
859
|
-
const source = pickSqlSource(args.rest[1], args
|
|
3580
|
+
const source = pickSqlSource(args.rest[1], singleOption(args, "file"));
|
|
860
3581
|
const params = parseSqlParams(optionString(args, "params"));
|
|
861
|
-
const engineOption = parseEngine(args
|
|
3582
|
+
const engineOption = parseEngine(singleOption(args, "engine"));
|
|
862
3583
|
const connection = optionString(args, "connection") ?? "default";
|
|
863
3584
|
const query = source.kind === "file" ? readSqlFile(source.path) : source.sql;
|
|
864
3585
|
const config = requireLogin(args);
|
|
@@ -905,8 +3626,20 @@ function optionString(args, key) {
|
|
|
905
3626
|
const value = args.options[key];
|
|
906
3627
|
if (value === true)
|
|
907
3628
|
throw new CliError(`--${camelToKebab(key)} requires a value.`, 2);
|
|
3629
|
+
if (Array.isArray(value)) {
|
|
3630
|
+
throw new CliError(`--${camelToKebab(key)} can only be given once.`, 2);
|
|
3631
|
+
}
|
|
908
3632
|
return typeof value === "string" ? value : undefined;
|
|
909
3633
|
}
|
|
3634
|
+
// A single-valued option that may legitimately be a bare flag (the db/connector
|
|
3635
|
+
// parsers handle `true` themselves) — only a REPEATED option is rejected here.
|
|
3636
|
+
function singleOption(args, key) {
|
|
3637
|
+
const value = args.options[key];
|
|
3638
|
+
if (Array.isArray(value)) {
|
|
3639
|
+
throw new CliError(`--${camelToKebab(key)} can only be given once.`, 2);
|
|
3640
|
+
}
|
|
3641
|
+
return value;
|
|
3642
|
+
}
|
|
910
3643
|
function assertKnownDbOptions(args, allowed) {
|
|
911
3644
|
for (const key of Object.keys(args.options)) {
|
|
912
3645
|
if (!allowed.includes(key)) {
|
|
@@ -918,6 +3651,254 @@ function camelToKebab(value) {
|
|
|
918
3651
|
return value.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
919
3652
|
}
|
|
920
3653
|
// ---------------------------------------------------------------------------
|
|
3654
|
+
// query — saved queries (`railcode query list|run|create|delete`)
|
|
3655
|
+
//
|
|
3656
|
+
// list/run hit the org-scoped `/data/queries` plane with the personal token
|
|
3657
|
+
// (subject to `saved_query` grants; `_ctx_*` binds resolve to YOU). create/delete
|
|
3658
|
+
// hit the admin management API (`/saved-queries`, connection:manage required).
|
|
3659
|
+
// ---------------------------------------------------------------------------
|
|
3660
|
+
const QUERY_HELP = `railcode query — saved queries
|
|
3661
|
+
|
|
3662
|
+
Usage:
|
|
3663
|
+
railcode query list List this org's saved queries
|
|
3664
|
+
railcode query run <name> [--params <json>] Invoke a saved query by name
|
|
3665
|
+
railcode query create --name <n> --connection <c> (--sql <sql> | --sql-file <f>)
|
|
3666
|
+
[--params <json>] [--description <d>]
|
|
3667
|
+
Author a saved query (admin)
|
|
3668
|
+
railcode query update <name> [--sql <sql> | --sql-file <f>] [--params <json>]
|
|
3669
|
+
[--clear-params] [--connection <c>] [--description <d>]
|
|
3670
|
+
Edit a saved query in place (admin;
|
|
3671
|
+
omitted fields keep their value)
|
|
3672
|
+
railcode query delete <name> Delete a saved query (admin)
|
|
3673
|
+
|
|
3674
|
+
Aliases: query = queries; list = ls; run = invoke.
|
|
3675
|
+
|
|
3676
|
+
Run options:
|
|
3677
|
+
--params '<json-object>' Caller params, exactly as the API takes
|
|
3678
|
+
them: '{"region":"emea","limit":5}'.
|
|
3679
|
+
Params declared with a default may be
|
|
3680
|
+
omitted (the server binds the default).
|
|
3681
|
+
--json Print the raw { columns, rows, ... } envelope
|
|
3682
|
+
|
|
3683
|
+
Create / update options:
|
|
3684
|
+
--name <name> Invoke handle ([a-z0-9_], unique per org)
|
|
3685
|
+
--connection <name> Data connection the query runs against
|
|
3686
|
+
--sql <sql> The SQL template inline (:param style);
|
|
3687
|
+
quote it, or use --sql="..." if it starts
|
|
3688
|
+
with a -- comment
|
|
3689
|
+
--sql-file <path> File holding the SQL template
|
|
3690
|
+
--params '<json-array>' Declared params in the API shape:
|
|
3691
|
+
'[{"name":"region","type":"string","default":"emea"},
|
|
3692
|
+
{"name":"limit","type":"int"}]'
|
|
3693
|
+
Types: string, int, float, bool. A "default"
|
|
3694
|
+
makes the param optional at invoke time.
|
|
3695
|
+
On update the array REPLACES the params
|
|
3696
|
+
--clear-params (update only) declare no params
|
|
3697
|
+
--description <text> Shown to members next to the name
|
|
3698
|
+
|
|
3699
|
+
Editing the SQL or params bumps the query's version; grants naming the query are
|
|
3700
|
+
untouched (unlike delete + recreate, which removes them).
|
|
3701
|
+
|
|
3702
|
+
Templates may reference :_ctx_user_id, :_ctx_user_email and :_ctx_org — the server
|
|
3703
|
+
injects those from whoever invokes (they can never be passed as params), so a
|
|
3704
|
+
query like "where rep_email = :_ctx_user_email" is row-scoped per caller.
|
|
3705
|
+
`;
|
|
3706
|
+
async function commandQuery(args) {
|
|
3707
|
+
try {
|
|
3708
|
+
const sub = args.rest[0] ?? "";
|
|
3709
|
+
switch (sub) {
|
|
3710
|
+
case "list":
|
|
3711
|
+
case "ls":
|
|
3712
|
+
await commandQueryList(args);
|
|
3713
|
+
return;
|
|
3714
|
+
case "run":
|
|
3715
|
+
case "invoke":
|
|
3716
|
+
await commandQueryRun(args);
|
|
3717
|
+
return;
|
|
3718
|
+
case "create":
|
|
3719
|
+
await commandQueryCreate(args);
|
|
3720
|
+
return;
|
|
3721
|
+
case "update":
|
|
3722
|
+
await commandQueryUpdate(args);
|
|
3723
|
+
return;
|
|
3724
|
+
case "delete":
|
|
3725
|
+
await commandQueryDelete(args);
|
|
3726
|
+
return;
|
|
3727
|
+
case "":
|
|
3728
|
+
case "help":
|
|
3729
|
+
console.log(QUERY_HELP);
|
|
3730
|
+
return;
|
|
3731
|
+
default:
|
|
3732
|
+
throw new CliError(`Unknown query command: ${sub}\n\n${QUERY_HELP}`, 2);
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
catch (error) {
|
|
3736
|
+
if (error instanceof QueryCmdError)
|
|
3737
|
+
throw new CliError(error.message, 2);
|
|
3738
|
+
throw error;
|
|
3739
|
+
}
|
|
3740
|
+
}
|
|
3741
|
+
async function commandQueryList(args) {
|
|
3742
|
+
assertKnownQueryOptions(args, ["json", "apiUrl"]);
|
|
3743
|
+
if (args.rest.length > 1) {
|
|
3744
|
+
throw new CliError(`railcode query list takes no arguments (got "${args.rest[1]}").`, 2);
|
|
3745
|
+
}
|
|
3746
|
+
const config = requireLogin(args);
|
|
3747
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/queries`);
|
|
3748
|
+
if (resp.status === 401)
|
|
3749
|
+
await reLoginOrThrow();
|
|
3750
|
+
const queries = (await readJsonOrThrow(resp, "List saved queries"));
|
|
3751
|
+
if (args.options.json) {
|
|
3752
|
+
console.log(JSON.stringify(queries, null, 2));
|
|
3753
|
+
return;
|
|
3754
|
+
}
|
|
3755
|
+
if (queries.length === 0) {
|
|
3756
|
+
console.log("No saved queries in this organization.");
|
|
3757
|
+
return;
|
|
3758
|
+
}
|
|
3759
|
+
console.log(formatTable(["name", "params", "version", "description"], queries.map((query) => [
|
|
3760
|
+
query.name,
|
|
3761
|
+
paramSignature(query.params),
|
|
3762
|
+
`v${query.version}`,
|
|
3763
|
+
query.description || "-",
|
|
3764
|
+
])));
|
|
3765
|
+
}
|
|
3766
|
+
async function commandQueryRun(args) {
|
|
3767
|
+
assertKnownQueryOptions(args, ["params", "json", "apiUrl"]);
|
|
3768
|
+
const name = args.rest[1];
|
|
3769
|
+
if (!name || args.rest.length > 2) {
|
|
3770
|
+
throw new CliError(`Usage: railcode query run <name> [--params '{"k":"v"}']`, 2);
|
|
3771
|
+
}
|
|
3772
|
+
const params = parseRunParams(optionString(args, "params"));
|
|
3773
|
+
const config = requireLogin(args);
|
|
3774
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/data/queries/${encodeURIComponent(name)}`, {
|
|
3775
|
+
method: "POST",
|
|
3776
|
+
headers: { "Content-Type": "application/json" },
|
|
3777
|
+
body: JSON.stringify({ params }),
|
|
3778
|
+
});
|
|
3779
|
+
if (resp.status === 401)
|
|
3780
|
+
await reLoginOrThrow();
|
|
3781
|
+
const result = (await readJsonOrThrow(resp, `Run saved query "${name}"`));
|
|
3782
|
+
if (args.options.json) {
|
|
3783
|
+
console.log(JSON.stringify(result, null, 2));
|
|
3784
|
+
return;
|
|
3785
|
+
}
|
|
3786
|
+
console.log(formatTable(result.columns, result.rows));
|
|
3787
|
+
console.log("");
|
|
3788
|
+
console.log(`(${result.rowcount} row${result.rowcount === 1 ? "" : "s"})`);
|
|
3789
|
+
if (result.truncated) {
|
|
3790
|
+
console.log("Note: results were truncated by the server.");
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
async function commandQueryCreate(args) {
|
|
3794
|
+
assertKnownQueryOptions(args, [
|
|
3795
|
+
"name",
|
|
3796
|
+
"connection",
|
|
3797
|
+
"sql",
|
|
3798
|
+
"sqlFile",
|
|
3799
|
+
"params",
|
|
3800
|
+
"description",
|
|
3801
|
+
"apiUrl",
|
|
3802
|
+
]);
|
|
3803
|
+
const name = optionString(args, "name");
|
|
3804
|
+
const connection = optionString(args, "connection");
|
|
3805
|
+
if (!name || !connection) {
|
|
3806
|
+
throw new CliError("railcode query create requires --name and --connection.\n\n" + QUERY_HELP, 2);
|
|
3807
|
+
}
|
|
3808
|
+
// required=true: pickAuthoringSql throws rather than return null here.
|
|
3809
|
+
const source = pickAuthoringSql(optionString(args, "sql"), optionString(args, "sqlFile"), true);
|
|
3810
|
+
const params = parseParamDecls(optionString(args, "params"));
|
|
3811
|
+
const sql = source.kind === "file" ? readSqlFile(source.path) : source.sql;
|
|
3812
|
+
const config = requireLogin(args);
|
|
3813
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/saved-queries`, {
|
|
3814
|
+
method: "POST",
|
|
3815
|
+
headers: { "Content-Type": "application/json" },
|
|
3816
|
+
body: JSON.stringify({
|
|
3817
|
+
name,
|
|
3818
|
+
connection,
|
|
3819
|
+
sql,
|
|
3820
|
+
params,
|
|
3821
|
+
description: optionString(args, "description") ?? "",
|
|
3822
|
+
}),
|
|
3823
|
+
});
|
|
3824
|
+
if (resp.status === 401)
|
|
3825
|
+
await reLoginOrThrow();
|
|
3826
|
+
const created = (await readJsonOrThrow(resp, `Create saved query "${name}"`));
|
|
3827
|
+
console.log(`Saved query "${created.name}" created (v${created.version}).`);
|
|
3828
|
+
console.log(`Invoke it: railcode query run ${created.name}`);
|
|
3829
|
+
}
|
|
3830
|
+
async function commandQueryUpdate(args) {
|
|
3831
|
+
assertKnownQueryOptions(args, [
|
|
3832
|
+
"sql",
|
|
3833
|
+
"sqlFile",
|
|
3834
|
+
"params",
|
|
3835
|
+
"clearParams",
|
|
3836
|
+
"connection",
|
|
3837
|
+
"description",
|
|
3838
|
+
"apiUrl",
|
|
3839
|
+
]);
|
|
3840
|
+
const name = args.rest[1];
|
|
3841
|
+
if (!name || args.rest.length > 2) {
|
|
3842
|
+
throw new CliError("Usage: railcode query update <name> [--sql ... | --sql-file ...]", 2);
|
|
3843
|
+
}
|
|
3844
|
+
const source = pickAuthoringSql(optionString(args, "sql"), optionString(args, "sqlFile"), false);
|
|
3845
|
+
const sql = source ? (source.kind === "file" ? readSqlFile(source.path) : source.sql) : undefined;
|
|
3846
|
+
const paramsJson = optionString(args, "params");
|
|
3847
|
+
const clearParams = args.options.clearParams === true;
|
|
3848
|
+
if (clearParams && paramsJson !== undefined) {
|
|
3849
|
+
throw new CliError("--clear-params cannot be combined with --params.", 2);
|
|
3850
|
+
}
|
|
3851
|
+
const params = clearParams ? [] : paramsJson !== undefined ? parseParamDecls(paramsJson) : undefined;
|
|
3852
|
+
const connection = optionString(args, "connection");
|
|
3853
|
+
const description = optionString(args, "description");
|
|
3854
|
+
// PATCH semantics: only the fields present in the body change; params replace
|
|
3855
|
+
// the whole declared list (the backend re-validates them against the SQL).
|
|
3856
|
+
const body = {};
|
|
3857
|
+
if (connection !== undefined)
|
|
3858
|
+
body.connection = connection;
|
|
3859
|
+
if (sql !== undefined)
|
|
3860
|
+
body.sql = sql;
|
|
3861
|
+
if (params !== undefined)
|
|
3862
|
+
body.params = params;
|
|
3863
|
+
if (description !== undefined)
|
|
3864
|
+
body.description = description;
|
|
3865
|
+
if (Object.keys(body).length === 0) {
|
|
3866
|
+
throw new CliError("Nothing to update. Pass --sql/--sql-file, --params/--clear-params, --connection or --description.", 2);
|
|
3867
|
+
}
|
|
3868
|
+
const config = requireLogin(args);
|
|
3869
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/saved-queries/${encodeURIComponent(name)}`, {
|
|
3870
|
+
method: "PATCH",
|
|
3871
|
+
headers: { "Content-Type": "application/json" },
|
|
3872
|
+
body: JSON.stringify(body),
|
|
3873
|
+
});
|
|
3874
|
+
if (resp.status === 401)
|
|
3875
|
+
await reLoginOrThrow();
|
|
3876
|
+
const updated = (await readJsonOrThrow(resp, `Update saved query "${name}"`));
|
|
3877
|
+
console.log(`Saved query "${updated.name}" updated (v${updated.version}).`);
|
|
3878
|
+
}
|
|
3879
|
+
async function commandQueryDelete(args) {
|
|
3880
|
+
assertKnownQueryOptions(args, ["apiUrl"]);
|
|
3881
|
+
const name = args.rest[1];
|
|
3882
|
+
if (!name || args.rest.length > 2) {
|
|
3883
|
+
throw new CliError("Usage: railcode query delete <name>", 2);
|
|
3884
|
+
}
|
|
3885
|
+
const config = requireLogin(args);
|
|
3886
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/saved-queries/${encodeURIComponent(name)}`, { method: "DELETE" });
|
|
3887
|
+
if (resp.status === 401)
|
|
3888
|
+
await reLoginOrThrow();
|
|
3889
|
+
if (!resp.ok) {
|
|
3890
|
+
throw new CliError(`Delete saved query "${name}" failed (${resp.status}): ${await errorDetail(resp)}`, 1);
|
|
3891
|
+
}
|
|
3892
|
+
console.log(`Saved query "${name}" deleted (its grants were removed too).`);
|
|
3893
|
+
}
|
|
3894
|
+
function assertKnownQueryOptions(args, allowed) {
|
|
3895
|
+
for (const key of Object.keys(args.options)) {
|
|
3896
|
+
if (!allowed.includes(key)) {
|
|
3897
|
+
throw new CliError(`Unknown option --${camelToKebab(key)} for \`railcode query\`.\n\n${QUERY_HELP}`, 2);
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
// ---------------------------------------------------------------------------
|
|
921
3902
|
// connector — list service connectors + proxy one call
|
|
922
3903
|
// (`railcode connector list|fetch`)
|
|
923
3904
|
//
|
|
@@ -1007,9 +3988,9 @@ async function commandConnectorFetch(args) {
|
|
|
1007
3988
|
if (args.rest.length > 2) {
|
|
1008
3989
|
throw new CliError('railcode connector fetch takes a single path — quote it: railcode connector fetch "/v1/charges".', 2);
|
|
1009
3990
|
}
|
|
1010
|
-
const connector = parseConnectorOption(args
|
|
1011
|
-
const method = parseMethodOption(args
|
|
1012
|
-
const bodySource = pickConnectorBody(args
|
|
3991
|
+
const connector = parseConnectorOption(singleOption(args, "connector"));
|
|
3992
|
+
const method = parseMethodOption(singleOption(args, "method"));
|
|
3993
|
+
const bodySource = pickConnectorBody(singleOption(args, "body"), singleOption(args, "file"));
|
|
1013
3994
|
const body = bodySource.kind === "file"
|
|
1014
3995
|
? readConnectorBodyFile(bodySource.path)
|
|
1015
3996
|
: bodySource.kind === "inline"
|
|
@@ -1122,6 +4103,71 @@ async function errorDetail(response) {
|
|
|
1122
4103
|
return text || response.statusText;
|
|
1123
4104
|
}
|
|
1124
4105
|
// ---------------------------------------------------------------------------
|
|
4106
|
+
// llm — list the LLM providers/models apps can call (`railcode llm`)
|
|
4107
|
+
// ---------------------------------------------------------------------------
|
|
4108
|
+
const LLM_HELP = `railcode llm — the org's LLM gateway, as apps see it
|
|
4109
|
+
|
|
4110
|
+
Usage:
|
|
4111
|
+
railcode llm providers List configured providers with their models
|
|
4112
|
+
railcode llm models Flat list of every callable model
|
|
4113
|
+
|
|
4114
|
+
Every configured provider is live; calls route by (provider, model). Pass either
|
|
4115
|
+
to \`llm.generate()\` / \`llm.stream()\` in app code — or neither to use the org
|
|
4116
|
+
default marked in the listing. Keys and provider internals are never shown.
|
|
4117
|
+
|
|
4118
|
+
Options:
|
|
4119
|
+
--json Print the raw provider array
|
|
4120
|
+
`;
|
|
4121
|
+
async function commandLlm(args) {
|
|
4122
|
+
const sub = args.rest[0] ?? "";
|
|
4123
|
+
switch (sub) {
|
|
4124
|
+
case "providers":
|
|
4125
|
+
case "provider":
|
|
4126
|
+
await commandLlmProviders(args, "providers");
|
|
4127
|
+
return;
|
|
4128
|
+
case "models":
|
|
4129
|
+
case "model":
|
|
4130
|
+
await commandLlmProviders(args, "models");
|
|
4131
|
+
return;
|
|
4132
|
+
case "":
|
|
4133
|
+
case "help":
|
|
4134
|
+
console.log(LLM_HELP);
|
|
4135
|
+
return;
|
|
4136
|
+
default:
|
|
4137
|
+
throw new CliError(`Unknown llm command: ${sub}\n\n${LLM_HELP}`, 2);
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
async function commandLlmProviders(args, view) {
|
|
4141
|
+
assertKnownLlmOptions(args, ["json", "apiUrl"]);
|
|
4142
|
+
if (args.rest.length > 1) {
|
|
4143
|
+
throw new CliError(`railcode llm ${view} takes no arguments (got "${args.rest[1]}").`, 2);
|
|
4144
|
+
}
|
|
4145
|
+
const config = requireLogin(args);
|
|
4146
|
+
const providers = await fetchLlmProviders(config);
|
|
4147
|
+
if (args.options.json) {
|
|
4148
|
+
console.log(JSON.stringify(providers, null, 2));
|
|
4149
|
+
return;
|
|
4150
|
+
}
|
|
4151
|
+
if (providers.length === 0) {
|
|
4152
|
+
console.log("No LLM providers configured for this organization.");
|
|
4153
|
+
return;
|
|
4154
|
+
}
|
|
4155
|
+
console.log(view === "providers" ? llmProviderTable(providers) : llmModelTable(providers));
|
|
4156
|
+
}
|
|
4157
|
+
async function fetchLlmProviders(config) {
|
|
4158
|
+
const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/llm/providers`);
|
|
4159
|
+
if (resp.status === 401)
|
|
4160
|
+
await reLoginOrThrow();
|
|
4161
|
+
return (await readJsonOrThrow(resp, "List LLM providers"));
|
|
4162
|
+
}
|
|
4163
|
+
function assertKnownLlmOptions(args, allowed) {
|
|
4164
|
+
for (const key of Object.keys(args.options)) {
|
|
4165
|
+
if (!allowed.includes(key)) {
|
|
4166
|
+
throw new CliError(`Unknown option --${camelToKebab(key)} for \`railcode llm\`.\n\n${LLM_HELP}`, 2);
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4170
|
+
// ---------------------------------------------------------------------------
|
|
1125
4171
|
// Config persistence
|
|
1126
4172
|
// ---------------------------------------------------------------------------
|
|
1127
4173
|
function loadConfig() {
|
|
@@ -1670,6 +4716,16 @@ function resolveDevSdkPath() {
|
|
|
1670
4716
|
];
|
|
1671
4717
|
return candidates.find((p) => existsSync(p)) ?? null;
|
|
1672
4718
|
}
|
|
4719
|
+
// Logo assets bundled into the React template's src/assets/. Packaged builds
|
|
4720
|
+
// copy cli/assets/react-template -> cli/static/react-template-assets (see
|
|
4721
|
+
// package.json build:sdk); running in-repo falls back to the source dir.
|
|
4722
|
+
function resolveReactTemplateAssetsDir() {
|
|
4723
|
+
const candidates = [
|
|
4724
|
+
join(CLI_PACKAGE_ROOT, "static", "react-template-assets"), // packaged CLI
|
|
4725
|
+
join(CLI_PACKAGE_ROOT, "assets", "react-template"), // in-repo source
|
|
4726
|
+
];
|
|
4727
|
+
return candidates.find((p) => existsSync(p)) ?? null;
|
|
4728
|
+
}
|
|
1673
4729
|
// The dev analog of resolveDeployPlan: custom command | dev script (Vite) | serve
|
|
1674
4730
|
// static files straight from the root when there's no package.json.
|
|
1675
4731
|
function resolveDevPlan(cwd, manifest) {
|
|
@@ -1824,9 +4880,12 @@ async function handleLocalApi(ctx, req, res, url) {
|
|
|
1824
4880
|
}
|
|
1825
4881
|
// Proxied surfaces → forward to the real instance with the saved token.
|
|
1826
4882
|
if (path === "/connections" ||
|
|
4883
|
+
path === "/queries" ||
|
|
4884
|
+
path.startsWith("/queries/") ||
|
|
1827
4885
|
path === "/sql" ||
|
|
1828
4886
|
path === "/llm/generate" ||
|
|
1829
4887
|
path === "/llm/stream" ||
|
|
4888
|
+
path === "/llm/providers" ||
|
|
1830
4889
|
path === "/service-connectors" ||
|
|
1831
4890
|
path === "/service-connectors/request") {
|
|
1832
4891
|
await forwardCompute(ctx, req, res, path, url);
|
|
@@ -2018,12 +5077,10 @@ function devCreds(config) {
|
|
|
2018
5077
|
return null;
|
|
2019
5078
|
return { apiUrl: normalizeApiOrigin(apiUrl), token, orgUuid };
|
|
2020
5079
|
}
|
|
2021
|
-
// Resolve the server-side app for proxying — lazily and memoized
|
|
2022
|
-
//
|
|
2023
|
-
//
|
|
2024
|
-
|
|
2025
|
-
// app but never creates one.
|
|
2026
|
-
function resolveDevAppUuid(ctx, creds, allowCreate) {
|
|
5080
|
+
// Resolve the server-side app for proxying — lazily and memoized. Local dev must
|
|
5081
|
+
// not create product apps: an app becomes real on first successful deploy, not on
|
|
5082
|
+
// a dev-time SQL/LLM/connector call.
|
|
5083
|
+
function resolveDevAppUuid(ctx, creds) {
|
|
2027
5084
|
const base = `${creds.apiUrl}/api/organizations/${creds.orgUuid}/apps`;
|
|
2028
5085
|
const authed = { Authorization: `Bearer ${creds.token}` };
|
|
2029
5086
|
if (!ctx.appUuidPromise) {
|
|
@@ -2033,45 +5090,38 @@ function resolveDevAppUuid(ctx, creds, allowCreate) {
|
|
|
2033
5090
|
if (!resp.ok)
|
|
2034
5091
|
return null;
|
|
2035
5092
|
const apps = (await resp.json());
|
|
2036
|
-
return apps.find((a) => a.app_slug === ctx.app)?.uuid ?? null;
|
|
5093
|
+
return apps.find((a) => a.app_slug === ctx.app && a.status === "active")?.uuid ?? null;
|
|
2037
5094
|
}
|
|
2038
5095
|
catch {
|
|
2039
5096
|
return null;
|
|
2040
5097
|
}
|
|
2041
5098
|
})();
|
|
2042
5099
|
}
|
|
2043
|
-
return ctx.appUuidPromise
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
console.log(` created app "${ctx.app}" on ${creds.apiUrl} (first llm/sql/connector call)`);
|
|
2058
|
-
ctx.appUuidPromise = Promise.resolve(app.uuid); // later resolves skip the list
|
|
2059
|
-
return app.uuid;
|
|
2060
|
-
}
|
|
2061
|
-
catch {
|
|
2062
|
-
return null;
|
|
2063
|
-
}
|
|
2064
|
-
})();
|
|
2065
|
-
}
|
|
2066
|
-
return ctx.appCreatePromise;
|
|
2067
|
-
});
|
|
5100
|
+
return ctx.appUuidPromise;
|
|
5101
|
+
}
|
|
5102
|
+
export function devUpstreamAuthFallback(status, degradeOk) {
|
|
5103
|
+
if (status !== 401)
|
|
5104
|
+
return null;
|
|
5105
|
+
if (degradeOk)
|
|
5106
|
+
return { status: 200, body: [] };
|
|
5107
|
+
return {
|
|
5108
|
+
status: 503,
|
|
5109
|
+
body: {
|
|
5110
|
+
error: "auth",
|
|
5111
|
+
message: "Saved token was rejected. Run `railcode login` again.",
|
|
5112
|
+
},
|
|
5113
|
+
};
|
|
2068
5114
|
}
|
|
2069
5115
|
async function forwardCompute(ctx, req, res, path, url) {
|
|
2070
5116
|
// Never reply 401 to the browser — the SDK reloads the page on 401 (loop).
|
|
2071
|
-
// Load-time list calls degrade to [] so dataConnectors()/
|
|
2072
|
-
// load don't break
|
|
2073
|
-
// return 503
|
|
2074
|
-
|
|
5117
|
+
// Load-time list calls degrade to [] so dataConnectors()/savedQueries()/
|
|
5118
|
+
// serviceConnectors() at load don't break when the saved token is bad; actual
|
|
5119
|
+
// calls (/queries/{name}, /sql, /llm, /service-connectors/request) return 503.
|
|
5120
|
+
// A 403 is a real authorization denial and must be forwarded with its body.
|
|
5121
|
+
const degradeOk = path === "/connections" ||
|
|
5122
|
+
path === "/queries" ||
|
|
5123
|
+
path === "/service-connectors" ||
|
|
5124
|
+
path === "/llm/providers";
|
|
2075
5125
|
const creds = devCreds(ctx.config);
|
|
2076
5126
|
if (!creds) {
|
|
2077
5127
|
if (degradeOk)
|
|
@@ -2081,15 +5131,13 @@ async function forwardCompute(ctx, req, res, path, url) {
|
|
|
2081
5131
|
message: "Run `railcode login` to use LLM and connectors in dev.",
|
|
2082
5132
|
});
|
|
2083
5133
|
}
|
|
2084
|
-
|
|
2085
|
-
// the app server-side; a load-time list (GET /connections, /service-connectors) won't.
|
|
2086
|
-
const appUuid = await resolveDevAppUuid(ctx, creds, !degradeOk);
|
|
5134
|
+
const appUuid = await resolveDevAppUuid(ctx, creds);
|
|
2087
5135
|
if (!appUuid) {
|
|
2088
5136
|
if (degradeOk)
|
|
2089
5137
|
return sendJson(res, 200, []);
|
|
2090
5138
|
return sendJson(res, 503, {
|
|
2091
5139
|
error: "app_unavailable",
|
|
2092
|
-
message: "
|
|
5140
|
+
message: "Deploy this app once before using LLM, SQL, saved queries, or connectors in dev.",
|
|
2093
5141
|
});
|
|
2094
5142
|
}
|
|
2095
5143
|
const target = `${creds.apiUrl}/api/organizations/${creds.orgUuid}/apps/${appUuid}${path}${url.search}`;
|
|
@@ -2120,13 +5168,9 @@ async function forwardCompute(ctx, req, res, path, url) {
|
|
|
2120
5168
|
return sendJson(res, 200, []);
|
|
2121
5169
|
return sendJson(res, 503, { error: "unreachable", message: `Could not reach ${creds.apiUrl}.` });
|
|
2122
5170
|
}
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
return sendJson(res, 503, {
|
|
2127
|
-
error: "auth",
|
|
2128
|
-
message: "Saved token was rejected. Run `railcode login` again.",
|
|
2129
|
-
});
|
|
5171
|
+
const authFallback = devUpstreamAuthFallback(upstream.status, degradeOk);
|
|
5172
|
+
if (authFallback) {
|
|
5173
|
+
return sendJson(res, authFallback.status, authFallback.body);
|
|
2130
5174
|
}
|
|
2131
5175
|
if (path === "/llm/stream" && upstream.body) {
|
|
2132
5176
|
res.writeHead(upstream.status, {
|