railcode 0.1.14 → 0.1.17

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/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";
@@ -13,7 +13,9 @@ 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
15
  import { QueryCmdError, paramSignature, parseParamDecls, parseRunParams, pickAuthoringSql, } from "./query.js";
16
+ import { APP_MANIFEST_NAME, ManifestParseError, documentOperations, parseManifestYaml, renderManifestDeployOutcome, renderManifestSummary, } from "./manifest.js";
16
17
  import { ConnectorError, buildConnectorRequest, connectorOpenapiOutput, connectorTable, parseConnectorOption, parseMethodOption, pickConnectorBody, proxyExitCode, renderConnectorDocs, renderProxyResponse, } from "./connectors.js";
18
+ import { llmModelTable, llmProviderTable } from "./llm.js";
17
19
  // ---------------------------------------------------------------------------
18
20
  // Constants
19
21
  // ---------------------------------------------------------------------------
@@ -31,13 +33,16 @@ Developer CLI for the multi-tenant Railcode platform.
31
33
 
32
34
  Usage:
33
35
  railcode login [--api-url <url>] Sign in and mint a personal API token
34
- railcode deploy Build (if configured) and deploy the app here
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
35
38
  railcode dev [--port <n>] [--reset] Run the app locally against an emulated /_api
36
- railcode init <app> [--template static|react]
39
+ railcode init <app> [--template react|static]
37
40
  railcode design-system Print your org's design-system guidance (markdown)
38
41
  railcode db <list|query> ... List data connectors / run read-only SQL
39
42
  railcode query <list|run|create|update|delete> ... Saved queries: invoke by name / author (admin)
40
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
41
46
  railcode --version
42
47
  railcode --help
43
48
 
@@ -86,6 +91,12 @@ async function main() {
86
91
  case "connectors":
87
92
  await commandConnector(args);
88
93
  return;
94
+ case "llm":
95
+ await commandLlm(args);
96
+ return;
97
+ case "manifest":
98
+ await commandManifest(args);
99
+ return;
89
100
  default:
90
101
  throw new CliError(`Unknown command: ${args.command}\n\n${HELP}`, 2);
91
102
  }
@@ -148,6 +159,11 @@ function readCliPackageVersion() {
148
159
  // ---------------------------------------------------------------------------
149
160
  async function commandLogin(args) {
150
161
  const saved = loadConfig();
162
+ const setupToken = optionString(args, "setupToken");
163
+ if (setupToken) {
164
+ await loginWithSetupToken(args, saved, setupToken);
165
+ return;
166
+ }
151
167
  const apiUrl = await resolveLoginApiUrl(args, saved);
152
168
  const serverConfig = (await readJsonOrThrow(await apiFetch(apiUrl, "/api/config", {}), "Server config"));
153
169
  const minted = await authorizeCliWithBrowser(apiUrl);
@@ -183,6 +199,41 @@ async function commandLogin(args) {
183
199
  }
184
200
  console.log(`Token ${minted.token_prefix}... saved to ${CONFIG_PATH}.`);
185
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
+ }
186
237
  async function authorizeCliWithBrowser(apiUrl) {
187
238
  if (!process.stdin.isTTY) {
188
239
  throw new CliError("Browser login requires a TTY. Set RAILCODE_API_TOKEN in non-interactive environments.", 2);
@@ -369,36 +420,90 @@ async function commandDeploy(args) {
369
420
  await runShell(deployPlan.build, cwd);
370
421
  }
371
422
  const files = collectDeployFiles(deployPlan.root, { appRoot: cwd });
372
- // Create-or-resolve the app by slug within the saved org.
373
- const app = await resolveOrCreateApp(config, manifest.app);
423
+ includeAppManifest(files, cwd);
424
+ validateBundledManifest(files);
374
425
  console.log(`Deploying ${files.length} file(s) to ${manifest.app}...`);
375
- let response = await postDeploy(config, app.uuid, files);
426
+ let response = await postDeployBySlug(config, manifest.app, files);
376
427
  if (response.status === 401) {
377
428
  config = await reLoginOrThrow();
378
- const fresh = await resolveOrCreateApp(config, manifest.app);
379
- response = await postDeploy(config, fresh.uuid, files);
429
+ response = await postDeployBySlug(config, manifest.app, files);
380
430
  }
381
431
  if (!response.ok) {
382
432
  throw new CliError(`Deploy failed (${response.status}): ${await errorDetail(response)}`, 1);
383
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
+ }
384
446
  const liveUrl = appLiveUrl(config, manifest.app);
385
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
+ }
386
455
  console.log(liveUrl);
387
456
  }
388
- async function resolveOrCreateApp(config, slug) {
389
- const orgPath = `/api/organizations/${config.orgUuid}/apps`;
390
- const list = (await readJsonOrThrow(await authedFetch(config, orgPath), "List apps"));
391
- const existing = list.find((app) => app.app_slug === slug);
392
- if (existing)
393
- return existing;
394
- const created = await authedFetch(config, orgPath, {
395
- 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",
396
463
  headers: { "Content-Type": "application/json" },
397
- body: JSON.stringify({ name: slug, app_slug: slug }),
464
+ body: JSON.stringify({ mode: "private", members: [] }),
465
+ });
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),
398
483
  });
399
- return (await readJsonOrThrow(created, "Create app"));
400
484
  }
401
- async function postDeploy(config, appUuid, files) {
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;
505
+ }
506
+ async function postDeployBySlug(config, appSlug, files) {
402
507
  // The deploy API is multipart: one part named "files" per file, with the
403
508
  // relative path carried as the part filename.
404
509
  const form = new FormData();
@@ -406,7 +511,7 @@ async function postDeploy(config, appUuid, files) {
406
511
  const bytes = new Uint8Array(file.data);
407
512
  form.append("files", new Blob([bytes], { type: file.contentType }), file.path);
408
513
  }
409
- return authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${appUuid}/deploy`, { method: "POST", body: form });
514
+ return authedFetch(config, `/api/organizations/${config.orgUuid}/apps/by-slug/${encodeURIComponent(appSlug)}/deploy`, { method: "POST", body: form });
410
515
  }
411
516
  function collectDeployFiles(root, options) {
412
517
  if (!existsSync(root) || !statSync(root).isDirectory()) {
@@ -450,7 +555,13 @@ function shouldSkipDeployPath(root, appRoot, relPath) {
450
555
  const deployingAppRoot = resolve(root) === resolve(appRoot);
451
556
  if (!deployingAppRoot)
452
557
  return false;
453
- return first === MANIFEST_NAME || first === "package.json" || first === "pnpm-lock.yaml";
558
+ return (first === MANIFEST_NAME ||
559
+ first === "package.json" ||
560
+ first === "pnpm-lock.yaml" ||
561
+ first === "package-lock.json" ||
562
+ first === "yarn.lock" ||
563
+ first === "bun.lockb" ||
564
+ first === "bun.lock");
454
565
  }
455
566
  function appLiveUrl(config, appSlug) {
456
567
  const api = new URL(config.apiUrl ?? DEFAULT_API_URL);
@@ -490,6 +601,35 @@ async function resolveDeployPlan(root, manifest) {
490
601
  }
491
602
  throw new CliError(`Could not find deploy output. Set "dist" in ${MANIFEST_NAME}, add a package.json build script, or put index.html at the app root.`, 2);
492
603
  }
604
+ // Which package manager to drive for an app dir. We never want to force a
605
+ // particular one onto users, so we detect what the project already declares —
606
+ // the corepack `packageManager` field first, then a lockfile — and fall back to
607
+ // npm, the only manager guaranteed to ship with Node. A fresh scaffold (no
608
+ // lockfile) therefore uses npm; a user who adopts pnpm/yarn/bun is respected via
609
+ // their lockfile.
610
+ function detectPackageManager(root) {
611
+ const packagePath = join(root, "package.json");
612
+ if (existsSync(packagePath)) {
613
+ try {
614
+ const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
615
+ if (typeof pkg.packageManager === "string") {
616
+ const name = pkg.packageManager.split("@")[0];
617
+ if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun")
618
+ return name;
619
+ }
620
+ }
621
+ catch {
622
+ // Malformed package.json — fall through to lockfile / npm default.
623
+ }
624
+ }
625
+ if (existsSync(join(root, "pnpm-lock.yaml")))
626
+ return "pnpm";
627
+ if (existsSync(join(root, "yarn.lock")))
628
+ return "yarn";
629
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
630
+ return "bun";
631
+ return "npm";
632
+ }
493
633
  function packageBuildCommand(root) {
494
634
  const packagePath = join(root, "package.json");
495
635
  if (!existsSync(packagePath))
@@ -504,7 +644,7 @@ function packageBuildCommand(root) {
504
644
  if (typeof pkg.scripts?.build !== "string") {
505
645
  throw new CliError(`Found package.json but no build script. Add "build", or set "dist" in ${MANIFEST_NAME}.`, 2);
506
646
  }
507
- return "pnpm run build";
647
+ return `${detectPackageManager(root)} run build`;
508
648
  }
509
649
  // ---------------------------------------------------------------------------
510
650
  // init
@@ -530,14 +670,14 @@ async function commandInit(args) {
530
670
  console.log("Next:");
531
671
  console.log(` cd ${app}`);
532
672
  if (template === "react") {
533
- console.log(" pnpm install");
673
+ console.log(" npm install");
534
674
  }
535
675
  console.log(" railcode deploy");
536
676
  }
537
677
  function initTemplate(args) {
538
678
  const value = args.options.template;
539
679
  if (value === undefined)
540
- return "static";
680
+ return "react";
541
681
  if (value === true)
542
682
  throw new CliError("--template requires static or react.", 2);
543
683
  if (value === "static" || value === "react")
@@ -549,225 +689,2684 @@ function writeStaticStarter(target, app) {
549
689
  writeFileSync(join(target, "index.html"), starterIndexHtml(app));
550
690
  }
551
691
  function writeReactStarter(target, app) {
552
- writeFileSync(join(target, MANIFEST_NAME), `${JSON.stringify({ app, build: "pnpm run build", dist: "dist" }, null, 2)}\n`);
692
+ writeFileSync(join(target, MANIFEST_NAME), `${JSON.stringify({ app, build: "npm run build", dist: "dist" }, null, 2)}\n`);
553
693
  writeFileSync(join(target, "package.json"), reactPackageJson(app));
554
694
  writeFileSync(join(target, "index.html"), reactIndexHtml(app));
555
695
  writeFileSync(join(target, "tsconfig.json"), reactTsConfig());
556
696
  writeFileSync(join(target, "vite.config.ts"), reactViteConfig());
557
697
  mkdirSync(join(target, "src"), { recursive: true });
558
698
  writeFileSync(join(target, "src", "main.tsx"), reactMainTsx());
699
+ writeFileSync(join(target, "src", "railcode.d.ts"), reactRailcodeTypes());
700
+ writeFileSync(join(target, "src", "vite-env.d.ts"), reactViteEnvTypes());
559
701
  writeFileSync(join(target, "src", "App.tsx"), reactAppTsx(app));
560
702
  writeFileSync(join(target, "src", "styles.css"), reactStylesCss());
703
+ const assetsDir = resolveReactTemplateAssetsDir();
704
+ if (assetsDir) {
705
+ cpSync(assetsDir, join(target, "src", "assets"), { recursive: true });
706
+ }
707
+ }
708
+ function starterIndexHtml(app) {
709
+ // Plain HTML/JS starter — no build step. It loads the data-plane SDK and
710
+ // demonstrates the two most common calls: who am I, and read/write a doc.
711
+ return `<!doctype html>
712
+ <html lang="en">
713
+ <head>
714
+ <meta charset="utf-8" />
715
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
716
+ <title>${titleCase(app)} — Railcode</title>
717
+ <script src="/_api/sdk.js"></script>
718
+ </head>
719
+ <body>
720
+ <main>
721
+ <h1>${titleCase(app)}</h1>
722
+ <p id="who">Loading your identity…</p>
723
+ <p id="doc">Loading saved data…</p>
724
+ </main>
725
+ <script>
726
+ async function main() {
727
+ // \`me()\` and \`db\` are provided by /_api/sdk.js once the app is served.
728
+ const identity = await me();
729
+ document.getElementById("who").textContent =
730
+ "Signed in as " + ((identity.user && identity.user.email) || "unknown");
731
+
732
+ const docs = db.collection("greetings");
733
+ await docs.put("hello", { text: "Hello from ${app}!", at: new Date().toISOString() });
734
+ const saved = await docs.get("hello");
735
+ document.getElementById("doc").textContent = JSON.stringify(saved);
736
+ }
737
+ main().catch((error) => {
738
+ document.getElementById("who").textContent = "SDK error: " + error.message;
739
+ });
740
+ </script>
741
+ </body>
742
+ </html>
743
+ `;
744
+ }
745
+ function reactPackageJson(app) {
746
+ return `${JSON.stringify({
747
+ name: app,
748
+ version: "0.1.0",
749
+ private: true,
750
+ type: "module",
751
+ scripts: {
752
+ dev: "vite",
753
+ build: "tsc -p tsconfig.json && vite build",
754
+ preview: "vite preview",
755
+ },
756
+ dependencies: {
757
+ react: "^19.2.0",
758
+ "react-dom": "^19.2.0",
759
+ zustand: "^5.0.0",
760
+ },
761
+ devDependencies: {
762
+ "@vitejs/plugin-react": "^6.0.0",
763
+ "@types/react": "^19.0.0",
764
+ "@types/react-dom": "^19.0.0",
765
+ typescript: "^6.0.0",
766
+ vite: "^8.0.0",
767
+ },
768
+ }, null, 2)}\n`;
769
+ }
770
+ function reactIndexHtml(app) {
771
+ return `<!doctype html>
772
+ <html lang="en">
773
+ <head>
774
+ <meta charset="UTF-8" />
775
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
776
+ <title>${titleCase(app)} | Railcode</title>
777
+ <script src="/_api/sdk.js"></script>
778
+ </head>
779
+ <body>
780
+ <div id="root"></div>
781
+ <script type="module" src="/src/main.tsx"></script>
782
+ </body>
783
+ </html>
784
+ `;
785
+ }
786
+ function reactTsConfig() {
787
+ return `${JSON.stringify({
788
+ compilerOptions: {
789
+ target: "ES2022",
790
+ useDefineForClassFields: true,
791
+ lib: ["DOM", "DOM.Iterable", "ES2022"],
792
+ allowJs: false,
793
+ skipLibCheck: true,
794
+ esModuleInterop: true,
795
+ allowSyntheticDefaultImports: true,
796
+ strict: true,
797
+ forceConsistentCasingInFileNames: true,
798
+ module: "ESNext",
799
+ moduleResolution: "Bundler",
800
+ resolveJsonModule: true,
801
+ isolatedModules: true,
802
+ noEmit: true,
803
+ jsx: "react-jsx",
804
+ },
805
+ include: ["src"],
806
+ references: [],
807
+ }, null, 2)}\n`;
808
+ }
809
+ function reactViteConfig() {
810
+ return `import { defineConfig } from "vite";
811
+ import react from "@vitejs/plugin-react";
812
+
813
+ export default defineConfig({
814
+ plugins: [react()],
815
+ build: {
816
+ outDir: "dist",
817
+ },
818
+ });
819
+ `;
820
+ }
821
+ function reactRailcodeTypes() {
822
+ return `type Me = {
823
+ user: { uuid: string; name: string; email: string };
824
+ app: { uuid: string; slug: string; name: string };
825
+ org: { uuid: string; slug: string; name: string };
826
+ };
827
+
828
+ type AppUser = {
829
+ uuid: string;
830
+ name: string;
831
+ email: string;
832
+ role: string | null;
833
+ };
834
+
835
+ type KvRecord<T = unknown> = {
836
+ key: string;
837
+ value: T;
838
+ updated_at: string;
839
+ };
840
+
841
+ type WhereOp = "eq" | "ne" | "gt" | "gte" | "lt" | "lte" | "in";
842
+
843
+ type KvQuery<T = unknown> = {
844
+ where(field: string, op: WhereOp, value: unknown): KvQuery<T>;
845
+ prefix(value: string): KvQuery<T>;
846
+ updatedSince(value: string | Date): KvQuery<T>;
847
+ updatedBefore(value: string | Date): KvQuery<T>;
848
+ orderBy(field: string, direction?: "asc" | "desc"): KvQuery<T>;
849
+ page(page?: number, size?: number): Promise<KvRecord<T>[]>;
850
+ first(): Promise<KvRecord<T> | null>;
851
+ count(): Promise<number>;
852
+ };
853
+
854
+ type Collection<T = unknown> = {
855
+ get(key: string): Promise<T | null>;
856
+ put(key: string, value: T): Promise<T>;
857
+ delete(key: string): Promise<void>;
858
+ list(): Promise<KvRecord<T>[]>;
859
+ query(): KvQuery<T>;
860
+ where(field: string, op: WhereOp, value: unknown): KvQuery<T>;
861
+ prefix(value: string): KvQuery<T>;
862
+ };
863
+
864
+ type FileMeta = {
865
+ name: string;
866
+ content_type: string;
867
+ size: number;
868
+ updated_at: string;
869
+ };
870
+
871
+ type SqlRows = Array<Record<string, unknown>> & {
872
+ columns?: string[];
873
+ rowcount?: number;
874
+ truncated?: boolean;
875
+ };
876
+
877
+ type DataConnectorInfo = {
878
+ engine: "postgres" | "bigquery" | "snowflake" | string;
879
+ name: string;
880
+ };
881
+
882
+ type DatabaseHandle = {
883
+ runSQL(query: string, params?: unknown[]): Promise<SqlRows>;
884
+ };
885
+
886
+ type DatabaseNamespace = ((connection: string) => DatabaseHandle) & DatabaseHandle;
887
+
888
+ type SavedQueryInfo = {
889
+ name: string;
890
+ description: string;
891
+ params: { name: string; type: string }[];
892
+ version: number;
893
+ };
894
+
895
+ type ServiceConnectorInfo = {
896
+ name: string;
897
+ description: string | null;
898
+ auth_type: string;
899
+ allowed_methods: string[];
900
+ };
901
+
902
+ type ServiceConnectorResponse = {
903
+ status: number;
904
+ ok: boolean;
905
+ headers: Record<string, string>;
906
+ truncated: boolean;
907
+ text(): Promise<string>;
908
+ json<T = unknown>(): Promise<T>;
909
+ };
910
+
911
+ type LlmMessage = { role: "system" | "user" | "assistant"; content: string };
912
+
913
+ type LlmOutputSpec = { type?: "text" } | { type: "json"; schema: Record<string, unknown> };
914
+
915
+ type LlmOptions = {
916
+ /** A catalog model name (see llmProviders()); its provider is implied. */
917
+ model?: string;
918
+ /** A provider name; without a model, routes to that provider's default model. */
919
+ provider?: string;
920
+ system?: string;
921
+ output?: LlmOutputSpec;
922
+ temperature?: number;
923
+ maxOutputTokens?: number;
924
+ metadata?: Record<string, unknown>;
925
+ };
926
+
927
+ type LlmUsage = { inputTokens: number; outputTokens: number; totalTokens: number };
928
+
929
+ type LlmResult = {
930
+ text: string;
931
+ output: unknown | null;
932
+ usage: LlmUsage;
933
+ cost: string | null;
934
+ provider: string;
935
+ model: string;
936
+ finishReason: string | null;
937
+ requestId: string;
938
+ };
939
+
940
+ type LlmStreamEvent =
941
+ | { type: "text"; text: string }
942
+ | {
943
+ type: "done";
944
+ usage: LlmUsage;
945
+ cost: string | null;
946
+ provider: string;
947
+ model: string;
948
+ finishReason: string | null;
949
+ requestId: string;
950
+ }
951
+ | { type: "error"; error: string; message: string; requestId: string };
952
+
953
+ type LlmModelInfo = { model: string; default: boolean };
954
+
955
+ type LlmProviderInfo = { provider: string; default: boolean; models: LlmModelInfo[] };
956
+
957
+ declare const me: () => Promise<Me>;
958
+ declare const appUsers: () => Promise<AppUser[]>;
959
+ declare const designSystem: () => Promise<string>;
960
+ declare const db: { collection<T = unknown>(name: string): Collection<T> };
961
+ declare const files: {
962
+ upload(name: string, data: Blob | ArrayBuffer | ArrayBufferView, contentType?: string): Promise<FileMeta>;
963
+ url(name: string): string;
964
+ list(): Promise<FileMeta[]>;
965
+ delete(name: string): Promise<void>;
966
+ };
967
+ declare const data: DatabaseNamespace;
968
+ declare const postgres: DatabaseNamespace;
969
+ declare const bigquery: DatabaseNamespace;
970
+ declare const snowflake: DatabaseNamespace;
971
+ declare const dataConnectors: () => Promise<DataConnectorInfo[]>;
972
+ declare const savedQueries: () => Promise<SavedQueryInfo[]>;
973
+ declare const query: (name: string, params?: Record<string, unknown>) => Promise<SqlRows>;
974
+ declare const serviceConnectors: () => Promise<ServiceConnectorInfo[]>;
975
+ declare const connector: (name: string) => {
976
+ fetch(path: string, opts?: { method?: string; body?: string }): Promise<ServiceConnectorResponse>;
977
+ };
978
+ declare const llm: {
979
+ generate(input: string | LlmMessage[], opts?: LlmOptions): Promise<LlmResult>;
980
+ stream(input: string | LlmMessage[], opts?: LlmOptions): AsyncIterable<LlmStreamEvent>;
981
+ };
982
+ declare const llmProviders: () => Promise<LlmProviderInfo[]>;
983
+ `;
984
+ }
985
+ function reactViteEnvTypes() {
986
+ return `/// <reference types="vite/client" />
987
+ `;
988
+ }
989
+ function reactMainTsx() {
990
+ return `import React from "react";
991
+ import ReactDOM from "react-dom/client";
992
+ import { App } from "./App";
993
+ import "./styles.css";
994
+
995
+ ReactDOM.createRoot(document.getElementById("root")!).render(
996
+ <React.StrictMode>
997
+ <App />
998
+ </React.StrictMode>,
999
+ );
1000
+ `;
1001
+ }
1002
+ function reactAppTsx(app) {
1003
+ return `import {
1004
+ useEffect,
1005
+ useRef,
1006
+ useState,
1007
+ type ChangeEvent,
1008
+ type FormEvent,
1009
+ type ReactNode,
1010
+ } from "react";
1011
+ import { create } from "zustand";
1012
+ import postgresLogo from "./assets/postgres.svg";
1013
+ import bigqueryLogo from "./assets/bigquery.png";
1014
+ import stripeLogo from "./assets/connectors/stripe.png";
1015
+ import posthogLogo from "./assets/connectors/posthog.svg";
1016
+ import mixpanelLogo from "./assets/connectors/mixpanel.png";
1017
+ import resendLogo from "./assets/connectors/resend.svg";
1018
+ import clerkLogo from "./assets/connectors/clerk.png";
1019
+ import openaiLogo from "./assets/llm/openai.svg";
1020
+ import anthropicLogo from "./assets/llm/anthropic.png";
1021
+ import geminiLogo from "./assets/llm/gemini.png";
1022
+ import bedrockLogo from "./assets/llm/bedrock.png";
1023
+
1024
+ const APP_SLUG = "${app}";
1025
+
1026
+ type TodoValue = {
1027
+ text: string;
1028
+ done: boolean;
1029
+ authorName: string;
1030
+ authorEmail: string;
1031
+ updatedAt: string;
1032
+ };
1033
+
1034
+ type AppState = {
1035
+ loading: boolean;
1036
+ error: string | null;
1037
+ identity: Me | null;
1038
+ users: AppUser[];
1039
+ todos: KvRecord<TodoValue>[];
1040
+ fileList: FileMeta[];
1041
+ dataConnectorList: DataConnectorInfo[];
1042
+ savedQueryList: SavedQueryInfo[];
1043
+ serviceConnectorList: ServiceConnectorInfo[];
1044
+ load: () => Promise<void>;
1045
+ addTodo: (text: string) => Promise<void>;
1046
+ toggleTodo: (row: KvRecord<TodoValue>) => Promise<void>;
1047
+ deleteTodo: (key: string) => Promise<void>;
1048
+ uploadFile: (file: File) => Promise<void>;
1049
+ deleteFile: (name: string) => Promise<void>;
1050
+ clearError: () => void;
1051
+ };
1052
+
1053
+ function message(error: unknown): string {
1054
+ return error instanceof Error ? error.message : String(error);
1055
+ }
1056
+
1057
+ function truncate(value: string, max: number): string {
1058
+ return value.length > max ? value.slice(0, max - 1) + "…" : value;
1059
+ }
1060
+
1061
+ function formatBytes(value: number | undefined): string {
1062
+ if (!value) return "0 B";
1063
+ if (value < 1024) return value + " B";
1064
+ if (value < 1024 * 1024) return (value / 1024).toFixed(1) + " KB";
1065
+ return (value / (1024 * 1024)).toFixed(1) + " MB";
1066
+ }
1067
+
1068
+ function firstName(name: string): string {
1069
+ const trimmed = name.trim();
1070
+ const space = trimmed.indexOf(" ");
1071
+ return space === -1 ? trimmed : trimmed.slice(0, space);
1072
+ }
1073
+
1074
+ function initials(name: string): string {
1075
+ const parts = name.trim().split(/\\s+/);
1076
+ const first = parts[0]?.[0] || "";
1077
+ const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
1078
+ return (first + last).toUpperCase();
1079
+ }
1080
+
1081
+ function relativeTime(iso: string): string {
1082
+ const then = new Date(iso).getTime();
1083
+ if (Number.isNaN(then)) return "";
1084
+ const diffMs = Date.now() - then;
1085
+ const minute = 60000;
1086
+ const hour = 60 * minute;
1087
+ const day = 24 * hour;
1088
+ if (diffMs < minute) return "just now";
1089
+ if (diffMs < hour) return Math.round(diffMs / minute) + "m ago";
1090
+ if (diffMs < day) return Math.round(diffMs / hour) + "h ago";
1091
+ return Math.round(diffMs / day) + "d ago";
1092
+ }
1093
+
1094
+ function refreshTodos() {
1095
+ return db.collection<TodoValue>("todos").query().orderBy("updatedAt", "desc").page(1, 20);
1096
+ }
1097
+
1098
+ const useAppStore = create<AppState>((set, get) => ({
1099
+ loading: false,
1100
+ error: null,
1101
+ identity: null,
1102
+ users: [],
1103
+ todos: [],
1104
+ fileList: [],
1105
+ dataConnectorList: [],
1106
+ savedQueryList: [],
1107
+ serviceConnectorList: [],
1108
+
1109
+ async load() {
1110
+ set({ loading: true, error: null });
1111
+ const results = await Promise.allSettled([
1112
+ me(),
1113
+ appUsers(),
1114
+ refreshTodos(),
1115
+ files.list(),
1116
+ dataConnectors(),
1117
+ savedQueries(),
1118
+ serviceConnectors(),
1119
+ ]);
1120
+
1121
+ const failures = results
1122
+ .filter((result): result is PromiseRejectedResult => result.status === "rejected")
1123
+ .map((result) => message(result.reason));
1124
+
1125
+ set({
1126
+ loading: false,
1127
+ error: failures[0] || null,
1128
+ identity: results[0].status === "fulfilled" ? results[0].value : get().identity,
1129
+ users: results[1].status === "fulfilled" ? results[1].value : get().users,
1130
+ todos: results[2].status === "fulfilled" ? results[2].value : get().todos,
1131
+ fileList: results[3].status === "fulfilled" ? results[3].value : get().fileList,
1132
+ dataConnectorList:
1133
+ results[4].status === "fulfilled" ? results[4].value : get().dataConnectorList,
1134
+ savedQueryList: results[5].status === "fulfilled" ? results[5].value : get().savedQueryList,
1135
+ serviceConnectorList:
1136
+ results[6].status === "fulfilled" ? results[6].value : get().serviceConnectorList,
1137
+ });
1138
+ },
1139
+
1140
+ async addTodo(text) {
1141
+ try {
1142
+ const identity = get().identity || (await me());
1143
+ const id = crypto.randomUUID();
1144
+ const value: TodoValue = {
1145
+ text,
1146
+ done: false,
1147
+ authorName: identity.user.name,
1148
+ authorEmail: identity.user.email,
1149
+ updatedAt: new Date().toISOString(),
1150
+ };
1151
+ await db.collection<TodoValue>("todos").put(id, value);
1152
+ set({ todos: await refreshTodos(), identity, error: null });
1153
+ } catch (error) {
1154
+ set({ error: message(error) });
1155
+ }
1156
+ },
1157
+
1158
+ async toggleTodo(row) {
1159
+ try {
1160
+ const next: TodoValue = { ...row.value, done: !row.value.done, updatedAt: new Date().toISOString() };
1161
+ await db.collection<TodoValue>("todos").put(row.key, next);
1162
+ set({ todos: await refreshTodos(), error: null });
1163
+ } catch (error) {
1164
+ set({ error: message(error) });
1165
+ }
1166
+ },
1167
+
1168
+ async deleteTodo(key) {
1169
+ try {
1170
+ await db.collection<TodoValue>("todos").delete(key);
1171
+ set({ todos: await refreshTodos(), error: null });
1172
+ } catch (error) {
1173
+ set({ error: message(error) });
1174
+ }
1175
+ },
1176
+
1177
+ async uploadFile(file) {
1178
+ try {
1179
+ const contentType = file.type || "application/octet-stream";
1180
+ await files.upload(file.name, file, contentType);
1181
+ set({ fileList: await files.list(), error: null });
1182
+ } catch (error) {
1183
+ set({ error: message(error) });
1184
+ }
1185
+ },
1186
+
1187
+ async deleteFile(name) {
1188
+ try {
1189
+ await files.delete(name);
1190
+ set({ fileList: await files.list(), error: null });
1191
+ } catch (error) {
1192
+ set({ error: message(error) });
1193
+ }
1194
+ },
1195
+
1196
+ clearError() {
1197
+ set({ error: null });
1198
+ },
1199
+ }));
1200
+
1201
+ function usePrefersReducedMotion(): boolean {
1202
+ const [reduced, setReduced] = useState(false);
1203
+ useEffect(() => {
1204
+ const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
1205
+ setReduced(mql.matches);
1206
+ const handler = () => setReduced(mql.matches);
1207
+ mql.addEventListener("change", handler);
1208
+ return () => mql.removeEventListener("change", handler);
1209
+ }, []);
1210
+ return reduced;
1211
+ }
1212
+
1213
+ function Icon({ name, className }: { name: string; className?: string }) {
1214
+ return (
1215
+ <svg className={className ? "icon " + className : "icon"}>
1216
+ <use href={"#i-" + name}></use>
1217
+ </svg>
1218
+ );
1219
+ }
1220
+
1221
+ type CodeLanguage = "js" | "sql";
1222
+ type CodeTokenType = "string" | "keyword" | "number" | "call" | "punct" | "comment";
1223
+
1224
+ const CODE_TOKEN_RE =
1225
+ /('[^']*')|(\\bawait\\b)|(\\b\\d+\\b)|([A-Za-z_$][\\w$]*(?=\\())|([^\\s'A-Za-z0-9_$]+)|(\\s+)|([A-Za-z_$][\\w$]*)/g;
1226
+ const SQL_TOKEN_RE =
1227
+ /(--[^\\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;
1228
+
1229
+ function tokenizeCode(code: string): { type: CodeTokenType | null; text: string }[] {
1230
+ const tokens: { type: CodeTokenType | null; text: string }[] = [];
1231
+ const types: (CodeTokenType | null)[] = ["string", "keyword", "number", "call", "punct", null, null];
1232
+ let match: RegExpExecArray | null;
1233
+ CODE_TOKEN_RE.lastIndex = 0;
1234
+ while ((match = CODE_TOKEN_RE.exec(code))) {
1235
+ const groupIndex = match.slice(1).findIndex((group) => group !== undefined);
1236
+ tokens.push({ type: types[groupIndex], text: match[groupIndex + 1] });
1237
+ }
1238
+ return tokens;
1239
+ }
1240
+
1241
+ function tokenizeSql(code: string): { type: CodeTokenType | null; text: string }[] {
1242
+ const tokens: { type: CodeTokenType | null; text: string }[] = [];
1243
+ const types: (CodeTokenType | null)[] = [
1244
+ "comment",
1245
+ "string",
1246
+ "number",
1247
+ "keyword",
1248
+ null,
1249
+ "punct",
1250
+ null,
1251
+ ];
1252
+ let match: RegExpExecArray | null;
1253
+ SQL_TOKEN_RE.lastIndex = 0;
1254
+ while ((match = SQL_TOKEN_RE.exec(code))) {
1255
+ const groupIndex = match.slice(1).findIndex((group) => group !== undefined);
1256
+ tokens.push({ type: types[groupIndex], text: match[groupIndex + 1] });
1257
+ }
1258
+ return tokens;
1259
+ }
1260
+
1261
+ function CodeHighlight({ code, language = "js" }: { code: string; language?: CodeLanguage }) {
1262
+ const tokens = language === "sql" ? tokenizeSql(code) : tokenizeCode(code);
1263
+ return (
1264
+ <>
1265
+ {tokens.map((token, index) =>
1266
+ token.type ? (
1267
+ <span className={"code-" + token.type} key={index}>
1268
+ {token.text}
1269
+ </span>
1270
+ ) : (
1271
+ token.text
1272
+ ),
1273
+ )}
1274
+ </>
1275
+ );
1276
+ }
1277
+
1278
+ function Reveal({ code, below, children }: { code: string; below?: boolean; children: ReactNode }) {
1279
+ return (
1280
+ <span className="reveal" tabIndex={0} aria-label={"SDK call: " + code}>
1281
+ {children}
1282
+ <span className={below ? "reveal-tip is-below" : "reveal-tip"}>
1283
+ <CodeHighlight code={code} />
1284
+ </span>
1285
+ </span>
1286
+ );
1287
+ }
1288
+
1289
+ function ActionTip({
1290
+ code,
1291
+ className,
1292
+ children,
1293
+ }: {
1294
+ code: string;
1295
+ className?: string;
1296
+ children: ReactNode;
1297
+ }) {
1298
+ return (
1299
+ <span className={className ? "action-tip " + className : "action-tip"}>
1300
+ {children}
1301
+ <span className="action-tip-pop">
1302
+ <CodeHighlight code={code} />
1303
+ </span>
1304
+ </span>
1305
+ );
1306
+ }
1307
+
1308
+ function ThemeToggle() {
1309
+ const [theme, setTheme] = useState<"light" | "dark" | null>(null);
1310
+
1311
+ useEffect(() => {
1312
+ const stored = localStorage.getItem("theme");
1313
+ if (stored === "light" || stored === "dark") {
1314
+ setTheme(stored);
1315
+ document.documentElement.setAttribute("data-theme", stored);
1316
+ }
1317
+ }, []);
1318
+
1319
+ function toggle() {
1320
+ const mql = window.matchMedia("(prefers-color-scheme: dark)");
1321
+ const current = theme || (mql.matches ? "dark" : "light");
1322
+ const next = current === "dark" ? "light" : "dark";
1323
+ setTheme(next);
1324
+ document.documentElement.setAttribute("data-theme", next);
1325
+ localStorage.setItem("theme", next);
1326
+ }
1327
+
1328
+ return (
1329
+ <button className="theme-toggle" type="button" onClick={toggle} aria-label="Toggle color theme">
1330
+ <Icon name="sun" className="icon-sun" />
1331
+ <Icon name="moon" className="icon-moon" />
1332
+ </button>
1333
+ );
1334
+ }
1335
+
1336
+ function Hero() {
1337
+ const { identity, users } = useAppStore();
1338
+ const name = identity ? firstName(identity.user.name) : "…";
1339
+ const teammates = users.filter((user) => user.uuid !== identity?.user.uuid);
1340
+
1341
+ return (
1342
+ <section className="hero">
1343
+ <h1>
1344
+ Welcome to Railcode, <Reveal code="await me()" below>{name}</Reveal>!
1345
+ </h1>
1346
+ <p className="hero-sub">
1347
+ This is an interactive guide showing a little bit of what Railcode apps can do. But
1348
+ don&apos;t worry about memorizing anything, your coding agent will know what to do.
1349
+ </p>
1350
+ <div className="team-row">
1351
+ <div className="avatar-stack">
1352
+ {identity ? (
1353
+ <div className="avatar is-you" title={identity.user.name + " · you"}>
1354
+ {initials(identity.user.name)}
1355
+ </div>
1356
+ ) : null}
1357
+ {teammates.slice(0, 4).map((user) => (
1358
+ <div className="avatar" key={user.uuid} title={user.name + (user.role ? " · " + user.role : "")}>
1359
+ {initials(user.name)}
1360
+ </div>
1361
+ ))}
1362
+ </div>
1363
+ <Reveal code="await appUsers()" below>
1364
+ <span className="team-caption">
1365
+ {users.length || 1} teammate{users.length === 1 ? "" : "s"} in {identity?.org.name || "your org"}
1366
+ </span>
1367
+ </Reveal>
1368
+ </div>
1369
+ </section>
1370
+ );
1371
+ }
1372
+
1373
+ function GuideSection({
1374
+ title,
1375
+ body,
1376
+ children,
1377
+ }: {
1378
+ title: string;
1379
+ body: string;
1380
+ children: ReactNode;
1381
+ }) {
1382
+ return (
1383
+ <section className="guide-section">
1384
+ <div className="guide-copy">
1385
+ <h2>{title}</h2>
1386
+ <p>{body}</p>
1387
+ </div>
1388
+ <div className="guide-demo">{children}</div>
1389
+ </section>
1390
+ );
1391
+ }
1392
+
1393
+ function TodosCard() {
1394
+ const { todos, addTodo, toggleTodo, deleteTodo } = useAppStore();
1395
+ const [text, setText] = useState("");
1396
+
1397
+ function submit(event: FormEvent<HTMLFormElement>) {
1398
+ event.preventDefault();
1399
+ const value = text.trim();
1400
+ if (!value) return;
1401
+ setText("");
1402
+ void addTodo(value);
1403
+ }
1404
+
1405
+ return (
1406
+ <article className="card">
1407
+ <div className="card-head">
1408
+ <div>
1409
+ <div className="card-title-row">
1410
+ <Icon name="checklist" />
1411
+ <span className="card-title">
1412
+ <Reveal
1413
+ code={\`await db
1414
+ .collection('todos')
1415
+ .query()
1416
+ .orderBy('updatedAt', 'desc')
1417
+ .page(1, 20)\`}
1418
+ >
1419
+ To-dos
1420
+ </Reveal>
1421
+ </span>
1422
+ </div>
1423
+ </div>
1424
+ </div>
1425
+
1426
+ <form className="add-row" onSubmit={submit}>
1427
+ <input
1428
+ className="field"
1429
+ type="text"
1430
+ placeholder="Add a to-do…"
1431
+ maxLength={80}
1432
+ autoComplete="off"
1433
+ value={text}
1434
+ onChange={(event) => setText(event.target.value)}
1435
+ />
1436
+ <ActionTip code={\`await db.collection('todos').put(id, {
1437
+ text,
1438
+ done: false
1439
+ })\`}
1440
+ >
1441
+ <button className="btn btn-primary" type="submit">
1442
+ <Icon name="plus" />
1443
+ Add
1444
+ </button>
1445
+ </ActionTip>
1446
+ </form>
1447
+
1448
+ <div className="todo-list" aria-live="polite">
1449
+ {todos.length ? (
1450
+ todos.map((row) => (
1451
+ <div className={row.value.done ? "todo-row is-done" : "todo-row"} key={row.key}>
1452
+ <ActionTip code={\`await db.collection('todos').put(key, {
1453
+ ...todo,
1454
+ done: \${!row.value.done}
1455
+ })\`}
1456
+ >
1457
+ <button
1458
+ className="checkbox"
1459
+ type="button"
1460
+ aria-label={row.value.done ? "Mark not done" : "Mark done"}
1461
+ aria-pressed={row.value.done}
1462
+ onClick={() => void toggleTodo(row)}
1463
+ >
1464
+ <Icon name="check" />
1465
+ </button>
1466
+ </ActionTip>
1467
+ <div className="todo-body">
1468
+ <div className="todo-text">{row.value.text}</div>
1469
+ <div className="todo-meta">
1470
+ Added by {row.value.authorName} &middot; {relativeTime(row.updated_at)}
1471
+ </div>
1472
+ </div>
1473
+ <ActionTip code="await db.collection('todos').delete(key)">
1474
+ <button
1475
+ className="row-delete"
1476
+ type="button"
1477
+ aria-label="Delete to-do"
1478
+ onClick={() => void deleteTodo(row.key)}
1479
+ >
1480
+ <Icon name="x" />
1481
+ </button>
1482
+ </ActionTip>
1483
+ </div>
1484
+ ))
1485
+ ) : (
1486
+ <p className="empty-note">No to-dos yet &mdash; add the first one.</p>
1487
+ )}
1488
+ </div>
1489
+ </article>
1490
+ );
1491
+ }
1492
+
1493
+ function FilesCard() {
1494
+ const { fileList, uploadFile, deleteFile } = useAppStore();
1495
+ const inputRef = useRef<HTMLInputElement | null>(null);
1496
+ const [uploading, setUploading] = useState(false);
1497
+
1498
+ async function handleFile(event: ChangeEvent<HTMLInputElement>) {
1499
+ const file = event.target.files?.[0];
1500
+ event.target.value = "";
1501
+ if (!file) return;
1502
+ setUploading(true);
1503
+ await uploadFile(file);
1504
+ setUploading(false);
1505
+ }
1506
+
1507
+ return (
1508
+ <article className="card">
1509
+ <div className="card-head">
1510
+ <div>
1511
+ <div className="card-title-row">
1512
+ <Icon name="file" />
1513
+ <span className="card-title">
1514
+ <Reveal code="await files.list()">Files</Reveal>
1515
+ </span>
1516
+ </div>
1517
+ </div>
1518
+ </div>
1519
+
1520
+ <div className="file-list" aria-live="polite">
1521
+ {fileList.length ? (
1522
+ fileList.map((file) => (
1523
+ <div className="file-row" key={file.name}>
1524
+ <div className="file-icon">
1525
+ <Icon name="file" />
1526
+ </div>
1527
+ <div className="file-body">
1528
+ <a className="file-name" href={files.url(file.name)} target="_blank" rel="noreferrer">
1529
+ {file.name}
1530
+ </a>
1531
+ <div className="file-meta tabular">
1532
+ {formatBytes(file.size)} &middot; {file.content_type}
1533
+ </div>
1534
+ </div>
1535
+ <ActionTip code="await files.delete(file.name)">
1536
+ <button
1537
+ className="row-delete"
1538
+ type="button"
1539
+ aria-label="Delete file"
1540
+ onClick={() => void deleteFile(file.name)}
1541
+ >
1542
+ <Icon name="x" />
1543
+ </button>
1544
+ </ActionTip>
1545
+ </div>
1546
+ ))
1547
+ ) : (
1548
+ <p className="empty-note">No files uploaded yet.</p>
1549
+ )}
1550
+ </div>
1551
+
1552
+ <input ref={inputRef} type="file" className="file-input-hidden" onChange={handleFile} />
1553
+ <ActionTip code="await files.upload(file.name, file, file.type)" className="upload-tip">
1554
+ <button
1555
+ className="btn btn-ghost upload-btn"
1556
+ type="button"
1557
+ disabled={uploading}
1558
+ onClick={() => inputRef.current?.click()}
1559
+ >
1560
+ {uploading ? <span className="spinner" /> : <Icon name="upload" />}
1561
+ {uploading ? "Uploading…" : "Upload a file"}
1562
+ </button>
1563
+ </ActionTip>
1564
+ </article>
1565
+ );
1566
+ }
1567
+
1568
+ function ResultTable({ columns, rows, caption }: { columns: string[]; rows: string[][]; caption: string }) {
1569
+ return (
1570
+ <>
1571
+ <div className="result-status">
1572
+ <span>{caption}</span>
1573
+ <span className="tabular">{rows.length} rows</span>
1574
+ </div>
1575
+ <div className="table-wrap">
1576
+ <table>
1577
+ <thead>
1578
+ <tr>
1579
+ {columns.map((column) => (
1580
+ <th key={column}>{column}</th>
1581
+ ))}
1582
+ </tr>
1583
+ </thead>
1584
+ <tbody>
1585
+ {rows.map((row, index) => (
1586
+ <tr key={index}>
1587
+ {row.map((cell, cellIndex) => (
1588
+ <td className="tabular" key={cellIndex}>
1589
+ {cell}
1590
+ </td>
1591
+ ))}
1592
+ </tr>
1593
+ ))}
1594
+ </tbody>
1595
+ </table>
1596
+ </div>
1597
+ </>
1598
+ );
1599
+ }
1600
+
1601
+ const MOCK_EVENTS = {
1602
+ columns: ["id", "event", "created_at"],
1603
+ rows: [
1604
+ ["1042", "app.viewed", "2026-07-03 14:02"],
1605
+ ["1041", "note.created", "2026-07-03 11:47"],
1606
+ ["1040", "file.uploaded", "2026-07-02 19:03"],
1607
+ ["1039", "note.created", "2026-07-02 16:21"],
1608
+ ["1038", "session.started", "2026-07-02 09:14"],
1609
+ ],
1610
+ };
1611
+
1612
+ const MOCK_CUSTOMERS = {
1613
+ columns: ["customer", "region", "lifetime_spend"],
1614
+ rows: [
1615
+ ["Meridian Foods", "emea", "$84,200"],
1616
+ ["Nordic Rail Co.", "emea", "$61,750"],
1617
+ ["Atlas Freight", "emea", "$58,900"],
1618
+ ["Solvane Group", "emea", "$52,100"],
1619
+ ],
1620
+ };
1621
+
1622
+ type LogoItem = {
1623
+ name: string;
1624
+ src?: string;
1625
+ };
1626
+
1627
+ function titleCase(value: string): string {
1628
+ return value
1629
+ .replace(/[-_]+/g, " ")
1630
+ .replace(/\\b\\w/g, (char) => char.toUpperCase())
1631
+ .trim();
1632
+ }
1633
+
1634
+ const LLM_LOGOS: LogoItem[] = [
1635
+ { name: "OpenAI", src: openaiLogo },
1636
+ { name: "Anthropic", src: anthropicLogo },
1637
+ { name: "Gemini", src: geminiLogo },
1638
+ { name: "Bedrock", src: bedrockLogo },
1639
+ ];
1640
+
1641
+ const DATA_LOGO_ITEMS: LogoItem[] = [
1642
+ { name: "postgres", src: postgresLogo },
1643
+ { name: "bigquery", src: bigqueryLogo },
1644
+ ];
1645
+
1646
+ const CONNECTOR_LOGO_ITEMS: LogoItem[] = [
1647
+ { name: "stripe", src: stripeLogo },
1648
+ { name: "posthog", src: posthogLogo },
1649
+ { name: "mixpanel", src: mixpanelLogo },
1650
+ { name: "resend", src: resendLogo },
1651
+ { name: "clerk", src: clerkLogo },
1652
+ ];
1653
+
1654
+ function logoInitials(name: string): string {
1655
+ const normalized = titleCase(name);
1656
+ const words = normalized.split(/\\s+/).filter(Boolean);
1657
+ if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase();
1658
+ return normalized.slice(0, 2).toUpperCase();
1659
+ }
1660
+
1661
+ function LogoMark({ item }: { item: LogoItem }) {
1662
+ return (
1663
+ <div className={item.src ? "logo-mark has-image" : "logo-mark"} title={titleCase(item.name)}>
1664
+ {item.src ? <img src={item.src} alt="" aria-hidden="true" /> : logoInitials(item.name)}
1665
+ </div>
1666
+ );
1667
+ }
1668
+
1669
+ function LogoStrip({ items, logoOnly }: { items: LogoItem[]; logoOnly?: boolean }) {
1670
+ return (
1671
+ <div className={logoOnly ? "logo-strip is-logo-only" : "logo-strip"} aria-label="Available services">
1672
+ {items.map((item, index) => (
1673
+ <div className="logo-chip" key={item.name + "-" + index}>
1674
+ <LogoMark item={item} />
1675
+ {logoOnly ? null : <span>{titleCase(item.name)}</span>}
1676
+ </div>
1677
+ ))}
1678
+ </div>
1679
+ );
1680
+ }
1681
+
1682
+ function DataCard() {
1683
+ return (
1684
+ <article className="card">
1685
+ <div className="card-head">
1686
+ <div>
1687
+ <div className="card-title-row">
1688
+ <Icon name="database" />
1689
+ <span className="card-title">
1690
+ <Reveal code="dataConnectors() · savedQueries()">Data</Reveal>
1691
+ </span>
1692
+ </div>
1693
+ </div>
1694
+ </div>
1695
+
1696
+ <LogoStrip items={DATA_LOGO_ITEMS} logoOnly />
1697
+
1698
+ <pre className="sql-preview">
1699
+ <CodeHighlight
1700
+ language="sql"
1701
+ code={\`-- Mock SQL preview
1702
+ select id, event, created_at
1703
+ from app_events
1704
+ order by created_at desc
1705
+ limit 5;\`}
1706
+ />
1707
+ </pre>
1708
+ <p className="mock-note">
1709
+ This is mock data. A coding agent can swap this for <code>data('name').runSQL(...)</code>
1710
+ once an admin connects a source.
1711
+ </p>
1712
+
1713
+ <ResultTable columns={MOCK_EVENTS.columns} rows={MOCK_EVENTS.rows} caption="mock response" />
1714
+ </article>
1715
+ );
1716
+ }
1717
+
1718
+ type ConnResult = { status: string; text: string } | null;
1719
+
1720
+ const DEFAULT_CONNECTOR_REQUEST = \`{
1721
+ "path": "/",
1722
+ "body": {}
1723
+ }\`;
1724
+
1725
+ function ConnectorsCard() {
1726
+ const { serviceConnectorList } = useAppStore();
1727
+ const [active, setActive] = useState(serviceConnectorList[0]?.name || "");
1728
+ const [method, setMethod] = useState("GET");
1729
+ const [requestJson, setRequestJson] = useState(DEFAULT_CONNECTOR_REQUEST);
1730
+ const [result, setResult] = useState<ConnResult>(null);
1731
+ const [busy, setBusy] = useState(false);
1732
+ const [requestError, setRequestError] = useState<string | null>(null);
1733
+
1734
+ const activeConnector = serviceConnectorList.find((item) => item.name === active);
1735
+ const methods = activeConnector?.allowed_methods?.length ? activeConnector.allowed_methods : ["GET", "POST"];
1736
+
1737
+ useEffect(() => {
1738
+ const next = serviceConnectorList[0]?.name || "";
1739
+ setActive(next);
1740
+ setMethod(serviceConnectorList[0]?.allowed_methods?.[0] || "GET");
1741
+ setResult(null);
1742
+ setRequestError(null);
1743
+ }, [serviceConnectorList]);
1744
+
1745
+ async function fetchActive() {
1746
+ if (!activeConnector) return;
1747
+ setBusy(true);
1748
+ setRequestError(null);
1749
+ try {
1750
+ const request = JSON.parse(requestJson) as { path?: unknown; body?: unknown };
1751
+ const path = typeof request.path === "string" && request.path.trim() ? request.path : "/";
1752
+ const body =
1753
+ method === "GET" || method === "HEAD"
1754
+ ? undefined
1755
+ : JSON.stringify(request.body ?? {});
1756
+ const response = await connector(active).fetch(path, { method, body });
1757
+ const text = await response.text();
1758
+ setResult({
1759
+ status: response.status + (response.ok ? " OK" : ""),
1760
+ text: truncate(text, 900) || "[empty response]",
1761
+ });
1762
+ } catch (error) {
1763
+ if (error instanceof SyntaxError) {
1764
+ setRequestError("Request JSON is not valid.");
1765
+ } else {
1766
+ setRequestError(message(error));
1767
+ }
1768
+ setResult(null);
1769
+ } finally {
1770
+ setBusy(false);
1771
+ }
1772
+ }
1773
+
1774
+ return (
1775
+ <article className="card">
1776
+ <div className="card-head">
1777
+ <div>
1778
+ <div className="card-title-row">
1779
+ <Icon name="link" />
1780
+ <span className="card-title">
1781
+ <Reveal code="await serviceConnectors()">Connectors</Reveal>
1782
+ </span>
1783
+ </div>
1784
+ </div>
1785
+ </div>
1786
+
1787
+ <LogoStrip items={CONNECTOR_LOGO_ITEMS} logoOnly />
1788
+
1789
+ {serviceConnectorList.length ? (
1790
+ <>
1791
+ <div className="request-grid">
1792
+ <label className="control-field">
1793
+ <span>Connector</span>
1794
+ <select
1795
+ value={active}
1796
+ onChange={(event) => {
1797
+ const next = serviceConnectorList.find((item) => item.name === event.target.value);
1798
+ setActive(event.target.value);
1799
+ setMethod(next?.allowed_methods?.[0] || "GET");
1800
+ setResult(null);
1801
+ setRequestError(null);
1802
+ }}
1803
+ >
1804
+ {serviceConnectorList.map((item) => (
1805
+ <option value={item.name} key={item.name}>
1806
+ {item.name}
1807
+ </option>
1808
+ ))}
1809
+ </select>
1810
+ </label>
1811
+ <label className="control-field">
1812
+ <span>Request type</span>
1813
+ <select value={method} onChange={(event) => setMethod(event.target.value)}>
1814
+ {methods.map((item) => (
1815
+ <option value={item} key={item}>
1816
+ {item}
1817
+ </option>
1818
+ ))}
1819
+ </select>
1820
+ </label>
1821
+ </div>
1822
+
1823
+ <label className="json-editor">
1824
+ <span>Request JSON</span>
1825
+ <textarea
1826
+ className="field"
1827
+ rows={5}
1828
+ spellCheck={false}
1829
+ value={requestJson}
1830
+ onChange={(event) => {
1831
+ setRequestJson(event.target.value);
1832
+ setRequestError(null);
1833
+ }}
1834
+ />
1835
+ </label>
1836
+
1837
+ {requestError ? <p className="field-error">{requestError}</p> : null}
1838
+
1839
+ <ActionTip code={\`await connector(name).fetch(path, {
1840
+ method,
1841
+ body
1842
+ })\`} className="upload-tip">
1843
+ <button className="btn btn-primary" type="button" disabled={busy} onClick={() => void fetchActive()}>
1844
+ {busy ? <span className="spinner" /> : <Icon name="link" />}
1845
+ {busy ? "Sending..." : "Send request"}
1846
+ </button>
1847
+ </ActionTip>
1848
+
1849
+ <div className="response-panel">
1850
+ <div className="result-status">
1851
+ <span>Response</span>
1852
+ <span>{result?.status || "Waiting"}</span>
1853
+ </div>
1854
+ <pre className="preview">{result?.text || "Send a request to see the connector response."}</pre>
1855
+ </div>
1856
+ </>
1857
+ ) : (
1858
+ <p className="empty-note admin-empty">Connect a service in the Admin dashboard to try connectors.</p>
1859
+ )}
1860
+ </article>
1861
+ );
1862
+ }
1863
+
1864
+ const AI_SAMPLE_PROMPT = "Summarize what this Railcode template demonstrates in one sentence.";
1865
+ const AI_SAMPLE_RESPONSE =
1866
+ "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.";
1867
+
1868
+ function AiCard() {
1869
+ const [prompt, setPrompt] = useState(AI_SAMPLE_PROMPT);
1870
+ const [response, setResponse] = useState("");
1871
+ const [live, setLive] = useState<boolean | null>(null);
1872
+ const [busy, setBusy] = useState(false);
1873
+ const reduceMotion = usePrefersReducedMotion();
1874
+
1875
+ function revealText(text: string) {
1876
+ if (reduceMotion) {
1877
+ setResponse(text);
1878
+ return;
1879
+ }
1880
+ let i = 0;
1881
+ const interval = setInterval(() => {
1882
+ i += 3;
1883
+ setResponse(text.slice(0, i));
1884
+ if (i >= text.length) clearInterval(interval);
1885
+ }, 12);
1886
+ }
1887
+
1888
+ async function generate() {
1889
+ if (live === false) return;
1890
+ const value = prompt.trim();
1891
+ if (!value) return;
1892
+ setBusy(true);
1893
+ setResponse("");
1894
+ try {
1895
+ const result = await llm.generate(value, { metadata: { feature: "starter-home" } });
1896
+ setLive(true);
1897
+ revealText(result.text || AI_SAMPLE_RESPONSE);
1898
+ } catch {
1899
+ setLive(false);
1900
+ revealText(AI_SAMPLE_RESPONSE);
1901
+ } finally {
1902
+ setBusy(false);
1903
+ }
1904
+ }
1905
+
1906
+ const note =
1907
+ live === true
1908
+ ? " "
1909
+ : live === false
1910
+ ? "No LLM connected yet — showing a sample response."
1911
+ : "Falls back to a sample response if no LLM is connected.";
1912
+
1913
+ return (
1914
+ <article className="card">
1915
+ <div className="card-head">
1916
+ <div>
1917
+ <div className="card-title-row">
1918
+ <Icon name="chat" />
1919
+ <span className="card-title">
1920
+ <Reveal code="await llm.generate(prompt, opts)">AI</Reveal>
1921
+ </span>
1922
+ </div>
1923
+ </div>
1924
+ </div>
1925
+
1926
+ <div className="ai-body">
1927
+ <LogoStrip items={LLM_LOGOS} logoOnly />
1928
+ <textarea
1929
+ className="field"
1930
+ rows={2}
1931
+ disabled={live === false}
1932
+ value={prompt}
1933
+ onChange={(event) => setPrompt(event.target.value)}
1934
+ />
1935
+ <div className="ai-foot-row">
1936
+ <span className="ai-note">{note}</span>
1937
+ <ActionTip code="await llm.generate(prompt, opts)">
1938
+ <button className="btn btn-primary" type="button" disabled={busy || live === false} onClick={() => void generate()}>
1939
+ {busy ? "Generating…" : "Generate"}
1940
+ </button>
1941
+ </ActionTip>
1942
+ </div>
1943
+ {response ? (
1944
+ <div className="ai-response is-visible">
1945
+ {response}
1946
+ {busy ? <span className="cursor" /> : null}
1947
+ </div>
1948
+ ) : null}
1949
+ </div>
1950
+ </article>
1951
+ );
1952
+ }
1953
+
1954
+ function IconSprite() {
1955
+ return (
1956
+ <svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
1957
+ <symbol id="i-sun" viewBox="0 0 24 24">
1958
+ <circle cx="12" cy="12" r="5" />
1959
+ <line x1="12" y1="1" x2="12" y2="3" />
1960
+ <line x1="12" y1="21" x2="12" y2="23" />
1961
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
1962
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
1963
+ <line x1="1" y1="12" x2="3" y2="12" />
1964
+ <line x1="21" y1="12" x2="23" y2="12" />
1965
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
1966
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
1967
+ </symbol>
1968
+ <symbol id="i-moon" viewBox="0 0 24 24">
1969
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
1970
+ </symbol>
1971
+ <symbol id="i-plus" viewBox="0 0 24 24">
1972
+ <line x1="12" y1="5" x2="12" y2="19" />
1973
+ <line x1="5" y1="12" x2="19" y2="12" />
1974
+ </symbol>
1975
+ <symbol id="i-x" viewBox="0 0 24 24">
1976
+ <line x1="18" y1="6" x2="6" y2="18" />
1977
+ <line x1="6" y1="6" x2="18" y2="18" />
1978
+ </symbol>
1979
+ <symbol id="i-check" viewBox="0 0 24 24">
1980
+ <polyline points="20 6 9 17 4 12" />
1981
+ </symbol>
1982
+ <symbol id="i-checklist" viewBox="0 0 24 24">
1983
+ <polyline points="9 11 12 14 22 4" />
1984
+ <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
1985
+ </symbol>
1986
+ <symbol id="i-upload" viewBox="0 0 24 24">
1987
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1988
+ <polyline points="17 8 12 3 7 8" />
1989
+ <line x1="12" y1="3" x2="12" y2="15" />
1990
+ </symbol>
1991
+ <symbol id="i-file" viewBox="0 0 24 24">
1992
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
1993
+ <polyline points="14 2 14 8 20 8" />
1994
+ </symbol>
1995
+ <symbol id="i-database" viewBox="0 0 24 24">
1996
+ <ellipse cx="12" cy="5" rx="9" ry="3" />
1997
+ <path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" />
1998
+ <path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
1999
+ </symbol>
2000
+ <symbol id="i-link" viewBox="0 0 24 24">
2001
+ <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" />
2002
+ <line x1="8" y1="12" x2="16" y2="12" />
2003
+ </symbol>
2004
+ <symbol id="i-chat" viewBox="0 0 24 24">
2005
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
2006
+ </symbol>
2007
+ <symbol id="i-code" viewBox="0 0 24 24">
2008
+ <polyline points="16 18 22 12 16 6" />
2009
+ <polyline points="8 6 2 12 8 18" />
2010
+ </symbol>
2011
+ </svg>
2012
+ );
2013
+ }
2014
+
2015
+ export function App() {
2016
+ const state = useAppStore();
2017
+
2018
+ useEffect(() => {
2019
+ void state.load();
2020
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2021
+ }, []);
2022
+
2023
+ return (
2024
+ <>
2025
+ <IconSprite />
2026
+ <header className="topbar">
2027
+ <div className="topbar-inner">
2028
+ <div className="brand">
2029
+ <div className="brand-text">
2030
+ <span className="brand-app">{APP_SLUG}</span>
2031
+ </div>
2032
+ </div>
2033
+ <ThemeToggle />
2034
+ </div>
2035
+ </header>
2036
+
2037
+ <div className="shell">
2038
+ {state.error ? (
2039
+ <div className="error-banner">
2040
+ <span>{state.error}</span>
2041
+ <button type="button" onClick={state.clearError}>
2042
+ Dismiss
2043
+ </button>
2044
+ </div>
2045
+ ) : null}
2046
+
2047
+ <Hero />
2048
+
2049
+ <div className="guide-flow">
2050
+ <GuideSection
2051
+ title="Railcode apps can store data"
2052
+ 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."
2053
+ >
2054
+ <TodosCard />
2055
+ </GuideSection>
2056
+
2057
+ <GuideSection
2058
+ title="They can store files too"
2059
+ 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."
2060
+ >
2061
+ <FilesCard />
2062
+ </GuideSection>
2063
+
2064
+ <GuideSection
2065
+ title="They can query data sources"
2066
+ body="Connect all your data sources and build interactive visualizations, run integrated analyses, or build tools to manage operations."
2067
+ >
2068
+ <DataCard />
2069
+ </GuideSection>
2070
+
2071
+ <GuideSection
2072
+ title="They can connect to external services"
2073
+ body="Railcode apps can securely connect to your SaaS services so you can manage refunds, send emails, view data, and anything else you need."
2074
+ >
2075
+ <ConnectorsCard />
2076
+ </GuideSection>
2077
+
2078
+ <GuideSection
2079
+ title="They can use AI"
2080
+ body="Build AI-native apps that use LLMs to enable powerful workflows."
2081
+ >
2082
+ <AiCard />
2083
+ </GuideSection>
2084
+
2085
+ </div>
2086
+
2087
+ <footer>
2088
+ <span>
2089
+ Scaffolded with <code>railcode init --template react</code>
2090
+ </span>
2091
+ </footer>
2092
+ </div>
2093
+ </>
2094
+ );
2095
+ }
2096
+ `;
2097
+ }
2098
+ function reactStylesCss() {
2099
+ return `:root {
2100
+ --bg: #ffffff;
2101
+ --bg-inset: #f5f6f8;
2102
+ --card: #ffffff;
2103
+ --ink-1: #1a1f36;
2104
+ --ink-2: #4b5563;
2105
+ --ink-3: #687385;
2106
+ --ink-4: #9aa3b2;
2107
+ --line-quiet: rgba(26, 31, 54, 0.05);
2108
+ --line: rgba(26, 31, 54, 0.1);
2109
+ --line-strong: rgba(26, 31, 54, 0.2);
2110
+ --primary: #2563eb;
2111
+ --primary-ink: #ffffff;
2112
+ --primary-wash: rgba(37, 99, 235, 0.08);
2113
+ --solid: #1a1f36;
2114
+ --solid-ink: #ffffff;
2115
+ --ring: #2563eb;
2116
+ --code-bg: #161b2c;
2117
+ --code-ink: #d7deed;
2118
+ --code-keyword: #c4b5fd;
2119
+ --code-call: #7dd3fc;
2120
+ --code-string: #86efac;
2121
+ --code-number: #fbbf67;
2122
+ --code-punct: rgba(215, 222, 237, 0.5);
2123
+ --radius-sm: 4px;
2124
+ --radius-md: 6px;
2125
+ --radius-lg: 8px;
2126
+ --radius-xl: 12px;
2127
+ --space-2: 8px;
2128
+ --space-3: 12px;
2129
+ --space-4: 16px;
2130
+ --space-5: 20px;
2131
+ --space-6: 24px;
2132
+ --space-10: 40px;
2133
+ --space-12: 48px;
2134
+ --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
2135
+ --font-mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
2136
+ color-scheme: light;
2137
+ }
2138
+
2139
+ @media (prefers-color-scheme: dark) {
2140
+ :root {
2141
+ --bg: #18181b;
2142
+ --bg-inset: #232327;
2143
+ --card: #1f1f23;
2144
+ --ink-1: #ededee;
2145
+ --ink-2: #c2c2c8;
2146
+ --ink-3: #9b9ba3;
2147
+ --ink-4: #6b6b73;
2148
+ --line-quiet: rgba(255, 255, 255, 0.05);
2149
+ --line: rgba(255, 255, 255, 0.09);
2150
+ --line-strong: rgba(255, 255, 255, 0.16);
2151
+ --primary: #60a5fa;
2152
+ --primary-ink: #0f1115;
2153
+ --primary-wash: rgba(96, 165, 250, 0.12);
2154
+ --solid: #ededee;
2155
+ --solid-ink: #18181b;
2156
+ --ring: #60a5fa;
2157
+ color-scheme: dark;
2158
+ }
2159
+ }
2160
+
2161
+ :root[data-theme="dark"] {
2162
+ --bg: #18181b;
2163
+ --bg-inset: #232327;
2164
+ --card: #1f1f23;
2165
+ --ink-1: #ededee;
2166
+ --ink-2: #c2c2c8;
2167
+ --ink-3: #9b9ba3;
2168
+ --ink-4: #6b6b73;
2169
+ --line-quiet: rgba(255, 255, 255, 0.05);
2170
+ --line: rgba(255, 255, 255, 0.09);
2171
+ --line-strong: rgba(255, 255, 255, 0.16);
2172
+ --primary: #60a5fa;
2173
+ --primary-ink: #0f1115;
2174
+ --primary-wash: rgba(96, 165, 250, 0.12);
2175
+ --solid: #ededee;
2176
+ --solid-ink: #18181b;
2177
+ --ring: #60a5fa;
2178
+ color-scheme: dark;
2179
+ }
2180
+
2181
+ :root[data-theme="light"] {
2182
+ --bg: #ffffff;
2183
+ --bg-inset: #f5f6f8;
2184
+ --card: #ffffff;
2185
+ --ink-1: #1a1f36;
2186
+ --ink-2: #4b5563;
2187
+ --ink-3: #687385;
2188
+ --ink-4: #9aa3b2;
2189
+ --line-quiet: rgba(26, 31, 54, 0.05);
2190
+ --line: rgba(26, 31, 54, 0.1);
2191
+ --line-strong: rgba(26, 31, 54, 0.2);
2192
+ --primary: #2563eb;
2193
+ --primary-ink: #ffffff;
2194
+ --primary-wash: rgba(37, 99, 235, 0.08);
2195
+ --solid: #1a1f36;
2196
+ --solid-ink: #ffffff;
2197
+ --ring: #2563eb;
2198
+ color-scheme: light;
2199
+ }
2200
+
2201
+ * {
2202
+ box-sizing: border-box;
2203
+ }
2204
+
2205
+ body {
2206
+ margin: 0;
2207
+ min-width: 320px;
2208
+ background: var(--bg);
2209
+ color: var(--ink-1);
2210
+ font-family: var(--font-sans);
2211
+ font-size: 14px;
2212
+ line-height: 1.5;
2213
+ -webkit-font-smoothing: antialiased;
2214
+ }
2215
+
2216
+ h1,
2217
+ h2,
2218
+ h3,
2219
+ p {
2220
+ margin: 0;
2221
+ }
2222
+
2223
+ button,
2224
+ input,
2225
+ textarea {
2226
+ font: inherit;
2227
+ color: inherit;
2228
+ }
2229
+
2230
+ button {
2231
+ cursor: pointer;
2232
+ border: 0;
2233
+ background: transparent;
2234
+ padding: 0;
2235
+ }
2236
+
2237
+ button:disabled {
2238
+ cursor: not-allowed;
2239
+ opacity: 0.5;
2240
+ }
2241
+
2242
+ :focus-visible {
2243
+ outline: 2px solid var(--ring);
2244
+ outline-offset: 2px;
2245
+ }
2246
+
2247
+ .tabular {
2248
+ font-variant-numeric: tabular-nums;
2249
+ }
2250
+
2251
+ .icon {
2252
+ width: 16px;
2253
+ height: 16px;
2254
+ stroke: currentColor;
2255
+ fill: none;
2256
+ stroke-width: 1.8;
2257
+ stroke-linecap: round;
2258
+ stroke-linejoin: round;
2259
+ flex-shrink: 0;
2260
+ }
2261
+
2262
+ .shell {
2263
+ max-width: 1180px;
2264
+ margin: 0 auto;
2265
+ padding: 0 24px 96px;
2266
+ }
2267
+
2268
+ /* ---------- topbar ---------- */
2269
+
2270
+ .topbar {
2271
+ border-bottom: 1px solid var(--line);
2272
+ margin-bottom: var(--space-10);
2273
+ }
2274
+
2275
+ .topbar-inner {
2276
+ max-width: 1180px;
2277
+ margin: 0 auto;
2278
+ padding: 18px 24px;
2279
+ display: flex;
2280
+ align-items: center;
2281
+ justify-content: space-between;
2282
+ gap: var(--space-4);
2283
+ }
2284
+
2285
+ .brand {
2286
+ min-width: 0;
2287
+ }
2288
+
2289
+ .brand-text {
2290
+ display: flex;
2291
+ align-items: baseline;
2292
+ gap: 8px;
2293
+ min-width: 0;
2294
+ font-size: 13px;
2295
+ }
2296
+
2297
+ .brand-app {
2298
+ font-weight: 600;
2299
+ color: var(--ink-1);
2300
+ font-family: var(--font-mono);
2301
+ white-space: nowrap;
2302
+ overflow: hidden;
2303
+ text-overflow: ellipsis;
2304
+ }
2305
+
2306
+ .theme-toggle {
2307
+ width: 32px;
2308
+ height: 32px;
2309
+ border-radius: var(--radius-md);
2310
+ border: 1px solid var(--line);
2311
+ background: transparent;
2312
+ display: grid;
2313
+ place-items: center;
2314
+ color: var(--ink-3);
2315
+ transition:
2316
+ background-color 120ms ease,
2317
+ color 120ms ease;
2318
+ }
2319
+
2320
+ .theme-toggle:hover {
2321
+ background: var(--bg-inset);
2322
+ color: var(--ink-1);
2323
+ }
2324
+
2325
+ .theme-toggle .icon-moon {
2326
+ display: none;
2327
+ }
2328
+ :root[data-theme="dark"] .theme-toggle .icon-sun {
2329
+ display: none;
2330
+ }
2331
+ :root[data-theme="dark"] .theme-toggle .icon-moon {
2332
+ display: block;
2333
+ }
2334
+ @media (prefers-color-scheme: dark) {
2335
+ :root:not([data-theme="light"]) .theme-toggle .icon-sun {
2336
+ display: none;
2337
+ }
2338
+ :root:not([data-theme="light"]) .theme-toggle .icon-moon {
2339
+ display: block;
2340
+ }
2341
+ }
2342
+
2343
+ /* ---------- hero ---------- */
2344
+
2345
+ .hero {
2346
+ margin-bottom: var(--space-12);
2347
+ }
2348
+
2349
+ .hero h1 {
2350
+ font-size: 30px;
2351
+ font-weight: 650;
2352
+ letter-spacing: -0.01em;
2353
+ line-height: 1.2;
2354
+ text-wrap: balance;
2355
+ }
2356
+
2357
+ .hero-sub {
2358
+ margin-top: var(--space-2);
2359
+ color: var(--ink-3);
2360
+ font-size: 15px;
2361
+ max-width: 72ch;
2362
+ }
2363
+
2364
+ .team-row {
2365
+ margin-top: var(--space-6);
2366
+ display: flex;
2367
+ align-items: center;
2368
+ gap: var(--space-3);
2369
+ }
2370
+
2371
+ .avatar-stack {
2372
+ display: flex;
2373
+ }
2374
+
2375
+ .avatar {
2376
+ width: 28px;
2377
+ height: 28px;
2378
+ border-radius: 50%;
2379
+ border: 2px solid var(--bg);
2380
+ background: var(--bg-inset);
2381
+ color: var(--ink-2);
2382
+ display: grid;
2383
+ place-items: center;
2384
+ font-size: 11px;
2385
+ font-weight: 650;
2386
+ margin-left: -6px;
2387
+ }
2388
+
2389
+ .avatar:first-child {
2390
+ margin-left: 0;
2391
+ }
2392
+
2393
+ .avatar.is-you {
2394
+ background: var(--primary-wash);
2395
+ color: var(--primary);
2396
+ box-shadow: 0 0 0 1px var(--primary) inset;
2397
+ }
2398
+
2399
+ .team-caption {
2400
+ color: var(--ink-4);
2401
+ font-size: 13px;
2402
+ }
2403
+
2404
+ /* ---------- guide flow ---------- */
2405
+
2406
+ .guide-flow {
2407
+ display: flex;
2408
+ flex-direction: column;
2409
+ gap: var(--space-12);
2410
+ }
2411
+
2412
+ .guide-section {
2413
+ display: grid;
2414
+ grid-template-columns: minmax(0, 0.72fr) minmax(420px, 1fr);
2415
+ gap: var(--space-10);
2416
+ align-items: start;
2417
+ padding-top: var(--space-10);
2418
+ border-top: 1px solid var(--line);
2419
+ }
2420
+
2421
+ .guide-section:first-child {
2422
+ padding-top: 0;
2423
+ border-top: 0;
2424
+ }
2425
+
2426
+ .guide-copy {
2427
+ position: sticky;
2428
+ top: 24px;
2429
+ }
2430
+
2431
+ .guide-copy h2 {
2432
+ font-size: 22px;
2433
+ font-weight: 650;
2434
+ line-height: 1.25;
2435
+ text-wrap: balance;
2436
+ }
2437
+
2438
+ .guide-copy p {
2439
+ margin-top: var(--space-3);
2440
+ color: var(--ink-3);
2441
+ font-size: 14px;
2442
+ line-height: 1.65;
2443
+ max-width: 42ch;
2444
+ }
2445
+
2446
+ .guide-demo {
2447
+ min-width: 0;
2448
+ }
2449
+
2450
+ /* ---------- card ---------- */
2451
+
2452
+ .card {
2453
+ background: var(--card);
2454
+ border: 1px solid var(--line);
2455
+ border-radius: var(--radius-xl);
2456
+ padding: var(--space-5);
2457
+ display: flex;
2458
+ flex-direction: column;
2459
+ opacity: 0;
2460
+ transform: translateY(6px);
2461
+ animation: rise 420ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
2462
+ }
2463
+
2464
+ .card:nth-of-type(1) {
2465
+ animation-delay: 40ms;
2466
+ }
2467
+ .card:nth-of-type(2) {
2468
+ animation-delay: 80ms;
2469
+ }
2470
+ .card:nth-of-type(3) {
2471
+ animation-delay: 120ms;
2472
+ }
2473
+
2474
+ @keyframes rise {
2475
+ to {
2476
+ opacity: 1;
2477
+ transform: translateY(0);
2478
+ }
2479
+ }
2480
+
2481
+ .card-head {
2482
+ display: flex;
2483
+ align-items: flex-start;
2484
+ justify-content: space-between;
2485
+ gap: var(--space-3);
2486
+ margin-bottom: var(--space-4);
2487
+ }
2488
+
2489
+ .card-title-row {
2490
+ display: flex;
2491
+ align-items: center;
2492
+ gap: 8px;
2493
+ }
2494
+
2495
+ .card-title-row .icon {
2496
+ color: var(--ink-3);
2497
+ }
2498
+
2499
+ .card-title {
2500
+ font-size: 14px;
2501
+ font-weight: 650;
2502
+ }
2503
+
2504
+ .pill {
2505
+ flex: none;
2506
+ display: inline-flex;
2507
+ align-items: center;
2508
+ gap: 5px;
2509
+ padding: 3px 9px;
2510
+ border-radius: 100px;
2511
+ font-size: 11px;
2512
+ font-weight: 650;
2513
+ letter-spacing: 0.01em;
2514
+ white-space: nowrap;
2515
+ }
2516
+
2517
+ /* ---------- buttons & inputs ---------- */
2518
+
2519
+ .btn {
2520
+ display: inline-flex;
2521
+ align-items: center;
2522
+ justify-content: center;
2523
+ gap: 6px;
2524
+ border-radius: var(--radius-md);
2525
+ border: 1px solid transparent;
2526
+ padding: 8px 13px;
2527
+ font-size: 13px;
2528
+ font-weight: 600;
2529
+ transition:
2530
+ background-color 120ms ease,
2531
+ border-color 120ms ease,
2532
+ color 120ms ease,
2533
+ transform 80ms ease;
2534
+ white-space: nowrap;
2535
+ }
2536
+
2537
+ .btn:active {
2538
+ transform: scale(0.98);
2539
+ }
2540
+
2541
+ .btn-primary {
2542
+ background: var(--primary);
2543
+ color: var(--primary-ink);
2544
+ }
2545
+
2546
+ .btn-primary:hover {
2547
+ filter: brightness(1.06);
2548
+ }
2549
+
2550
+ .btn-ghost {
2551
+ background: transparent;
2552
+ border-color: var(--line);
2553
+ color: var(--ink-2);
2554
+ }
2555
+
2556
+ .btn-ghost:hover {
2557
+ background: var(--bg-inset);
2558
+ border-color: var(--line-strong);
2559
+ }
2560
+
2561
+ .btn-ghost.is-active {
2562
+ background: var(--primary-wash);
2563
+ border-color: var(--primary);
2564
+ color: var(--primary);
2565
+ }
2566
+
2567
+ .field {
2568
+ flex: 1;
2569
+ background: var(--bg-inset);
2570
+ border: 1px solid transparent;
2571
+ border-radius: var(--radius-md);
2572
+ padding: 8px 11px;
2573
+ font-size: 13px;
2574
+ color: var(--ink-1);
2575
+ }
2576
+
2577
+ .field::placeholder {
2578
+ color: var(--ink-4);
2579
+ }
2580
+
2581
+ .field:focus {
2582
+ background: var(--card);
2583
+ border-color: var(--primary);
2584
+ }
2585
+
2586
+ textarea.field {
2587
+ resize: vertical;
2588
+ min-height: 64px;
2589
+ line-height: 1.5;
2590
+ width: 100%;
2591
+ }
2592
+
2593
+ .chip-row {
2594
+ display: flex;
2595
+ flex-wrap: wrap;
2596
+ gap: 8px;
2597
+ }
2598
+
2599
+ /* ---------- logos ---------- */
2600
+
2601
+ .logo-strip {
2602
+ display: flex;
2603
+ flex-wrap: wrap;
2604
+ gap: 8px;
2605
+ margin-bottom: var(--space-4);
2606
+ }
2607
+
2608
+ .logo-chip {
2609
+ min-width: 0;
2610
+ display: inline-flex;
2611
+ align-items: center;
2612
+ gap: 7px;
2613
+ padding: 5px 9px 5px 6px;
2614
+ border: 1px solid var(--line);
2615
+ border-radius: var(--radius-md);
2616
+ color: var(--ink-2);
2617
+ background: var(--bg);
2618
+ font-size: 12px;
2619
+ font-weight: 600;
2620
+ }
2621
+
2622
+ .logo-chip span {
2623
+ white-space: nowrap;
2624
+ }
2625
+
2626
+ .logo-strip.is-logo-only {
2627
+ gap: 10px;
2628
+ }
2629
+
2630
+ .logo-strip.is-logo-only .logo-chip {
2631
+ padding: 0;
2632
+ border: 0;
2633
+ background: transparent;
2634
+ }
2635
+
2636
+ .logo-strip.is-logo-only .logo-mark {
2637
+ width: 30px;
2638
+ height: 30px;
2639
+ }
2640
+
2641
+ .logo-strip.is-logo-only .logo-mark.has-image {
2642
+ border: 0;
2643
+ padding: 3px;
2644
+ }
2645
+
2646
+ .logo-mark {
2647
+ width: 24px;
2648
+ height: 24px;
2649
+ border-radius: var(--radius-sm);
2650
+ display: grid;
2651
+ place-items: center;
2652
+ background: var(--solid);
2653
+ color: var(--solid-ink);
2654
+ font-family: var(--font-mono);
2655
+ font-size: 9px;
2656
+ font-weight: 750;
2657
+ letter-spacing: 0;
2658
+ overflow: hidden;
2659
+ }
2660
+
2661
+ .logo-mark.has-image {
2662
+ background: #fff;
2663
+ border: 1px solid var(--line);
2664
+ padding: 4px;
2665
+ }
2666
+
2667
+ .logo-mark img {
2668
+ display: block;
2669
+ width: 100%;
2670
+ height: 100%;
2671
+ object-fit: contain;
2672
+ }
2673
+
2674
+ /* ---------- to-dos ---------- */
2675
+
2676
+ .add-row {
2677
+ display: flex;
2678
+ gap: 8px;
2679
+ margin-bottom: var(--space-4);
2680
+ }
2681
+
2682
+ .todo-list {
2683
+ display: flex;
2684
+ flex-direction: column;
2685
+ gap: 2px;
2686
+ }
2687
+
2688
+ .todo-row {
2689
+ display: flex;
2690
+ align-items: flex-start;
2691
+ gap: 10px;
2692
+ padding: 9px 6px;
2693
+ border-radius: var(--radius-md);
2694
+ }
2695
+
2696
+ .todo-row:hover {
2697
+ background: var(--bg-inset);
2698
+ }
2699
+
2700
+ .checkbox {
2701
+ padding: 0;
2702
+ margin-top: 1px;
2703
+ width: 17px;
2704
+ height: 17px;
2705
+ border-radius: 5px;
2706
+ border: 1.5px solid var(--line-strong);
2707
+ background: var(--card);
2708
+ display: grid;
2709
+ place-items: center;
2710
+ flex: none;
2711
+ color: transparent;
2712
+ transition:
2713
+ background-color 120ms ease,
2714
+ border-color 120ms ease;
2715
+ }
2716
+
2717
+ .checkbox .icon {
2718
+ width: 11px;
2719
+ height: 11px;
2720
+ stroke-width: 3;
2721
+ pointer-events: none;
2722
+ }
2723
+
2724
+ .todo-row.is-done .checkbox {
2725
+ background: var(--solid);
2726
+ border-color: var(--solid);
2727
+ color: var(--solid-ink);
2728
+ }
2729
+
2730
+ .todo-body {
2731
+ flex: 1;
2732
+ min-width: 0;
2733
+ }
2734
+
2735
+ .todo-text {
2736
+ font-size: 13.5px;
2737
+ word-break: break-word;
2738
+ }
2739
+
2740
+ .todo-row.is-done .todo-text {
2741
+ color: var(--ink-4);
2742
+ text-decoration: line-through;
2743
+ }
2744
+
2745
+ .todo-meta {
2746
+ margin-top: 2px;
2747
+ color: var(--ink-4);
2748
+ font-size: 12px;
2749
+ }
2750
+
2751
+ .row-delete {
2752
+ flex: none;
2753
+ width: 24px;
2754
+ height: 24px;
2755
+ border-radius: var(--radius-sm);
2756
+ display: grid;
2757
+ place-items: center;
2758
+ color: var(--ink-4);
2759
+ opacity: 0.55;
2760
+ transition:
2761
+ opacity 120ms ease,
2762
+ background-color 120ms ease,
2763
+ color 120ms ease;
2764
+ }
2765
+
2766
+ .todo-row:hover .row-delete,
2767
+ .todo-row:focus-within .row-delete,
2768
+ .file-row:hover .row-delete,
2769
+ .file-row:focus-within .row-delete {
2770
+ opacity: 1;
2771
+ }
2772
+
2773
+ .row-delete:hover {
2774
+ background: var(--bg-inset);
2775
+ color: var(--ink-1);
2776
+ }
2777
+
2778
+ .row-delete .icon {
2779
+ width: 14px;
2780
+ height: 14px;
2781
+ pointer-events: none;
2782
+ }
2783
+
2784
+ .empty-note {
2785
+ color: var(--ink-4);
2786
+ font-size: 12.5px;
2787
+ padding: 10px 6px;
2788
+ }
2789
+
2790
+ /* ---------- files ---------- */
2791
+
2792
+ .file-list {
2793
+ display: flex;
2794
+ flex-direction: column;
2795
+ gap: 2px;
2796
+ margin-bottom: var(--space-4);
2797
+ }
2798
+
2799
+ .file-row {
2800
+ display: flex;
2801
+ align-items: center;
2802
+ gap: 10px;
2803
+ padding: 8px 6px;
2804
+ border-radius: var(--radius-md);
2805
+ }
2806
+
2807
+ .file-row:hover {
2808
+ background: var(--bg-inset);
2809
+ }
2810
+
2811
+ .file-icon {
2812
+ width: 28px;
2813
+ height: 28px;
2814
+ border-radius: var(--radius-sm);
2815
+ background: var(--bg-inset);
2816
+ display: grid;
2817
+ place-items: center;
2818
+ color: var(--ink-3);
2819
+ flex: none;
2820
+ }
2821
+
2822
+ .file-icon .icon {
2823
+ width: 14px;
2824
+ height: 14px;
2825
+ }
2826
+
2827
+ .file-body {
2828
+ flex: 1;
2829
+ min-width: 0;
2830
+ }
2831
+
2832
+ .file-name {
2833
+ display: block;
2834
+ font-size: 13px;
2835
+ font-weight: 550;
2836
+ color: var(--ink-1);
2837
+ text-decoration: none;
2838
+ white-space: nowrap;
2839
+ overflow: hidden;
2840
+ text-overflow: ellipsis;
2841
+ }
2842
+
2843
+ .file-name:hover {
2844
+ text-decoration: underline;
2845
+ }
2846
+
2847
+ .file-meta {
2848
+ color: var(--ink-4);
2849
+ font-size: 11.5px;
2850
+ font-family: var(--font-mono);
2851
+ }
2852
+
2853
+ .file-input-hidden {
2854
+ display: none;
2855
+ }
2856
+
2857
+ .upload-btn {
2858
+ margin-top: auto;
2859
+ align-self: flex-start;
2860
+ }
2861
+
2862
+ /* ---------- data / connectors result areas ---------- */
2863
+
2864
+ .result-area {
2865
+ margin-top: var(--space-4);
2866
+ min-height: 22px;
2867
+ }
2868
+
2869
+ .result-placeholder {
2870
+ color: var(--ink-4);
2871
+ font-size: 12.5px;
2872
+ padding-top: 2px;
2873
+ }
2874
+
2875
+ .sql-preview {
2876
+ margin: 0 0 var(--space-3);
2877
+ background: var(--bg-inset);
2878
+ border-radius: var(--radius-md);
2879
+ padding: 11px 12px;
2880
+ font-family: var(--font-mono);
2881
+ font-size: 12px;
2882
+ line-height: 1.55;
2883
+ color: var(--ink-2);
2884
+ white-space: pre-wrap;
2885
+ overflow-x: auto;
2886
+ }
2887
+
2888
+ .mock-note {
2889
+ color: var(--ink-4);
2890
+ font-size: 12.5px;
2891
+ line-height: 1.55;
2892
+ margin-bottom: var(--space-4);
2893
+ }
2894
+
2895
+ .mock-note code {
2896
+ font-family: var(--font-mono);
2897
+ color: var(--ink-2);
2898
+ }
2899
+
2900
+ .request-grid {
2901
+ display: grid;
2902
+ grid-template-columns: minmax(0, 1fr) minmax(120px, 0.42fr);
2903
+ gap: var(--space-3);
2904
+ margin-bottom: var(--space-3);
2905
+ }
2906
+
2907
+ .control-field,
2908
+ .json-editor {
2909
+ display: flex;
2910
+ flex-direction: column;
2911
+ gap: 6px;
2912
+ min-width: 0;
2913
+ }
2914
+
2915
+ .control-field span,
2916
+ .json-editor span {
2917
+ color: var(--ink-4);
2918
+ font-size: 11.5px;
2919
+ font-weight: 650;
2920
+ }
2921
+
2922
+ .control-field select {
2923
+ width: 100%;
2924
+ min-height: 36px;
2925
+ border: 1px solid var(--line);
2926
+ border-radius: var(--radius-md);
2927
+ background: var(--bg-inset);
2928
+ color: var(--ink-1);
2929
+ padding: 7px 10px;
2930
+ font: inherit;
2931
+ font-size: 13px;
2932
+ }
2933
+
2934
+ .json-editor {
2935
+ margin-bottom: var(--space-3);
2936
+ }
2937
+
2938
+ .json-editor textarea {
2939
+ font-family: var(--font-mono);
2940
+ font-size: 12px;
2941
+ min-height: 124px;
2942
+ }
2943
+
2944
+ .field-error {
2945
+ color: #b42318;
2946
+ font-size: 12.5px;
2947
+ margin-bottom: var(--space-3);
2948
+ }
2949
+
2950
+ .response-panel {
2951
+ margin-top: var(--space-4);
2952
+ }
2953
+
2954
+ .admin-empty {
2955
+ padding: var(--space-4);
2956
+ border: 1px solid var(--line);
2957
+ border-radius: var(--radius-md);
2958
+ background: var(--bg-inset);
2959
+ }
2960
+
2961
+ .table-wrap {
2962
+ overflow-x: auto;
2963
+ border: 1px solid var(--line);
2964
+ border-radius: var(--radius-md);
2965
+ }
2966
+
2967
+ table {
2968
+ width: 100%;
2969
+ border-collapse: collapse;
2970
+ font-size: 12.5px;
2971
+ }
2972
+
2973
+ th,
2974
+ td {
2975
+ text-align: left;
2976
+ padding: 7px 10px;
2977
+ white-space: nowrap;
2978
+ }
2979
+
2980
+ th {
2981
+ background: var(--bg-inset);
2982
+ color: var(--ink-4);
2983
+ font-weight: 650;
2984
+ font-size: 10.5px;
2985
+ text-transform: uppercase;
2986
+ letter-spacing: 0.04em;
2987
+ }
2988
+
2989
+ td {
2990
+ color: var(--ink-2);
2991
+ font-family: var(--font-mono);
2992
+ border-top: 1px solid var(--line);
2993
+ }
2994
+ td:first-child,
2995
+ th:first-child {
2996
+ color: var(--ink-1);
2997
+ }
2998
+
2999
+ .result-status {
3000
+ display: flex;
3001
+ align-items: center;
3002
+ justify-content: space-between;
3003
+ gap: 8px;
3004
+ margin-bottom: 8px;
3005
+ font-size: 11.5px;
3006
+ color: var(--ink-4);
3007
+ }
3008
+
3009
+ .conn-list {
3010
+ display: flex;
3011
+ flex-direction: column;
3012
+ gap: 2px;
3013
+ border: 1px solid var(--line);
3014
+ border-radius: var(--radius-md);
3015
+ overflow: hidden;
3016
+ }
3017
+
3018
+ .conn-row {
3019
+ display: flex;
3020
+ align-items: center;
3021
+ justify-content: space-between;
3022
+ gap: 10px;
3023
+ padding: 8px 10px;
3024
+ border-top: 1px solid var(--line);
3025
+ }
3026
+
3027
+ .conn-row:first-child {
3028
+ border-top: none;
3029
+ }
3030
+
3031
+ .conn-row-main {
3032
+ min-width: 0;
3033
+ }
3034
+
3035
+ .conn-row-title {
3036
+ font-size: 12.5px;
3037
+ font-weight: 600;
3038
+ white-space: nowrap;
3039
+ overflow: hidden;
3040
+ text-overflow: ellipsis;
3041
+ }
3042
+
3043
+ .conn-row-sub {
3044
+ font-size: 11.5px;
3045
+ color: var(--ink-4);
3046
+ font-family: var(--font-mono);
3047
+ white-space: nowrap;
3048
+ overflow: hidden;
3049
+ text-overflow: ellipsis;
3050
+ }
3051
+
3052
+ .conn-row-tag {
3053
+ flex: none;
3054
+ font-size: 11px;
3055
+ color: var(--ink-3);
3056
+ font-family: var(--font-mono);
3057
+ }
3058
+
3059
+ .preview {
3060
+ margin-top: 4px;
3061
+ background: var(--bg-inset);
3062
+ border-radius: var(--radius-md);
3063
+ padding: 10px 12px;
3064
+ font-family: var(--font-mono);
3065
+ font-size: 12px;
3066
+ line-height: 1.55;
3067
+ white-space: pre-wrap;
3068
+ word-break: break-word;
3069
+ max-height: 200px;
3070
+ overflow: auto;
3071
+ color: var(--ink-2);
3072
+ }
3073
+
3074
+ .loading-row {
3075
+ display: flex;
3076
+ align-items: center;
3077
+ gap: 8px;
3078
+ color: var(--ink-4);
3079
+ font-size: 12.5px;
3080
+ padding: 4px 0;
3081
+ }
3082
+
3083
+ .spinner {
3084
+ width: 12px;
3085
+ height: 12px;
3086
+ border-radius: 50%;
3087
+ border: 1.6px solid var(--line-strong);
3088
+ border-top-color: var(--ink-3);
3089
+ animation: spin 700ms linear infinite;
3090
+ }
3091
+
3092
+ @keyframes spin {
3093
+ to {
3094
+ transform: rotate(360deg);
3095
+ }
3096
+ }
3097
+
3098
+ /* ---------- reveal + action tooltips ---------- */
3099
+
3100
+ .reveal {
3101
+ position: relative;
3102
+ display: inline;
3103
+ background: var(--primary-wash);
3104
+ border-radius: var(--radius-sm);
3105
+ padding: 1px 6px;
3106
+ margin: 0 -2px;
3107
+ cursor: help;
3108
+ -webkit-tap-highlight-color: transparent;
3109
+ transition: background-color 120ms ease;
3110
+ }
3111
+
3112
+ .reveal:hover,
3113
+ .reveal:focus-visible {
3114
+ background: color-mix(in srgb, var(--primary-wash) 55%, var(--primary) 22%);
3115
+ }
3116
+
3117
+ .reveal-tip {
3118
+ position: absolute;
3119
+ left: 50%;
3120
+ bottom: calc(100% + 9px);
3121
+ transform: translateX(-50%) translateY(4px);
3122
+ background: var(--code-bg);
3123
+ color: var(--code-ink);
3124
+ font-family: var(--font-mono);
3125
+ font-size: 11.5px;
3126
+ white-space: pre-wrap;
3127
+ text-align: left;
3128
+ max-width: min(420px, calc(100vw - 24px));
3129
+ width: max-content;
3130
+ line-height: 1.5;
3131
+ padding: 7px 10px;
3132
+ border-radius: var(--radius-md);
3133
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
3134
+ opacity: 0;
3135
+ visibility: hidden;
3136
+ pointer-events: none;
3137
+ transition:
3138
+ opacity 140ms ease,
3139
+ transform 140ms ease;
3140
+ z-index: 20;
3141
+ }
3142
+
3143
+ .reveal-tip::after {
3144
+ content: "";
3145
+ position: absolute;
3146
+ top: 100%;
3147
+ left: 50%;
3148
+ transform: translateX(-50%);
3149
+ border: 5px solid transparent;
3150
+ border-top-color: var(--code-bg);
3151
+ }
3152
+
3153
+ .reveal-tip.is-below {
3154
+ bottom: auto;
3155
+ top: calc(100% + 9px);
3156
+ transform: translateX(-50%) translateY(-4px);
3157
+ }
3158
+
3159
+ .reveal-tip.is-below::after {
3160
+ top: auto;
3161
+ bottom: 100%;
3162
+ border-top-color: transparent;
3163
+ border-bottom-color: var(--code-bg);
561
3164
  }
562
- function starterIndexHtml(app) {
563
- // Plain HTML/JS starter — no build step. It loads the data-plane SDK and
564
- // demonstrates the two most common calls: who am I, and read/write a doc.
565
- return `<!doctype html>
566
- <html lang="en">
567
- <head>
568
- <meta charset="utf-8" />
569
- <meta name="viewport" content="width=device-width, initial-scale=1" />
570
- <title>${titleCase(app)} — Railcode</title>
571
- <script src="/_api/sdk.js"></script>
572
- </head>
573
- <body>
574
- <main>
575
- <h1>${titleCase(app)}</h1>
576
- <p id="who">Loading your identity…</p>
577
- <p id="doc">Loading saved data…</p>
578
- </main>
579
- <script>
580
- async function main() {
581
- // \`me()\` and \`db\` are provided by /_api/sdk.js once the app is served.
582
- const identity = await me();
583
- document.getElementById("who").textContent =
584
- "Signed in as " + ((identity.user && identity.user.email) || "unknown");
585
3165
 
586
- const docs = db.collection("greetings");
587
- await docs.put("hello", { text: "Hello from ${app}!", at: new Date().toISOString() });
588
- const saved = await docs.get("hello");
589
- document.getElementById("doc").textContent = JSON.stringify(saved);
590
- }
591
- main().catch((error) => {
592
- document.getElementById("who").textContent = "SDK error: " + error.message;
593
- });
594
- </script>
595
- </body>
596
- </html>
597
- `;
3166
+ .code-string {
3167
+ color: var(--code-string);
598
3168
  }
599
- function reactPackageJson(app) {
600
- return `${JSON.stringify({
601
- name: app,
602
- version: "0.1.0",
603
- private: true,
604
- type: "module",
605
- scripts: {
606
- dev: "vite",
607
- build: "tsc -p tsconfig.json && vite build",
608
- preview: "vite preview",
609
- },
610
- dependencies: {
611
- react: "^19.0.0",
612
- "react-dom": "^19.0.0",
613
- zustand: "^5.0.0",
614
- },
615
- devDependencies: {
616
- "@vitejs/plugin-react": "^5.0.0",
617
- "@types/react": "^19.0.0",
618
- "@types/react-dom": "^19.0.0",
619
- typescript: "^5.9.0",
620
- vite: "^7.0.0",
621
- },
622
- }, null, 2)}\n`;
3169
+
3170
+ .code-keyword {
3171
+ color: var(--code-keyword);
3172
+ font-weight: 600;
623
3173
  }
624
- function reactIndexHtml(app) {
625
- return `<!doctype html>
626
- <html lang="en">
627
- <head>
628
- <meta charset="UTF-8" />
629
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
630
- <title>${titleCase(app)} | Railcode</title>
631
- <script src="/_api/sdk.js"></script>
632
- </head>
633
- <body>
634
- <div id="root"></div>
635
- <script type="module" src="/src/main.tsx"></script>
636
- </body>
637
- </html>
638
- `;
3174
+
3175
+ .code-number {
3176
+ color: var(--code-number);
639
3177
  }
640
- function reactTsConfig() {
641
- return `${JSON.stringify({
642
- compilerOptions: {
643
- target: "ES2020",
644
- useDefineForClassFields: true,
645
- lib: ["DOM", "DOM.Iterable", "ES2020"],
646
- allowJs: false,
647
- skipLibCheck: true,
648
- esModuleInterop: true,
649
- allowSyntheticDefaultImports: true,
650
- strict: true,
651
- forceConsistentCasingInFileNames: true,
652
- module: "ESNext",
653
- moduleResolution: "Node",
654
- resolveJsonModule: true,
655
- isolatedModules: true,
656
- noEmit: true,
657
- jsx: "react-jsx",
658
- },
659
- include: ["src"],
660
- references: [],
661
- }, null, 2)}\n`;
3178
+
3179
+ .code-call {
3180
+ color: var(--code-call);
662
3181
  }
663
- function reactViteConfig() {
664
- return `import { defineConfig } from "vite";
665
- import react from "@vitejs/plugin-react";
666
3182
 
667
- export default defineConfig({
668
- plugins: [react()],
669
- build: {
670
- outDir: "dist",
671
- },
672
- });
673
- `;
3183
+ .code-punct {
3184
+ color: var(--code-punct);
674
3185
  }
675
- function reactMainTsx() {
676
- return `import React from "react";
677
- import ReactDOM from "react-dom/client";
678
- import { App } from "./App";
679
- import "./styles.css";
680
3186
 
681
- ReactDOM.createRoot(document.getElementById("root")!).render(
682
- <React.StrictMode>
683
- <App />
684
- </React.StrictMode>,
685
- );
686
- `;
3187
+ .code-comment {
3188
+ color: var(--ink-4);
687
3189
  }
688
- function reactAppTsx(app) {
689
- return `import { useEffect } from "react";
690
- import { create } from "zustand";
691
3190
 
692
- declare const me: () => Promise<{ user: { email: string; name: string } }>;
693
- declare const db: {
694
- collection: (name: string) => {
695
- put: (key: string, value: unknown) => Promise<void>;
696
- get: (key: string) => Promise<unknown>;
697
- };
698
- };
3191
+ .reveal:hover .reveal-tip,
3192
+ .reveal:focus-visible .reveal-tip {
3193
+ opacity: 1;
3194
+ visibility: visible;
3195
+ transform: translateX(-50%) translateY(0);
3196
+ }
699
3197
 
700
- type AppState = {
701
- status: string;
702
- saved: string;
703
- setStatus: (status: string) => void;
704
- setSaved: (saved: string) => void;
705
- };
3198
+ .action-tip {
3199
+ position: relative;
3200
+ display: inline-flex;
3201
+ align-items: center;
3202
+ flex: none;
3203
+ }
706
3204
 
707
- const useAppStore = create<AppState>((set) => ({
708
- status: "Loading your identity...",
709
- saved: "Loading saved data...",
710
- setStatus: (status) => set({ status }),
711
- setSaved: (saved) => set({ saved }),
712
- }));
3205
+ .upload-tip {
3206
+ margin-top: auto;
3207
+ align-self: flex-start;
3208
+ }
713
3209
 
714
- export function App() {
715
- const { status, saved, setStatus, setSaved } = useAppStore();
3210
+ .action-tip-pop {
3211
+ position: absolute;
3212
+ left: 50%;
3213
+ bottom: calc(100% + 9px);
3214
+ transform: translateX(-50%) translateY(4px);
3215
+ background: var(--code-bg);
3216
+ color: var(--code-ink);
3217
+ font-family: var(--font-mono);
3218
+ font-size: 11.5px;
3219
+ white-space: pre-wrap;
3220
+ text-align: left;
3221
+ max-width: min(360px, calc(100vw - 24px));
3222
+ width: max-content;
3223
+ line-height: 1.5;
3224
+ padding: 7px 10px;
3225
+ border-radius: var(--radius-md);
3226
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
3227
+ opacity: 0;
3228
+ visibility: hidden;
3229
+ pointer-events: none;
3230
+ transition:
3231
+ opacity 140ms ease,
3232
+ transform 140ms ease;
3233
+ z-index: 25;
3234
+ }
716
3235
 
717
- useEffect(() => {
718
- async function load() {
719
- const identity = await me();
720
- setStatus("Signed in as " + (identity.user?.email || "unknown"));
3236
+ .action-tip-pop::after {
3237
+ content: "";
3238
+ position: absolute;
3239
+ top: 100%;
3240
+ left: 50%;
3241
+ transform: translateX(-50%);
3242
+ border: 5px solid transparent;
3243
+ border-top-color: var(--code-bg);
3244
+ }
721
3245
 
722
- const docs = db.collection("greetings");
723
- await docs.put("hello", { text: "Hello from ${app}!", at: new Date().toISOString() });
724
- const value = await docs.get("hello");
725
- setSaved(JSON.stringify(value));
726
- }
3246
+ .action-tip:hover .action-tip-pop,
3247
+ .action-tip:focus-within .action-tip-pop {
3248
+ opacity: 1;
3249
+ visibility: visible;
3250
+ transform: translateX(-50%) translateY(0);
3251
+ }
727
3252
 
728
- load().catch((error: unknown) => {
729
- setStatus("SDK error: " + (error instanceof Error ? error.message : String(error)));
730
- });
731
- }, [setSaved, setStatus]);
3253
+ /* ---------- AI card ---------- */
732
3254
 
733
- return (
734
- <main>
735
- <h1>${titleCase(app)}</h1>
736
- <p>{status}</p>
737
- <p>{saved}</p>
738
- </main>
739
- );
3255
+ .ai-body {
3256
+ display: flex;
3257
+ flex-direction: column;
3258
+ gap: var(--space-3);
740
3259
  }
741
- `;
3260
+
3261
+ .ai-response {
3262
+ margin-top: var(--space-2);
3263
+ padding: 12px 14px;
3264
+ background: var(--bg-inset);
3265
+ border-radius: var(--radius-md);
3266
+ font-size: 13px;
3267
+ line-height: 1.6;
3268
+ color: var(--ink-1);
742
3269
  }
743
- function reactStylesCss() {
744
- return `:root {
745
- color: #172033;
746
- background: #f8fafc;
747
- font-family:
748
- Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
3270
+
3271
+ .cursor {
3272
+ display: inline-block;
3273
+ width: 2px;
3274
+ height: 14px;
3275
+ background: var(--ink-2);
3276
+ vertical-align: -2px;
3277
+ margin-left: 1px;
3278
+ animation: blink 900ms step-end infinite;
749
3279
  }
750
3280
 
751
- body {
752
- margin: 0;
3281
+ @keyframes blink {
3282
+ 50% {
3283
+ opacity: 0;
3284
+ }
753
3285
  }
754
3286
 
755
- main {
756
- display: grid;
757
- min-height: 100vh;
758
- align-content: center;
3287
+ .ai-foot-row {
3288
+ display: flex;
3289
+ align-items: center;
3290
+ justify-content: space-between;
3291
+ gap: 10px;
3292
+ }
3293
+
3294
+ .ai-note {
3295
+ color: var(--ink-4);
3296
+ font-size: 12px;
3297
+ }
3298
+
3299
+ /* ---------- error banner ---------- */
3300
+
3301
+ .error-banner {
3302
+ display: flex;
3303
+ justify-content: space-between;
3304
+ align-items: center;
759
3305
  gap: 12px;
760
- padding: 48px;
3306
+ margin-bottom: var(--space-6);
3307
+ padding: 12px 14px;
3308
+ border: 1px solid var(--line-strong);
3309
+ border-radius: var(--radius-lg);
3310
+ background: var(--bg-inset);
3311
+ color: var(--ink-1);
3312
+ font-size: 13px;
761
3313
  }
762
3314
 
763
- h1,
764
- p {
765
- margin: 0;
3315
+ .error-banner button {
3316
+ border: 0;
3317
+ background: transparent;
3318
+ color: var(--ink-3);
3319
+ font-weight: 600;
3320
+ font-size: 12.5px;
3321
+ }
3322
+
3323
+ .error-banner button:hover {
3324
+ color: var(--ink-1);
3325
+ }
3326
+
3327
+ footer {
3328
+ max-width: 1180px;
3329
+ margin: var(--space-12) auto 0;
3330
+ padding: var(--space-6) 24px 0;
3331
+ border-top: 1px solid var(--line);
3332
+ color: var(--ink-4);
3333
+ font-size: 12px;
3334
+ display: flex;
3335
+ justify-content: flex-start;
3336
+ gap: var(--space-4);
3337
+ }
3338
+
3339
+ footer code {
3340
+ font-family: var(--font-mono);
3341
+ color: var(--ink-3);
3342
+ }
3343
+
3344
+ @media (max-width: 760px) {
3345
+ .guide-section {
3346
+ grid-template-columns: 1fr;
3347
+ gap: var(--space-5);
3348
+ }
3349
+ .guide-copy {
3350
+ position: static;
3351
+ }
3352
+ .hero h1 {
3353
+ font-size: 26px;
3354
+ }
766
3355
  }
767
3356
 
768
- h1 {
769
- font-size: 40px;
770
- line-height: 1.1;
3357
+ @media (prefers-reduced-motion: reduce) {
3358
+ .card {
3359
+ animation: none !important;
3360
+ opacity: 1 !important;
3361
+ transform: none !important;
3362
+ }
3363
+ .cursor {
3364
+ animation: none;
3365
+ opacity: 1;
3366
+ }
3367
+ .spinner {
3368
+ animation-duration: 1400ms;
3369
+ }
771
3370
  }
772
3371
  `;
773
3372
  }
@@ -824,11 +3423,121 @@ async function commandDesignSystem(args) {
824
3423
  process.stdout.write(markdown.endsWith("\n") || markdown === "" ? markdown : `${markdown}\n`);
825
3424
  }
826
3425
  // ---------------------------------------------------------------------------
3426
+ // manifest — validate manifest.yaml locally / show an app's manifest state
3427
+ // ---------------------------------------------------------------------------
3428
+ const MANIFEST_HELP = `railcode manifest — the app authority manifest (${APP_MANIFEST_NAME})
3429
+
3430
+ Usage:
3431
+ railcode manifest validate [path] Strict local parse (default ./${APP_MANIFEST_NAME})
3432
+ railcode manifest show <app> Ratified doc + pending diff for an app (by slug)
3433
+
3434
+ Show options:
3435
+ --json Print the raw manifest envelope
3436
+
3437
+ The manifest declares the operations an app performs (saved queries, connector
3438
+ endpoints, llm, ad-hoc SQL) and whose authority they run under (run_as). The
3439
+ server-side RATIFIED copy is authoritative; a deploy whose manifest adds
3440
+ operations the deployer doesn't hold lands as a pending diff awaiting approval.
3441
+ No ${APP_MANIFEST_NAME} = run_as: user (pass-through, exactly the pre-manifest
3442
+ behavior).
3443
+ `;
3444
+ async function commandManifest(args) {
3445
+ const sub = args.rest[0] ?? "";
3446
+ switch (sub) {
3447
+ case "validate":
3448
+ await commandManifestValidate(args);
3449
+ return;
3450
+ case "show":
3451
+ await commandManifestShow(args);
3452
+ return;
3453
+ case "":
3454
+ case "help":
3455
+ console.log(MANIFEST_HELP);
3456
+ return;
3457
+ default:
3458
+ throw new CliError(`Unknown manifest subcommand: ${sub}\n\n${MANIFEST_HELP}`, 2);
3459
+ }
3460
+ }
3461
+ async function commandManifestValidate(args) {
3462
+ const path = resolve(process.cwd(), args.rest[1] ?? APP_MANIFEST_NAME);
3463
+ if (!existsSync(path)) {
3464
+ throw new CliError(`No manifest at ${path}. Without a ${APP_MANIFEST_NAME} the app deploys as run_as: user (pass-through).`, 2);
3465
+ }
3466
+ let doc;
3467
+ try {
3468
+ doc = parseManifestYaml(readFileSync(path, "utf8"));
3469
+ }
3470
+ catch (error) {
3471
+ if (error instanceof ManifestParseError) {
3472
+ throw new CliError(`Invalid ${APP_MANIFEST_NAME}: ${error.message}`, 1);
3473
+ }
3474
+ throw error;
3475
+ }
3476
+ console.log(`${relativeLabel(process.cwd(), path)} is valid.`);
3477
+ console.log(renderManifestSummary(doc));
3478
+ console.log("Note: resource names (saved queries, connectors, connections) are checked against your org at deploy.");
3479
+ }
3480
+ async function commandManifestShow(args) {
3481
+ const slug = args.rest[1];
3482
+ if (!slug)
3483
+ throw new CliError("Usage: railcode manifest show <app> [--json]", 2);
3484
+ const config = requireLogin(args);
3485
+ const list = (await readJsonOrThrow(await authedFetch(config, `/api/organizations/${config.orgUuid}/apps`), "List apps"));
3486
+ const app = list.find((a) => a.app_slug === slug);
3487
+ if (!app)
3488
+ throw new CliError(`No app "${slug}" in your org.`, 1);
3489
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/apps/${app.uuid}/manifest`);
3490
+ if (resp.status === 401)
3491
+ await reLoginOrThrow();
3492
+ if (resp.status === 403) {
3493
+ throw new CliError("You don't have access to view this app's manifest.", 1);
3494
+ }
3495
+ if (!resp.ok) {
3496
+ throw new CliError(`Could not fetch the manifest (${resp.status}): ${await errorDetail(resp)}`, 1);
3497
+ }
3498
+ const manifest = (await resp.json());
3499
+ if (args.options.json === true) {
3500
+ console.log(JSON.stringify(manifest, null, 2));
3501
+ return;
3502
+ }
3503
+ console.log(`Manifest for ${slug}`);
3504
+ if (!manifest.ratified && !manifest.pending) {
3505
+ console.log("No manifest — pass-through (runs as each caller, run_as: user).");
3506
+ return;
3507
+ }
3508
+ if (manifest.ratified) {
3509
+ const r = manifest.ratified;
3510
+ const by = r.ratified_by_email ?? "unknown";
3511
+ const when = r.ratified_at ? ` on ${r.ratified_at.slice(0, 10)}` : "";
3512
+ console.log(`Ratified by ${by}${when} (sha256 ${r.content_hash.slice(0, 12)}…)`);
3513
+ console.log(renderManifestSummary(r.document));
3514
+ }
3515
+ else {
3516
+ console.log("Nothing ratified yet — the app runs pass-through until approval.");
3517
+ }
3518
+ if (manifest.pending) {
3519
+ const p = manifest.pending;
3520
+ console.log(`Pending approval (deployed by ${p.created_by_email ?? "unknown"} on ${p.created_at.slice(0, 10)}):`);
3521
+ if (manifest.pending_added.length > 0) {
3522
+ for (const op of manifest.pending_added) {
3523
+ const label = documentOperations(p.document).find((o) => o.type === op.resource_type && o.id === op.resource_id)
3524
+ ?.label ?? `${op.resource_type} ${op.resource_id}`;
3525
+ console.log(` + ${label}`);
3526
+ }
3527
+ }
3528
+ else {
3529
+ console.log(" (no new operations)");
3530
+ }
3531
+ console.log(manifest.can_approve
3532
+ ? "You hold these operations — approve/reject from the app's Manifest panel."
3533
+ : "Approval needs an org admin (or any member holding these operations).");
3534
+ }
3535
+ }
3536
+ // ---------------------------------------------------------------------------
827
3537
  // db — list data connectors + run read-only SQL (`railcode db list|query`)
828
3538
  //
829
- // Data connectors are org-wide; the app is only the scoping/attribution context
830
- // for the SQL route, so we reuse resolveOrCreateApp (same as deploy/dev) and the
831
- // bearer, app-scoped endpoints `railcode dev` already forwards to.
3539
+ // Data connectors are org-wide, so these commands use the app-less org data
3540
+ // plane. They never resolve or create an app.
832
3541
  // ---------------------------------------------------------------------------
833
3542
  const DB_HELP = `railcode db — data connectors
834
3543
 
@@ -1427,6 +4136,71 @@ async function errorDetail(response) {
1427
4136
  return text || response.statusText;
1428
4137
  }
1429
4138
  // ---------------------------------------------------------------------------
4139
+ // llm — list the LLM providers/models apps can call (`railcode llm`)
4140
+ // ---------------------------------------------------------------------------
4141
+ const LLM_HELP = `railcode llm — the org's LLM gateway, as apps see it
4142
+
4143
+ Usage:
4144
+ railcode llm providers List configured providers with their models
4145
+ railcode llm models Flat list of every callable model
4146
+
4147
+ Every configured provider is live; calls route by (provider, model). Pass either
4148
+ to \`llm.generate()\` / \`llm.stream()\` in app code — or neither to use the org
4149
+ default marked in the listing. Keys and provider internals are never shown.
4150
+
4151
+ Options:
4152
+ --json Print the raw provider array
4153
+ `;
4154
+ async function commandLlm(args) {
4155
+ const sub = args.rest[0] ?? "";
4156
+ switch (sub) {
4157
+ case "providers":
4158
+ case "provider":
4159
+ await commandLlmProviders(args, "providers");
4160
+ return;
4161
+ case "models":
4162
+ case "model":
4163
+ await commandLlmProviders(args, "models");
4164
+ return;
4165
+ case "":
4166
+ case "help":
4167
+ console.log(LLM_HELP);
4168
+ return;
4169
+ default:
4170
+ throw new CliError(`Unknown llm command: ${sub}\n\n${LLM_HELP}`, 2);
4171
+ }
4172
+ }
4173
+ async function commandLlmProviders(args, view) {
4174
+ assertKnownLlmOptions(args, ["json", "apiUrl"]);
4175
+ if (args.rest.length > 1) {
4176
+ throw new CliError(`railcode llm ${view} takes no arguments (got "${args.rest[1]}").`, 2);
4177
+ }
4178
+ const config = requireLogin(args);
4179
+ const providers = await fetchLlmProviders(config);
4180
+ if (args.options.json) {
4181
+ console.log(JSON.stringify(providers, null, 2));
4182
+ return;
4183
+ }
4184
+ if (providers.length === 0) {
4185
+ console.log("No LLM providers configured for this organization.");
4186
+ return;
4187
+ }
4188
+ console.log(view === "providers" ? llmProviderTable(providers) : llmModelTable(providers));
4189
+ }
4190
+ async function fetchLlmProviders(config) {
4191
+ const resp = await authedFetch(config, `/api/organizations/${config.orgUuid}/llm/providers`);
4192
+ if (resp.status === 401)
4193
+ await reLoginOrThrow();
4194
+ return (await readJsonOrThrow(resp, "List LLM providers"));
4195
+ }
4196
+ function assertKnownLlmOptions(args, allowed) {
4197
+ for (const key of Object.keys(args.options)) {
4198
+ if (!allowed.includes(key)) {
4199
+ throw new CliError(`Unknown option --${camelToKebab(key)} for \`railcode llm\`.\n\n${LLM_HELP}`, 2);
4200
+ }
4201
+ }
4202
+ }
4203
+ // ---------------------------------------------------------------------------
1430
4204
  // Config persistence
1431
4205
  // ---------------------------------------------------------------------------
1432
4206
  function loadConfig() {
@@ -1854,7 +4628,7 @@ function assetServerPort(args, manifest) {
1854
4628
  // Print the install command and stop — never mutate the app dir on `railcode dev`.
1855
4629
  function ensureAppDeps(cwd) {
1856
4630
  if (!existsSync(join(cwd, "node_modules"))) {
1857
- throw new CliError("Dependencies are not installed. Run `pnpm install` here, then `railcode dev` again.", 1);
4631
+ throw new CliError(`Dependencies are not installed. Run \`${detectPackageManager(cwd)} install\` here, then \`railcode dev\` again.`, 1);
1858
4632
  }
1859
4633
  }
1860
4634
  async function startAssetServer(plan, cwd, startPort, devProxyPort) {
@@ -1864,11 +4638,14 @@ async function startAssetServer(plan, cwd, startPort, devProxyPort) {
1864
4638
  const reservation = await reserveAvailablePort(startPort, "asset");
1865
4639
  const assetPort = reservation.port;
1866
4640
  // For the detected Vite case, run the local vite binary directly with pinned
1867
- // host/port so the proxy target is known. (Going through `pnpm dev -- …` would
1868
- // forward the `--` separator to vite, which then ignores the flags.) A custom
1869
- // command gets the ports via env and is expected to honour PORT.
4641
+ // host/port so the proxy target is known. `npx` resolves the vite already in
4642
+ // the app's node_modules (deps are guaranteed present ensureAppDeps runs
4643
+ // first) and is package-manager-agnostic, so we don't force pnpm on the user.
4644
+ // (Going through `<pm> dev -- …` would forward the `--` separator to vite,
4645
+ // which then ignores the flags.) A custom command gets the ports via env and
4646
+ // is expected to honour PORT.
1870
4647
  const command = plan.vite
1871
- ? `pnpm exec vite --host 127.0.0.1 --port ${assetPort} --strictPort --clearScreen false`
4648
+ ? `npx vite --host 127.0.0.1 --port ${assetPort} --strictPort --clearScreen false`
1872
4649
  : plan.command;
1873
4650
  console.log(` asset server: ${command}`);
1874
4651
  try {
@@ -1975,6 +4752,16 @@ function resolveDevSdkPath() {
1975
4752
  ];
1976
4753
  return candidates.find((p) => existsSync(p)) ?? null;
1977
4754
  }
4755
+ // Logo assets bundled into the React template's src/assets/. Packaged builds
4756
+ // copy cli/assets/react-template -> cli/static/react-template-assets (see
4757
+ // package.json build:sdk); running in-repo falls back to the source dir.
4758
+ function resolveReactTemplateAssetsDir() {
4759
+ const candidates = [
4760
+ join(CLI_PACKAGE_ROOT, "static", "react-template-assets"), // packaged CLI
4761
+ join(CLI_PACKAGE_ROOT, "assets", "react-template"), // in-repo source
4762
+ ];
4763
+ return candidates.find((p) => existsSync(p)) ?? null;
4764
+ }
1978
4765
  // The dev analog of resolveDeployPlan: custom command | dev script (Vite) | serve
1979
4766
  // static files straight from the root when there's no package.json.
1980
4767
  function resolveDevPlan(cwd, manifest) {
@@ -1987,7 +4774,7 @@ function resolveDevPlan(cwd, manifest) {
1987
4774
  try {
1988
4775
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
1989
4776
  if (typeof pkg.scripts?.dev === "string") {
1990
- return { mode: "asset", root, command: "pnpm dev", vite: true };
4777
+ return { mode: "asset", root, command: `${detectPackageManager(cwd)} run dev`, vite: true };
1991
4778
  }
1992
4779
  }
1993
4780
  catch {
@@ -2134,6 +4921,7 @@ async function handleLocalApi(ctx, req, res, url) {
2134
4921
  path === "/sql" ||
2135
4922
  path === "/llm/generate" ||
2136
4923
  path === "/llm/stream" ||
4924
+ path === "/llm/providers" ||
2137
4925
  path === "/service-connectors" ||
2138
4926
  path === "/service-connectors/request") {
2139
4927
  await forwardCompute(ctx, req, res, path, url);
@@ -2325,12 +5113,10 @@ function devCreds(config) {
2325
5113
  return null;
2326
5114
  return { apiUrl: normalizeApiOrigin(apiUrl), token, orgUuid };
2327
5115
  }
2328
- // Resolve the server-side app for proxying — lazily and memoized, so we don't
2329
- // pollute org state on a typo'd `railcode dev`. The app is **created only on the
2330
- // first actual call** (llm/sql/service-connectors request, allowCreate); a
2331
- // load-time list (`GET /connections`, `/service-connectors`) resolves an existing
2332
- // app but never creates one.
2333
- function resolveDevAppUuid(ctx, creds, allowCreate) {
5116
+ // Resolve the server-side app for proxying — lazily and memoized. Local dev must
5117
+ // not create product apps: an app becomes real on first successful deploy, not on
5118
+ // a dev-time SQL/LLM/connector call.
5119
+ function resolveDevAppUuid(ctx, creds) {
2334
5120
  const base = `${creds.apiUrl}/api/organizations/${creds.orgUuid}/apps`;
2335
5121
  const authed = { Authorization: `Bearer ${creds.token}` };
2336
5122
  if (!ctx.appUuidPromise) {
@@ -2340,45 +5126,38 @@ function resolveDevAppUuid(ctx, creds, allowCreate) {
2340
5126
  if (!resp.ok)
2341
5127
  return null;
2342
5128
  const apps = (await resp.json());
2343
- return apps.find((a) => a.app_slug === ctx.app)?.uuid ?? null;
5129
+ return apps.find((a) => a.app_slug === ctx.app && a.status === "active")?.uuid ?? null;
2344
5130
  }
2345
5131
  catch {
2346
5132
  return null;
2347
5133
  }
2348
5134
  })();
2349
5135
  }
2350
- return ctx.appUuidPromise.then((existing) => {
2351
- if (existing || !allowCreate)
2352
- return existing;
2353
- if (!ctx.appCreatePromise) {
2354
- ctx.appCreatePromise = (async () => {
2355
- try {
2356
- const created = await fetch(base, {
2357
- method: "POST",
2358
- headers: { ...authed, "Content-Type": "application/json" },
2359
- body: JSON.stringify({ name: ctx.app, app_slug: ctx.app }),
2360
- });
2361
- if (!created.ok)
2362
- return null;
2363
- const app = (await created.json());
2364
- console.log(` created app "${ctx.app}" on ${creds.apiUrl} (first llm/sql/connector call)`);
2365
- ctx.appUuidPromise = Promise.resolve(app.uuid); // later resolves skip the list
2366
- return app.uuid;
2367
- }
2368
- catch {
2369
- return null;
2370
- }
2371
- })();
2372
- }
2373
- return ctx.appCreatePromise;
2374
- });
5136
+ return ctx.appUuidPromise;
5137
+ }
5138
+ export function devUpstreamAuthFallback(status, degradeOk) {
5139
+ if (status !== 401)
5140
+ return null;
5141
+ if (degradeOk)
5142
+ return { status: 200, body: [] };
5143
+ return {
5144
+ status: 503,
5145
+ body: {
5146
+ error: "auth",
5147
+ message: "Saved token was rejected. Run `railcode login` again.",
5148
+ },
5149
+ };
2375
5150
  }
2376
5151
  async function forwardCompute(ctx, req, res, path, url) {
2377
5152
  // Never reply 401 to the browser — the SDK reloads the page on 401 (loop).
2378
5153
  // Load-time list calls degrade to [] so dataConnectors()/savedQueries()/
2379
- // serviceConnectors() at load don't break; actual call surfaces
2380
- // (/queries/{name}, /sql, /llm, /service-connectors/request) return 503 instead.
2381
- const degradeOk = path === "/connections" || path === "/queries" || path === "/service-connectors";
5154
+ // serviceConnectors() at load don't break when the saved token is bad; actual
5155
+ // calls (/queries/{name}, /sql, /llm, /service-connectors/request) return 503.
5156
+ // A 403 is a real authorization denial and must be forwarded with its body.
5157
+ const degradeOk = path === "/connections" ||
5158
+ path === "/queries" ||
5159
+ path === "/service-connectors" ||
5160
+ path === "/llm/providers";
2382
5161
  const creds = devCreds(ctx.config);
2383
5162
  if (!creds) {
2384
5163
  if (degradeOk)
@@ -2388,15 +5167,13 @@ async function forwardCompute(ctx, req, res, path, url) {
2388
5167
  message: "Run `railcode login` to use LLM and connectors in dev.",
2389
5168
  });
2390
5169
  }
2391
- // Only the actual call surfaces (saved-query invoke / llm / sql /
2392
- // service-connectors/request) may create the app server-side; load-time lists won't.
2393
- const appUuid = await resolveDevAppUuid(ctx, creds, !degradeOk);
5170
+ const appUuid = await resolveDevAppUuid(ctx, creds);
2394
5171
  if (!appUuid) {
2395
5172
  if (degradeOk)
2396
5173
  return sendJson(res, 200, []);
2397
5174
  return sendJson(res, 503, {
2398
5175
  error: "app_unavailable",
2399
- message: "Could not resolve this app on the server.",
5176
+ message: "Deploy this app once before using LLM, SQL, saved queries, or connectors in dev.",
2400
5177
  });
2401
5178
  }
2402
5179
  const target = `${creds.apiUrl}/api/organizations/${creds.orgUuid}/apps/${appUuid}${path}${url.search}`;
@@ -2427,13 +5204,9 @@ async function forwardCompute(ctx, req, res, path, url) {
2427
5204
  return sendJson(res, 200, []);
2428
5205
  return sendJson(res, 503, { error: "unreachable", message: `Could not reach ${creds.apiUrl}.` });
2429
5206
  }
2430
- if (upstream.status === 401 || upstream.status === 403) {
2431
- if (degradeOk)
2432
- return sendJson(res, 200, []);
2433
- return sendJson(res, 503, {
2434
- error: "auth",
2435
- message: "Saved token was rejected. Run `railcode login` again.",
2436
- });
5207
+ const authFallback = devUpstreamAuthFallback(upstream.status, degradeOk);
5208
+ if (authFallback) {
5209
+ return sendJson(res, authFallback.status, authFallback.body);
2437
5210
  }
2438
5211
  if (path === "/llm/stream" && upstream.body) {
2439
5212
  res.writeHead(upstream.status, {