@layers/amba 1.1.0 → 4.0.3

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,16 +1,55 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import pc from "picocolors";
4
- import { access, chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
+ import { access, chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
+ import { homedir, tmpdir } from "node:os";
5
6
  import { basename, dirname, join, relative, resolve } from "node:path";
6
- import { createInterface } from "node:readline";
7
7
  import { createServer } from "node:http";
8
- import { homedir, tmpdir } from "node:os";
9
8
  import open from "open";
10
9
  import { createHash, randomBytes } from "node:crypto";
10
+ import { createInterface } from "node:readline";
11
+ import { fileURLToPath } from "node:url";
11
12
  import { createWriteStream, watch } from "node:fs";
12
13
  import { spawn } from "node:child_process";
13
14
  import { build } from "esbuild";
15
+ //#region ../shared/dist/index.js
16
+ /**
17
+ * Shared email-shape validation.
18
+ *
19
+ * Lives in `@layers/amba-shared` so the API (validates inbound emails
20
+ * server-side before minting tokens / writing DB rows) and the CLI
21
+ * (validates locally before round-tripping `amba claim <email>`) share
22
+ * one canonical implementation. Previous drift between the two
23
+ * implementations caused a real BugBot finding: a >320-char email
24
+ * passed the CLI's loose regex but bounced server-side with
25
+ * `INVALID_INPUT` — confusing UX.
26
+ *
27
+ * Intentionally permissive: validates the structural shape ("no
28
+ * whitespace, has an `@`, has a dot in the domain part, ≤320 chars")
29
+ * rather than running the full RFC-5321 grammar. Most callers
30
+ * (hosted dashboard, Expo app form, CLI prompts) already validate
31
+ * client-side; anything weirder than this check will bounce at the
32
+ * upstream email provider anyway.
33
+ *
34
+ * The check exists so a 400 INVALID_INPUT surfaces before we mint a
35
+ * token / write a DB row / fire an outbound provider request — that's
36
+ * the load-bearing property, not "exactly RFC-compliant."
37
+ *
38
+ * The 320-char cap matches RFC 5321 §4.5.3.1.3 (path length, which
39
+ * includes the email + envelope wrappers). It's the conventional
40
+ * upper bound most validators converge on.
41
+ */
42
+ function isPlausibleEmail(value) {
43
+ if (typeof value !== "string") return false;
44
+ const trimmed = value.trim();
45
+ if (trimmed.length === 0 || trimmed.length > 320) return false;
46
+ const at = trimmed.indexOf("@");
47
+ if (at <= 0 || at === trimmed.length - 1) return false;
48
+ if (/\s/.test(trimmed)) return false;
49
+ if (!trimmed.slice(at + 1).includes(".")) return false;
50
+ return true;
51
+ }
52
+ //#endregion
14
53
  //#region src/_internal/shared.ts
15
54
  const DEFAULT_API_URL = "https://api.amba.dev";
16
55
  const CONSOLE_URL = "https://app.amba.dev";
@@ -246,6 +285,16 @@ function setBearerOverride(token) {
246
285
  bearerOverride = token === null || token.length === 0 ? null : token;
247
286
  }
248
287
  /**
288
+ * Read the current bearer override without consuming it. Returns
289
+ * `null` when no override is set. Used by commands that need to know
290
+ * "did the operator supply a PAT for this invocation?" — e.g. `init`
291
+ * branches on whether to bypass stored-creds and use the supplied
292
+ * token verbatim.
293
+ */
294
+ function getBearerOverride() {
295
+ return bearerOverride;
296
+ }
297
+ /**
249
298
  * Resolve the bearer token to send on the next admin API call.
250
299
  *
251
300
  * Returns the override (PAT or JWT supplied via flag/env) when set,
@@ -328,7 +377,7 @@ async function request(method, path, body) {
328
377
  try {
329
378
  const errorBody = await res.json();
330
379
  if (errorBody.error?.message) {
331
- errorMessage = errorBody.error.message;
380
+ errorMessage = `${res.status} — ${errorBody.error.message}`;
332
381
  errorCode = errorBody.error.code;
333
382
  }
334
383
  } catch {}
@@ -352,6 +401,14 @@ async function listProjects() {
352
401
  async function createProject(input) {
353
402
  return request("POST", "/projects", input);
354
403
  }
404
+ /**
405
+ * PATCH /admin/projects/:projectId — update mutable fields. Server
406
+ * silently ignores unknown keys; we filter to the documented allow-list
407
+ * before sending so a typo at the CLI doesn't pass the wire silently.
408
+ */
409
+ async function updateProject(projectId, patch) {
410
+ return request("PATCH", `/projects/${projectId}`, patch);
411
+ }
355
412
  async function getProject(projectId) {
356
413
  return request("GET", `/projects/${projectId}`);
357
414
  }
@@ -441,7 +498,7 @@ async function streamUsersExport(projectId, query = {}) {
441
498
  try {
442
499
  const errorBody = await res.json();
443
500
  if (errorBody.error?.message) {
444
- errorMessage = errorBody.error.message;
501
+ errorMessage = `${res.status} — ${errorBody.error.message}`;
445
502
  errorCode = errorBody.error.code;
446
503
  }
447
504
  } catch {}
@@ -503,12 +560,12 @@ async function deleteQueueBinding(projectId, queueName) {
503
560
  async function setSecretViaApi(projectId, input) {
504
561
  return request("POST", `/projects/${projectId}/secrets`, input);
505
562
  }
506
- async function listSecretsViaApi(projectId) {
507
- return request("GET", `/projects/${projectId}/secrets`);
563
+ async function listSecretsViaApi(projectId, options = {}) {
564
+ return request("GET", `/projects/${projectId}/secrets${options.function ? `?${new URLSearchParams({ function: options.function }).toString()}` : ""}`);
508
565
  }
509
- async function deleteSecretViaApi(projectId, name, options) {
510
- const qs = new URLSearchParams({ function: options.function });
511
- return request("DELETE", `/projects/${projectId}/secrets/${encodeURIComponent(name)}?${qs.toString()}`);
566
+ async function deleteSecretViaApi(projectId, name, options = {}) {
567
+ const qs = options.function ? `?${new URLSearchParams({ function: options.function }).toString()}` : "";
568
+ return request("DELETE", `/projects/${projectId}/secrets/${encodeURIComponent(name)}${qs}`);
512
569
  }
513
570
  async function createCollection(projectId, input) {
514
571
  return request("POST", `/projects/${projectId}/collections`, input);
@@ -588,6 +645,20 @@ async function rollbackSiteViaApi(projectId, siteName, deploymentId) {
588
645
  async function deleteSiteViaApi(projectId, siteName, options) {
589
646
  return request("DELETE", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}?confirm=${encodeURIComponent(options.confirm)}`);
590
647
  }
648
+ async function searchDomainsViaApi(projectId, query, limit) {
649
+ const params = new URLSearchParams({ q: query });
650
+ if (limit !== void 0) params.set("limit", String(limit));
651
+ return request("GET", `/projects/${projectId}/domains/search?${params.toString()}`);
652
+ }
653
+ async function checkDomainsViaApi(projectId, domains) {
654
+ return request("POST", `/projects/${projectId}/domains/check`, { domains });
655
+ }
656
+ async function purchaseDomainViaApi(projectId, body) {
657
+ return request("POST", `/projects/${projectId}/domains/purchase`, body);
658
+ }
659
+ async function listPurchasedDomainsViaApi(projectId) {
660
+ return request("GET", `/projects/${projectId}/domains`);
661
+ }
591
662
  /**
592
663
  * Delete a function entirely. Cascade: removes the deployed script
593
664
  * (backend 404 treated as success), then marks every historical
@@ -621,193 +692,6 @@ async function validateApiKey(apiKey) {
621
692
  };
622
693
  }
623
694
  //#endregion
624
- //#region src/context-files.ts
625
- /**
626
- * Generate AMBA.md project context file for AI agents.
627
- */
628
- function generateAmbaMarkdown(opts) {
629
- const sdkPackage = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
630
- const providerExample = opts.framework === "expo" ? `
631
- ### Client Setup
632
-
633
- \`\`\`tsx
634
- // app/_layout.tsx
635
- import { useEffect } from 'react';
636
- import { Slot } from 'expo-router';
637
- import { Amba } from '@layers/amba-expo';
638
-
639
- export default function RootLayout() {
640
- useEffect(() => {
641
- Amba.configure({
642
- projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
643
- apiKey: process.env.EXPO_PUBLIC_AMBA_API_KEY!,
644
- });
645
- }, []);
646
-
647
- return <Slot />;
648
- }
649
- \`\`\`
650
-
651
- ### Using the Client
652
-
653
- \`\`\`tsx
654
- import { Amba } from '@layers/amba-expo';
655
-
656
- export default function MyComponent() {
657
- const onPress = async () => {
658
- // Track an event
659
- await Amba.events.track('lesson_completed', { lesson_id: '123' });
660
-
661
- // Sign in with Apple (requires expo-apple-authentication)
662
- await Amba.signInWithApple();
663
-
664
- // Read remote config
665
- const showBanner = await Amba.config.fetch();
666
-
667
- // Email sign-in
668
- await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
669
- };
670
-
671
- // ...
672
- }
673
- \`\`\`` : `
674
- ### Client Setup
675
-
676
- \`\`\`typescript
677
- import { Amba } from '${sdkPackage}';
678
-
679
- await Amba.configure({
680
- projectId: process.env.AMBA_PROJECT_ID!,
681
- apiKey: process.env.AMBA_API_KEY!,
682
- });
683
-
684
- // Track an event
685
- await Amba.events.track('page_viewed', { page: '/pricing' });
686
-
687
- // Read remote config
688
- const config = await Amba.config.fetch();
689
-
690
- // Email sign-in
691
- await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
692
- \`\`\``;
693
- return `# Amba Project Context
694
-
695
- > This file provides context about the Amba integration for AI coding agents.
696
-
697
- ## Project Info
698
-
699
- | Key | Value |
700
- |-----|-------|
701
- | Project ID | \`${opts.projectId}\` |
702
- | Project Name | ${opts.projectName} |
703
- | Framework | ${opts.framework} |
704
- | SDK | \`${sdkPackage}\` |
705
-
706
- ## Environment Variables
707
-
708
- These are configured in \`.env.local\`:
709
-
710
- - \`AMBA_PROJECT_ID\` — Your project identifier
711
- - \`AMBA_API_KEY\` — Client API key (safe for client-side use)
712
- - \`AMBA_API_URL\` — API endpoint (defaults to https://api.amba.dev)
713
-
714
- ## SDK Usage
715
- ${providerExample}
716
-
717
- ## Available Features
718
-
719
- - **Push Notifications** — Send targeted push notifications to user segments
720
- - **Remote Config** — Key-value configuration that updates without app releases
721
- - **Segments** — Group users by behavior, properties, or entitlements
722
- - **Streaks** — Track user engagement streaks (daily, weekly)
723
- - **Content Libraries** — Scheduled content delivery (daily tips, weekly challenges)
724
- - **Entitlements** — Subscription status via RevenueCat integration
725
- - **Analytics** — DAU, MAU, retention, and custom event tracking
726
-
727
- ## API Reference
728
-
729
- - Admin API: \`https://api.amba.dev/v1/admin\`
730
- - Client API: \`https://api.amba.dev/v1/client\`
731
- - Docs: \`https://docs.amba.dev\`
732
-
733
- ## CLI Commands
734
-
735
- \`\`\`bash
736
- amba status # Check project health
737
- amba push test # Send a test push notification
738
- amba config list # List remote config values
739
- amba config set <key> <value> # Set a config value
740
- \`\`\`
741
- `;
742
- }
743
- /**
744
- * Generate .cursor/rules/amba.mdc Cursor rules file.
745
- */
746
- function generateCursorRules(opts) {
747
- const sdk = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
748
- return `---
749
- description: Rules for working with the Amba SDK in this project
750
- globs: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
751
- ---
752
-
753
- # Amba SDK Rules
754
-
755
- ## Project Setup
756
- - Project ID: \`${opts.projectId}\`
757
- - SDK: \`${sdk}\`
758
- - API URL: \`https://api.amba.dev\`
759
-
760
- ## Environment Variables
761
- - Always read Amba config from environment variables, never hardcode
762
- - Use \`process.env.AMBA_PROJECT_ID\` and \`process.env.AMBA_API_KEY\`
763
- - The .env.local file contains the project credentials
764
-
765
- ## SDK Patterns
766
- ${opts.framework === "expo" ? `- Import the \`Amba\` singleton from \`@layers/amba-expo\`
767
- - Call \`Amba.init({ projectId, apiKey })\` once in the root layout (inside a \`useEffect\`)
768
- - The Expo wrapper auto-wires AsyncStorage, push tokens, and Apple/Google sign-in
769
- - Use \`Amba.signInWithApple()\` / \`Amba.signInWithGoogle()\` for social auth one-liners
770
- - Call \`Amba.track()\` for engagement events, don't build custom analytics` : `- Initialize the Amba client once and export it as a singleton
771
- - Use \`Amba.client.track()\` for all engagement events
772
- - Use \`Amba.client.config.get()\` for remote configuration
773
- - Use \`Amba.client.auth\` for sign-up / sign-in flows`}
774
-
775
- ## Push Notifications
776
- - Register push tokens via the SDK \`registerPushToken()\` method
777
- - Handle notification payloads using the SDK's notification listener
778
- - Don't implement custom push token management
779
-
780
- ## Remote Config
781
- - Use remote config for feature flags and dynamic values
782
- - Always provide sensible defaults when reading config values
783
- - Config values are cached — don't fetch on every render
784
-
785
- ## Streaks
786
- - Streaks are server-managed; the SDK provides read-only access
787
- - Use \`track()\` to record qualifying events — the server evaluates streaks
788
- - Show streak state from \`streak.current()\`, don't calculate manually
789
-
790
- ## Best Practices
791
- - Don't store Amba API keys in source code or commit them to git
792
- - Use \`.env.local\` for local development credentials
793
- - The client API key (prefixed \`amb_dev_ck_\` or \`amb_live_ck_\`) is safe for client-side use
794
- - Server keys (prefixed \`amb_dev_sk_\` or \`amb_live_sk_\`) must stay server-side only
795
- `;
796
- }
797
- /**
798
- * Write both context files to the project directory.
799
- */
800
- async function generateContextFiles(opts) {
801
- const files = [];
802
- await writeFile(join(opts.cwd, "AMBA.md"), generateAmbaMarkdown(opts), "utf-8");
803
- files.push("AMBA.md");
804
- const cursorDir = join(opts.cwd, ".cursor", "rules");
805
- await mkdir(cursorDir, { recursive: true });
806
- await writeFile(join(cursorDir, "amba.mdc"), generateCursorRules(opts), "utf-8");
807
- files.push(".cursor/rules/amba.mdc");
808
- return files;
809
- }
810
- //#endregion
811
695
  //#region src/sandbox.ts
812
696
  /**
813
697
  * Headless agentic sandbox bootstrap.
@@ -934,60 +818,9 @@ async function performSandboxSignup(req, options = {}) {
934
818
  api_url: apiUrl,
935
819
  provisioning_status: data.project.provisioning_status,
936
820
  verify_url: data.project.verify_url,
937
- email: req.email
938
- };
939
- }
940
- /**
941
- * Write the PAT to `~/.amba/credentials.json` (chmod 0600) in a shape
942
- * the existing `loadCredentials` reader recognises.
943
- *
944
- * `auth.ts` was built around browser-OAuth tokens (`access_token` +
945
- * `refresh_token` + `expires_at`). PATs are long-lived and don't refresh
946
- * — but the stored-creds reader only inspects `access_token`, so we
947
- * write the PAT there and leave `refresh_token` empty + a far-future
948
- * `expires_at` so the expiry guard never fires.
949
- *
950
- * Real-credential safety: if the file already exists AND its `source`
951
- * is NOT `'sandbox-init'` AND `access_token` is non-empty, we treat it
952
- * as a real OAuth/PAT session and back it up to
953
- * `credentials.json.bak-<unix-ms>` before overwriting. The next
954
- * `--sandbox` run reuses our own previous sandbox creds without
955
- * back-up. This keeps the agentic flow idempotent while preventing a
956
- * silent clobber of a developer's real account.
957
- */
958
- async function writeSandboxCredentials(pat, options = {}) {
959
- const dir = join(options.homeDir ?? homedir(), ".amba");
960
- const path = join(dir, "credentials.json");
961
- await mkdir(dir, { recursive: true });
962
- let backedUpTo = null;
963
- try {
964
- const existingRaw = await readFile(path, "utf-8");
965
- const existing = JSON.parse(existingRaw);
966
- const hasToken = typeof existing.access_token === "string" && existing.access_token.length > 0;
967
- const isSandboxOwned = existing.source === "sandbox-init";
968
- if (hasToken && !isSandboxOwned) {
969
- backedUpTo = `${path}.bak-${Date.now()}`;
970
- await writeFile(backedUpTo, existingRaw, "utf-8");
971
- try {
972
- await chmod(backedUpTo, 384);
973
- } catch {}
974
- }
975
- } catch (err) {
976
- if (!isEnoent(err)) {}
977
- }
978
- const payload = {
979
- access_token: pat,
980
- refresh_token: "",
981
- expires_at: (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString(),
982
- source: "sandbox-init"
983
- };
984
- await writeFile(path, JSON.stringify(payload, null, 2), "utf-8");
985
- try {
986
- await chmod(path, 384);
987
- } catch {}
988
- return {
989
- path,
990
- backedUpTo
821
+ email: req.email,
822
+ developer_id: data.developer?.id ?? "",
823
+ developer_name: data.developer?.name
991
824
  };
992
825
  }
993
826
  /**
@@ -996,23 +829,30 @@ async function writeSandboxCredentials(pat, options = {}) {
996
829
  * Mirrors the `init` interactive flow exactly so the existing env-read
997
830
  * conventions in the SDKs and CLI commands keep working. The merge
998
831
  * logic: if the file already exists and contains an `AMBA_PROJECT_ID`
999
- * line we replace the three Amba lines in place; otherwise we append a
832
+ * line we replace the Amba lines in place; otherwise we append a
1000
833
  * fresh stanza.
834
+ *
835
+ * `serverKey` is optional — pass it on the new two-scope credential
836
+ * model where init mints both client+server. Pre-existing AMBA_SERVER_KEY
837
+ * lines are refreshed when a new value is provided and removed when
838
+ * serverKey is null AND no prior line existed (no-op on second case).
1001
839
  */
1002
- async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl) {
840
+ async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl, serverKey) {
1003
841
  const envPath = join(cwd, ".env.local");
1004
- const stanza = [
842
+ const stanzaLines = [
1005
843
  "# Amba SDK configuration (sandbox tier)",
1006
844
  `AMBA_PROJECT_ID=${projectId}`,
1007
- `AMBA_CLIENT_KEY=${clientKey}`,
1008
- `AMBA_API_URL=${apiUrl}`,
1009
- ""
1010
- ].join("\n");
845
+ `AMBA_CLIENT_KEY=${clientKey}`
846
+ ];
847
+ if (serverKey) stanzaLines.push(`AMBA_SERVER_KEY=${serverKey}`);
848
+ stanzaLines.push(`AMBA_API_URL=${apiUrl}`);
849
+ stanzaLines.push("");
850
+ const stanza = stanzaLines.join("\n");
1011
851
  let existing = "";
1012
852
  try {
1013
853
  existing = await readFile(envPath, "utf-8");
1014
854
  } catch (err) {
1015
- if (!isEnoent(err)) throw err;
855
+ if (!isEnoent$1(err)) throw err;
1016
856
  }
1017
857
  if (existing.length === 0) {
1018
858
  await writeFile(envPath, stanza, "utf-8");
@@ -1030,6 +870,14 @@ async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl) {
1030
870
  } else if (hadApiKey) updated = updated.replace(/^AMBA_API_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
1031
871
  else if (hadClientKey) updated = updated.replace(/^AMBA_CLIENT_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
1032
872
  else updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_CLIENT_KEY=${clientKey}\n`;
873
+ if (serverKey) if (/^AMBA_SERVER_KEY=/m.test(updated)) updated = updated.replace(/^AMBA_SERVER_KEY=.*/m, () => `AMBA_SERVER_KEY=${serverKey}`);
874
+ else {
875
+ const clientKeyMatch = updated.match(/^AMBA_CLIENT_KEY=.*\n?/m);
876
+ if (clientKeyMatch) {
877
+ const insertAt = (clientKeyMatch.index ?? 0) + clientKeyMatch[0].length;
878
+ updated = updated.slice(0, insertAt) + `AMBA_SERVER_KEY=${serverKey}\n` + updated.slice(insertAt);
879
+ } else updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_SERVER_KEY=${serverKey}\n`;
880
+ }
1033
881
  if (!/^AMBA_API_URL=/m.test(updated)) updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_API_URL=${apiUrl}\n`;
1034
882
  await writeFile(envPath, updated, "utf-8");
1035
883
  return envPath;
@@ -1069,8 +917,7 @@ without a credit card or email verification.
1069
917
 
1070
918
  The credentials live in **\`.env.local\`** (\`AMBA_PROJECT_ID\`,
1071
919
  \`AMBA_CLIENT_KEY\`, \`AMBA_API_URL\`) — gitignored by convention. Your
1072
- Personal Access Token is stored in \`~/.amba/credentials.json\` and was
1073
- written into every MCP client config we could detect.
920
+ Personal Access Token is stored in \`~/.amba/credentials.json\`.
1074
921
 
1075
922
  ## SDK quickstart
1076
923
 
@@ -1095,10 +942,11 @@ await Amba.events.track('app_opened');
1095
942
 
1096
943
  ## Upgrade past sandbox
1097
944
 
1098
- The account is unverified. To upgrade to the Free tier (and stop being
1099
- capped at 100 MAU / 10 MB DB), claim the email on the account:
1100
-
1101
- ${ctx.verifyUrl ? `1. Open ${ctx.verifyUrl}\n2. Change the email on the developer record to one you control via [app.amba.dev](https://app.amba.dev/settings).` : `1. Open [app.amba.dev](https://app.amba.dev) and request a password reset for \`${ctx.email}\` — the sandbox password was random and isn't recoverable.\n2. Change the email on the developer record to one you control.`}
945
+ This account uses an auto-generated email (\`${ctx.email}\`). Run
946
+ \`amba claim me@example.com\` to bind it to a real address — you'll get
947
+ a one-click link in your inbox that promotes the project to the Free
948
+ tier (1,000 MAU, 500 MB DB) and lets you sign in from a browser if you
949
+ ever need to.
1102
950
 
1103
951
  ## Useful commands
1104
952
 
@@ -1129,42 +977,6 @@ function buildAmbaMcpEntry(pat) {
1129
977
  };
1130
978
  }
1131
979
  /**
1132
- * Map an MCP config file path back to its client family. Returns null
1133
- * for paths that don't match any known config location — defensive
1134
- * against future additions to `mcpClientTargets`.
1135
- *
1136
- * The match is on path tail rather than full equality so the cwd /
1137
- * homedir-injected variants both classify correctly. We deliberately
1138
- * accept both global and project-local Claude Code paths
1139
- * (`.claude.json` and `.mcp.json`) as 'claude-code'.
1140
- */
1141
- function classifyMcpPath(path) {
1142
- if (path.endsWith(".claude.json") || path.endsWith(".mcp.json")) return "claude-code";
1143
- if (path.includes(`/.cursor/`) || path.includes(`\\.cursor\\`)) return "cursor";
1144
- if (path.includes("/windsurf/mcp_config.json") || path.includes("\\windsurf\\mcp_config.json")) return "windsurf";
1145
- return null;
1146
- }
1147
- /**
1148
- * Reduce a list of written-config paths to the set of unique client
1149
- * families they belong to. Order: claude-code, cursor, windsurf (so
1150
- * the done-message renders consistently). Skips unclassified paths
1151
- * silently.
1152
- */
1153
- function clientKindsFromPaths(paths) {
1154
- const present = /* @__PURE__ */ new Set();
1155
- for (const p of paths) {
1156
- const kind = classifyMcpPath(p);
1157
- if (kind) present.add(kind);
1158
- }
1159
- const ordered = [];
1160
- for (const k of [
1161
- "claude-code",
1162
- "cursor",
1163
- "windsurf"
1164
- ]) if (present.has(k)) ordered.push(k);
1165
- return ordered;
1166
- }
1167
- /**
1168
980
  * The list of MCP client config files we probe. Order matters only for
1169
981
  * the printed report.
1170
982
  *
@@ -1231,7 +1043,7 @@ async function mergeMcpConfigFile(target, pat) {
1231
1043
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) existing = parsed;
1232
1044
  else throw new Error(`Existing config at ${target.path} is not a JSON object`);
1233
1045
  } catch (err) {
1234
- if (isEnoent(err)) {
1046
+ if (isEnoent$1(err)) {
1235
1047
  if (!target.scaffoldIfMissing) return {
1236
1048
  path: null,
1237
1049
  backedUpTo: null
@@ -1300,7 +1112,7 @@ async function fileExists$1(path) {
1300
1112
  return false;
1301
1113
  }
1302
1114
  }
1303
- function isEnoent(err) {
1115
+ function isEnoent$1(err) {
1304
1116
  return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1305
1117
  }
1306
1118
  /**
@@ -1313,21 +1125,1254 @@ function formatManualMcpSnippet(pat) {
1313
1125
  return JSON.stringify({ mcpServers: { amba: buildAmbaMcpEntry(pat) } }, null, 2);
1314
1126
  }
1315
1127
  //#endregion
1316
- //#region ../mcp/dist/expo-build-prompt.js
1128
+ //#region src/credentials.ts
1317
1129
  /**
1318
- * Canonical Amba Expo build prompt — markdown body (no MDX frontmatter).
1130
+ * Two-scope credential model for `amba init`.
1319
1131
  *
1320
- * Source of truth for three customer-facing surfaces:
1132
+ * Identity is **developer-scoped** (one machine identity, persisted in
1133
+ * `~/.amba/credentials.json`). State is **project-scoped** (one per
1134
+ * project directory, persisted in `<cwd>/.amba/project.json`).
1321
1135
  *
1322
- * 1. The published docs page at
1323
- * `https://docs.amba.dev/docs/prompts/expo-build` — the MDX file at
1324
- * `apps/docs/content/docs/prompts/expo-build.mdx` ships the same
1325
- * body wrapped in fumadocs frontmatter.
1326
- * 2. The MCP resource `amba://prompts/expo-build` registered by
1327
- * `registerAllResources()` in `./index.ts` and exposed by the
1328
- * hosted MCP server at `mcp.amba.dev`.
1329
- * 3. The inlined snapshot baked into the `/amba-build` Claude Code
1330
- * skill by `amba init --sandbox` (see `packages/cli/src/skills.ts`).
1136
+ * One Amba account can own N projects. Running `amba init` in five
1137
+ * different folders under one identity yields one developer row + five
1138
+ * project rows — exactly the model `apps/console` and the API enforce.
1139
+ *
1140
+ * Backward compatibility
1141
+ * ----------------------
1142
+ * The legacy `~/.amba/credentials.json` (browser-OAuth era) carried
1143
+ * `{ access_token, refresh_token, expires_at }`. We read both shapes —
1144
+ * a missing `version` key signals legacy and triggers a one-shot
1145
+ * in-place upgrade after the first successful `developer_me` verify.
1146
+ *
1147
+ * Idempotency
1148
+ * -----------
1149
+ * `ensureDeveloperIdentity` + `ensureProjectForCwd` are the two entry
1150
+ * points. Both are safe to call on every `amba init` run:
1151
+ * - identity: load → verify → upgrade-or-keep; only signs up if no
1152
+ * verified PAT exists anywhere.
1153
+ * - project: load `<cwd>/.amba/project.json` → verify the
1154
+ * `project_id` still belongs to the current developer; if missing
1155
+ * or stale, mint a new project under the dev's identity.
1156
+ */
1157
+ function developerCredentialsPath(homeDir) {
1158
+ return join(homeDir ?? homedir(), ".amba", "credentials.json");
1159
+ }
1160
+ function projectCredentialsPath(cwd) {
1161
+ return join(cwd, ".amba", "project.json");
1162
+ }
1163
+ /**
1164
+ * Read `~/.amba/credentials.json`. Returns null when the file is
1165
+ * missing, malformed, or empty. Handles both new (versioned) and
1166
+ * legacy shapes — legacy returns `version: 1` after migration but
1167
+ * with `source: 'legacy'` so callers can tell.
1168
+ *
1169
+ * Does NOT verify the PAT against the API. Caller must follow up
1170
+ * with `verifyPat` before trusting the identity.
1171
+ */
1172
+ async function loadDeveloperCredentials(options = {}) {
1173
+ const path = developerCredentialsPath(options.homeDir);
1174
+ let raw;
1175
+ try {
1176
+ raw = await readFile(path, "utf-8");
1177
+ } catch (err) {
1178
+ if (isEnoent(err)) return null;
1179
+ throw err;
1180
+ }
1181
+ if (raw.trim().length === 0) return null;
1182
+ let parsed;
1183
+ try {
1184
+ parsed = JSON.parse(raw);
1185
+ } catch {
1186
+ return null;
1187
+ }
1188
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
1189
+ const obj = parsed;
1190
+ if (obj["version"] === 1) {
1191
+ const v = obj;
1192
+ if (typeof v["pat"] !== "string" || v["pat"].length === 0) return null;
1193
+ return {
1194
+ version: 1,
1195
+ developer_id: typeof v["developer_id"] === "string" ? v["developer_id"] : null,
1196
+ email: typeof v["email"] === "string" ? v["email"] : "unknown",
1197
+ pat: v["pat"],
1198
+ api_url: typeof v["api_url"] === "string" ? v["api_url"] : DEFAULT_API_URL,
1199
+ source: normalizeSource(v["source"]),
1200
+ created_at: typeof v["created_at"] === "string" ? v["created_at"] : (/* @__PURE__ */ new Date()).toISOString(),
1201
+ access_token: v["pat"],
1202
+ refresh_token: "",
1203
+ expires_at: typeof v["expires_at"] === "string" ? v["expires_at"] : (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString()
1204
+ };
1205
+ }
1206
+ const accessToken = obj["access_token"];
1207
+ if (typeof accessToken !== "string" || accessToken.length === 0) return null;
1208
+ const onDiskSource = normalizeSource(obj["source"]);
1209
+ return {
1210
+ version: 1,
1211
+ developer_id: null,
1212
+ email: "unknown",
1213
+ pat: accessToken,
1214
+ api_url: DEFAULT_API_URL,
1215
+ source: typeof obj["source"] === "string" ? onDiskSource : "legacy",
1216
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1217
+ access_token: accessToken,
1218
+ refresh_token: "",
1219
+ expires_at: typeof obj["expires_at"] === "string" ? obj["expires_at"] : (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString()
1220
+ };
1221
+ }
1222
+ function normalizeSource(value) {
1223
+ if (value === "sandbox-init" || value === "browser-auth" || value === "manual" || value === "legacy") return value;
1224
+ return "manual";
1225
+ }
1226
+ /**
1227
+ * Atomically write developer credentials to `~/.amba/credentials.json`
1228
+ * with mode 0600. Writes to a sibling `.tmp` first and renames into
1229
+ * place so a crash mid-write doesn't leave the file empty.
1230
+ *
1231
+ * Backs up an existing file when its `source` is not one of the
1232
+ * managed sources OR when the existing PAT differs from the one being
1233
+ * written. The backup goes to `credentials.json.bak-<unix-ms>`.
1234
+ */
1235
+ async function writeDeveloperCredentials(creds, options = {}) {
1236
+ const path = developerCredentialsPath(options.homeDir);
1237
+ await mkdir(join(options.homeDir ?? homedir(), ".amba"), { recursive: true });
1238
+ let backedUpTo = null;
1239
+ try {
1240
+ const existingRaw = await readFile(path, "utf-8");
1241
+ const existing = JSON.parse(existingRaw);
1242
+ const existingToken = typeof existing.pat === "string" && existing.pat.length > 0 ? existing.pat : typeof existing.access_token === "string" ? existing.access_token : "";
1243
+ if (existingToken.length > 0 && existingToken !== creds.pat) {
1244
+ backedUpTo = `${path}.bak-${Date.now()}`;
1245
+ await writeFile(backedUpTo, existingRaw, "utf-8");
1246
+ try {
1247
+ await chmod(backedUpTo, 384);
1248
+ } catch {}
1249
+ }
1250
+ } catch {}
1251
+ const tmpPath = `${path}.tmp-${Date.now()}`;
1252
+ await writeFile(tmpPath, JSON.stringify(creds, null, 2), "utf-8");
1253
+ try {
1254
+ await chmod(tmpPath, 384);
1255
+ } catch {}
1256
+ await rename(tmpPath, path);
1257
+ return {
1258
+ path,
1259
+ backedUpTo
1260
+ };
1261
+ }
1262
+ async function loadProjectCredentials(cwd) {
1263
+ const path = projectCredentialsPath(cwd);
1264
+ let raw;
1265
+ try {
1266
+ raw = await readFile(path, "utf-8");
1267
+ } catch (err) {
1268
+ if (isEnoent(err)) return null;
1269
+ throw err;
1270
+ }
1271
+ if (raw.trim().length === 0) return null;
1272
+ let parsed;
1273
+ try {
1274
+ parsed = JSON.parse(raw);
1275
+ } catch {
1276
+ return null;
1277
+ }
1278
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
1279
+ const obj = parsed;
1280
+ if (obj["version"] !== 1) return null;
1281
+ if (typeof obj["project_id"] !== "string" || obj["project_id"].length === 0) return null;
1282
+ if (typeof obj["client_key"] !== "string" || obj["client_key"].length === 0) return null;
1283
+ return {
1284
+ version: 1,
1285
+ project_id: obj["project_id"],
1286
+ project_name: typeof obj["project_name"] === "string" ? obj["project_name"] : "unknown",
1287
+ environment: obj["environment"] === "production" ? "production" : "development",
1288
+ client_key: obj["client_key"],
1289
+ server_key: typeof obj["server_key"] === "string" && obj["server_key"].length > 0 ? obj["server_key"] : null,
1290
+ api_url: typeof obj["api_url"] === "string" ? obj["api_url"] : DEFAULT_API_URL,
1291
+ wired_surfaces: Array.isArray(obj["wired_surfaces"]) ? obj["wired_surfaces"].filter((s) => typeof s === "string") : [],
1292
+ created_at: typeof obj["created_at"] === "string" ? obj["created_at"] : (/* @__PURE__ */ new Date()).toISOString(),
1293
+ updated_at: typeof obj["updated_at"] === "string" ? obj["updated_at"] : (/* @__PURE__ */ new Date()).toISOString()
1294
+ };
1295
+ }
1296
+ async function writeProjectCredentials(cwd, creds) {
1297
+ const path = projectCredentialsPath(cwd);
1298
+ await mkdir(join(cwd, ".amba"), { recursive: true });
1299
+ const tmpPath = `${path}.tmp-${Date.now()}`;
1300
+ await writeFile(tmpPath, JSON.stringify(creds, null, 2), "utf-8");
1301
+ try {
1302
+ await chmod(tmpPath, 384);
1303
+ } catch {}
1304
+ await rename(tmpPath, path);
1305
+ return path;
1306
+ }
1307
+ /**
1308
+ * Verify a PAT by calling `GET /v1/auth/developer/me`. Returns the
1309
+ * developer row on success, `null` on 401/403/404 (PAT invalid or
1310
+ * developer not found), or throws on network / 5xx errors.
1311
+ *
1312
+ * This is the single source of truth for "do we have a working
1313
+ * identity." Used at the top of every init run.
1314
+ */
1315
+ async function verifyPat(pat, options = {}) {
1316
+ const apiUrl = options.apiUrl ?? process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
1317
+ const res = await (options.fetchImpl ?? fetch)(`${apiUrl}/v1/auth/developer/me`, {
1318
+ method: "GET",
1319
+ headers: {
1320
+ Authorization: `Bearer ${pat}`,
1321
+ "User-Agent": "amba-cli/credentials"
1322
+ }
1323
+ });
1324
+ if (res.status === 401 || res.status === 403 || res.status === 404) return null;
1325
+ if (!res.ok) throw new Error(`developer/me verify returned ${res.status} ${res.statusText}`);
1326
+ let raw;
1327
+ try {
1328
+ raw = await res.json();
1329
+ } catch (err) {
1330
+ const reason = err instanceof Error ? err.message : String(err);
1331
+ throw new Error(`developer/me returned 2xx but body was not JSON: ${reason}`);
1332
+ }
1333
+ if (!raw.data?.id) return null;
1334
+ return {
1335
+ id: raw.data.id,
1336
+ email: raw.data.email ?? "unknown",
1337
+ name: raw.data.name
1338
+ };
1339
+ }
1340
+ /**
1341
+ * Ensure the machine has a verified Amba developer identity.
1342
+ *
1343
+ * Decision tree:
1344
+ * 1. Load existing `~/.amba/credentials.json`.
1345
+ * 2. If found, verify the PAT via `developer/me`.
1346
+ * - Valid → migrate shape if legacy, return.
1347
+ * - Invalid → fall through to signup (unless `signupOnMissing: false`).
1348
+ * 3. No creds (or invalid) + `signupOnMissing !== false` → call
1349
+ * `performSandboxSignup` with generated email/password, write the
1350
+ * result, return.
1351
+ * 4. No creds + `signupOnMissing === false` → throw.
1352
+ */
1353
+ async function ensureDeveloperIdentity(options = {}) {
1354
+ const apiUrl = options.apiUrl ?? process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
1355
+ const fetchImpl = options.fetchImpl ?? fetch;
1356
+ const existing = await loadDeveloperCredentials({ homeDir: options.homeDir });
1357
+ if (existing) {
1358
+ let verified = null;
1359
+ try {
1360
+ verified = await verifyPat(existing.pat, {
1361
+ apiUrl,
1362
+ fetchImpl
1363
+ });
1364
+ } catch {
1365
+ throw new Error(`Could not verify existing Amba credentials at ${developerCredentialsPath(options.homeDir)} — check your network and try again.`);
1366
+ }
1367
+ if (verified) {
1368
+ if (existing.source === "legacy" || existing.developer_id !== verified.id || existing.email !== verified.email) {
1369
+ const upgraded = {
1370
+ ...existing,
1371
+ version: 1,
1372
+ developer_id: verified.id,
1373
+ email: verified.email,
1374
+ api_url: apiUrl,
1375
+ source: existing.source === "legacy" ? "manual" : existing.source,
1376
+ created_at: existing.created_at
1377
+ };
1378
+ const write = await writeDeveloperCredentials(upgraded, { homeDir: options.homeDir });
1379
+ return {
1380
+ credentials: upgraded,
1381
+ newlySignedUp: false,
1382
+ developer: verified,
1383
+ firstProject: null,
1384
+ credentialsBackedUpTo: write.backedUpTo,
1385
+ credentialsPath: write.path
1386
+ };
1387
+ }
1388
+ return {
1389
+ credentials: existing,
1390
+ newlySignedUp: false,
1391
+ developer: verified,
1392
+ firstProject: null,
1393
+ credentialsBackedUpTo: null,
1394
+ credentialsPath: developerCredentialsPath(options.homeDir)
1395
+ };
1396
+ }
1397
+ }
1398
+ if (options.signupOnMissing === false) throw new Error(`No verified Amba identity at ${developerCredentialsPath(options.homeDir)} and signup-on-missing is disabled. Run \`amba login\` to authenticate.`);
1399
+ const signup = await performSandboxSignup({
1400
+ email: options.sandboxEmail?.trim() || generateSandboxEmail(),
1401
+ password: generateSandboxPassword()
1402
+ }, {
1403
+ apiUrl,
1404
+ fetchImpl
1405
+ });
1406
+ const developerId = signup.developer_id.length > 0 ? signup.developer_id : null;
1407
+ const developer = {
1408
+ id: developerId ?? "pending",
1409
+ email: signup.email,
1410
+ ...signup.developer_name ? { name: signup.developer_name } : {}
1411
+ };
1412
+ const newCreds = {
1413
+ version: 1,
1414
+ developer_id: developerId,
1415
+ email: signup.email,
1416
+ pat: signup.pat,
1417
+ api_url: signup.api_url,
1418
+ source: "sandbox-init",
1419
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1420
+ access_token: signup.pat,
1421
+ refresh_token: "",
1422
+ expires_at: (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString()
1423
+ };
1424
+ const write = await writeDeveloperCredentials(newCreds, { homeDir: options.homeDir });
1425
+ return {
1426
+ credentials: newCreds,
1427
+ newlySignedUp: true,
1428
+ developer,
1429
+ firstProject: {
1430
+ project_id: signup.project_id,
1431
+ client_key: signup.client_key,
1432
+ server_key: signup.server_key ?? null,
1433
+ provisioning_status: signup.provisioning_status,
1434
+ verify_url: signup.verify_url
1435
+ },
1436
+ credentialsBackedUpTo: write.backedUpTo,
1437
+ credentialsPath: write.path
1438
+ };
1439
+ }
1440
+ /**
1441
+ * Ensure the current working directory is attached to an Amba project.
1442
+ *
1443
+ * Decision tree:
1444
+ * 1. Load existing `<cwd>/.amba/project.json`.
1445
+ * - Present → return (no API call; we trust the file's metadata
1446
+ * until something downstream fails, at which point the caller
1447
+ * re-keys).
1448
+ * 2. Missing + `signupFirstProject` provided → use those keys, write
1449
+ * `<cwd>/.amba/project.json`, return (newlyCreated=true).
1450
+ * 3. Missing + no signup payload + `attachToProjectId` provided →
1451
+ * mint a new client+server key under that project, write the
1452
+ * file, return.
1453
+ * 4. Missing + no signup payload + no attach → call
1454
+ * `createProject({ name, environment })` under the dev's PAT,
1455
+ * mint both keys, write the file, return.
1456
+ */
1457
+ async function ensureProjectForCwd(cwd, options) {
1458
+ const existing = await loadProjectCredentials(cwd);
1459
+ if (existing) return {
1460
+ credentials: existing,
1461
+ newlyCreated: false
1462
+ };
1463
+ const environment = options.environment ?? "development";
1464
+ const apiUrl = process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
1465
+ setBearerOverride(options.pat);
1466
+ if (options.signupFirstProject) {
1467
+ const creds = {
1468
+ version: 1,
1469
+ project_id: options.signupFirstProject.project_id,
1470
+ project_name: options.defaultName ?? (basename(cwd) || "amba-sandbox"),
1471
+ environment,
1472
+ client_key: options.signupFirstProject.client_key,
1473
+ server_key: options.signupFirstProject.server_key,
1474
+ api_url: apiUrl,
1475
+ wired_surfaces: [],
1476
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1477
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1478
+ };
1479
+ await writeProjectCredentials(cwd, creds);
1480
+ return {
1481
+ credentials: creds,
1482
+ newlyCreated: true
1483
+ };
1484
+ }
1485
+ if (options.attachToProjectId) {
1486
+ const { clientKey, serverKey } = await mintProjectKeyPair(options.attachToProjectId, environment);
1487
+ const creds = {
1488
+ version: 1,
1489
+ project_id: options.attachToProjectId,
1490
+ project_name: options.defaultName ?? (basename(cwd) || "amba-project"),
1491
+ environment,
1492
+ client_key: clientKey,
1493
+ server_key: serverKey,
1494
+ api_url: apiUrl,
1495
+ wired_surfaces: [],
1496
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1497
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1498
+ };
1499
+ await writeProjectCredentials(cwd, creds);
1500
+ return {
1501
+ credentials: creds,
1502
+ newlyCreated: true
1503
+ };
1504
+ }
1505
+ const uniqueName = await uniqueProjectName(sanitizeProjectName(options.defaultName ?? (basename(cwd) || "amba-project")));
1506
+ const project = await createProject({
1507
+ name: uniqueName,
1508
+ environment
1509
+ });
1510
+ const { clientKey, serverKey } = await mintProjectKeyPair(project.data.id, environment);
1511
+ const creds = {
1512
+ version: 1,
1513
+ project_id: project.data.id,
1514
+ project_name: uniqueName,
1515
+ environment,
1516
+ client_key: clientKey,
1517
+ server_key: serverKey,
1518
+ api_url: apiUrl,
1519
+ wired_surfaces: [],
1520
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1521
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1522
+ };
1523
+ await writeProjectCredentials(cwd, creds);
1524
+ return {
1525
+ credentials: creds,
1526
+ newlyCreated: true
1527
+ };
1528
+ }
1529
+ async function mintProjectKeyPair(projectId, environment) {
1530
+ const clientRes = await createApiKey(projectId, "client", environment);
1531
+ const serverRes = await createApiKey(projectId, "server", environment);
1532
+ return {
1533
+ clientKey: clientRes.data.key,
1534
+ serverKey: serverRes.data.key
1535
+ };
1536
+ }
1537
+ /**
1538
+ * Pick a project name unique against the developer's current set.
1539
+ *
1540
+ * Multi-folder reality: a developer running `amba init` from
1541
+ * `~/code/fitness-app` then `~/code/fitness-app-v2` will get names
1542
+ * derived from different basenames already; the disambiguation is
1543
+ * only for the rare case where two folders end up with the same
1544
+ * basename (e.g. `~/work/fitness` and `~/personal/fitness`).
1545
+ */
1546
+ async function uniqueProjectName(base) {
1547
+ let existing;
1548
+ try {
1549
+ existing = (await listProjects()).data.map((p) => p.name);
1550
+ } catch {
1551
+ return base;
1552
+ }
1553
+ if (!existing.includes(base)) return base;
1554
+ for (let i = 2; i < 100; i += 1) {
1555
+ const candidate = `${base}-${i}`;
1556
+ if (!existing.includes(candidate)) return candidate;
1557
+ }
1558
+ return `${base}-${Date.now().toString(36)}`;
1559
+ }
1560
+ /**
1561
+ * Sanitize a candidate project name. The control-plane enforces
1562
+ * `^[a-zA-Z0-9-_]{1,64}$` (see `apps/api/src/routes/projects.ts`); the
1563
+ * basename of a project folder often contains spaces or dots. We
1564
+ * collapse runs of non-allowed chars to `-`, trim outer dashes, and
1565
+ * truncate to 64.
1566
+ */
1567
+ function sanitizeProjectName(input) {
1568
+ const collapsed = input.normalize("NFKD").replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
1569
+ return collapsed.length > 0 ? collapsed : "amba-project";
1570
+ }
1571
+ function isEnoent(err) {
1572
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1573
+ }
1574
+ //#endregion
1575
+ //#region src/commands/claim.ts
1576
+ /**
1577
+ * `amba claim <email>` — bind a sandbox account to a real email address
1578
+ * via a one-click magic link.
1579
+ *
1580
+ * Sandbox accounts are minted with an auto-generated address
1581
+ * (`sandbox-<epoch>-<nonce>@layers.com`) and remain capped at 100 MAU /
1582
+ * 10 MB DB until the developer claims a real email. This command POSTs
1583
+ * the target email to `/v1/auth/developer/claim` under the developer's
1584
+ * stored PAT; the backend emails a single-use magic link that — when
1585
+ * clicked — updates the developer row and flips the project tier from
1586
+ * `sandbox` to `verified_free` (1,000 MAU, 500 MB DB).
1587
+ *
1588
+ * Wire shape:
1589
+ *
1590
+ * POST {AMBA_API_URL}/v1/auth/developer/claim
1591
+ * Authorization: Bearer {pat}
1592
+ * Content-Type: application/json
1593
+ * Body: { "email": "<target-email>" }
1594
+ *
1595
+ * Success: HTTP 200 `{ "ok": true }`
1596
+ * Errors: HTTP 400 INVALID_INPUT
1597
+ * HTTP 409 EMAIL_TAKEN — that address already owns another account
1598
+ * HTTP 409 ALREADY_CLAIMED — this account is already verified
1599
+ * HTTP 429 — rate-limited
1600
+ * HTTP 5xx — surface verbatim with code + message
1601
+ *
1602
+ * UX contract: a single ✓ line + a hint that the link expires in 15
1603
+ * minutes. No copy-paste tokens, no follow-up commands. The click in
1604
+ * the email is the whole flow.
1605
+ */
1606
+ async function claimCommand(email, options = {}) {
1607
+ console.log();
1608
+ console.log(pc.bold(" amba claim"));
1609
+ console.log(pc.dim(" ─────────────────────────────────"));
1610
+ console.log();
1611
+ const trimmed = email.trim();
1612
+ if (!isPlausibleEmail(trimmed)) {
1613
+ console.log(pc.red(" ✗") + " Invalid email format.");
1614
+ console.log();
1615
+ process.exit(1);
1616
+ }
1617
+ let pat = options.pat ?? null;
1618
+ if (!pat) try {
1619
+ const dev = await loadDeveloperCredentials({ homeDir: options.homeDir });
1620
+ if (dev?.pat) pat = dev.pat;
1621
+ } catch {}
1622
+ if (!pat) {
1623
+ console.log(pc.red(" ✗") + " No Amba credentials found. Run " + pc.bold("amba init") + " first.");
1624
+ console.log();
1625
+ process.exit(1);
1626
+ }
1627
+ const url = `${options.apiUrl?.trim() || process.env["AMBA_API_URL"]?.trim() || "https://api.amba.dev"}/v1/auth/developer/claim`;
1628
+ const fetchImpl = options.fetchImpl ?? fetch;
1629
+ let res;
1630
+ try {
1631
+ res = await fetchImpl(url, {
1632
+ method: "POST",
1633
+ headers: {
1634
+ Authorization: `Bearer ${pat}`,
1635
+ "Content-Type": "application/json",
1636
+ "User-Agent": "amba-cli/claim"
1637
+ },
1638
+ body: JSON.stringify({ email: trimmed })
1639
+ });
1640
+ } catch (err) {
1641
+ const reason = err instanceof Error ? err.message : String(err);
1642
+ console.log(pc.red(" ✗") + ` Could not reach Amba: ${reason}`);
1643
+ console.log();
1644
+ process.exit(1);
1645
+ }
1646
+ if (res.status === 200) {
1647
+ try {
1648
+ await res.text();
1649
+ } catch {}
1650
+ console.log(pc.green(" ✓") + ` Check ${pc.bold(trimmed)} for a one-click link.`);
1651
+ console.log(pc.dim(" (Link expires in 15 minutes.)"));
1652
+ console.log();
1653
+ return;
1654
+ }
1655
+ let errCode = "";
1656
+ let errMessage = "";
1657
+ try {
1658
+ const body = await res.json();
1659
+ errCode = body.error?.code ?? "";
1660
+ errMessage = body.error?.message ?? "";
1661
+ } catch {}
1662
+ if (res.status === 400 && errCode === "INVALID_INPUT") {
1663
+ console.log(pc.red(" ✗") + " Invalid email format.");
1664
+ console.log();
1665
+ process.exit(1);
1666
+ }
1667
+ if (res.status === 409 && errCode === "EMAIL_TAKEN") {
1668
+ console.log(pc.red(" ✗") + ` That email is already on another Amba account. If it's yours, sign in via ` + pc.bold("amba login") + " or reach out to support@layers.com.");
1669
+ console.log();
1670
+ process.exit(1);
1671
+ }
1672
+ if (res.status === 409 && errCode === "ALREADY_CLAIMED") {
1673
+ console.log(pc.red(" ✗") + " This account is already verified.");
1674
+ console.log();
1675
+ process.exit(1);
1676
+ }
1677
+ if (res.status === 429) {
1678
+ console.log(pc.red(" ✗") + " Too many claim attempts. Try again in a minute.");
1679
+ console.log();
1680
+ process.exit(1);
1681
+ }
1682
+ const codeLabel = errCode || `HTTP_${res.status}`;
1683
+ const messageLabel = errMessage || res.statusText || "Request failed";
1684
+ console.log(pc.red(" ✗") + ` ${codeLabel}: ${messageLabel}`);
1685
+ console.log();
1686
+ process.exit(1);
1687
+ }
1688
+ //#endregion
1689
+ //#region src/context-files.ts
1690
+ /**
1691
+ * Generate AMBA.md project context file for AI agents.
1692
+ */
1693
+ function generateAmbaMarkdown(opts) {
1694
+ const sdkPackage = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
1695
+ const providerExample = opts.framework === "expo" ? `
1696
+ ### Client Setup
1697
+
1698
+ \`\`\`tsx
1699
+ // app/_layout.tsx
1700
+ import { useEffect } from 'react';
1701
+ import { Slot } from 'expo-router';
1702
+ import { Amba } from '@layers/amba-expo';
1703
+
1704
+ export default function RootLayout() {
1705
+ useEffect(() => {
1706
+ Amba.configure({
1707
+ projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
1708
+ apiKey: process.env.EXPO_PUBLIC_AMBA_API_KEY!,
1709
+ });
1710
+ }, []);
1711
+
1712
+ return <Slot />;
1713
+ }
1714
+ \`\`\`
1715
+
1716
+ ### Using the Client
1717
+
1718
+ \`\`\`tsx
1719
+ import { Amba } from '@layers/amba-expo';
1720
+
1721
+ export default function MyComponent() {
1722
+ const onPress = async () => {
1723
+ // Track an event
1724
+ await Amba.events.track('lesson_completed', { lesson_id: '123' });
1725
+
1726
+ // Sign in with Apple (requires expo-apple-authentication)
1727
+ await Amba.signInWithApple();
1728
+
1729
+ // Read remote config
1730
+ const showBanner = await Amba.config.fetch();
1731
+
1732
+ // Email sign-in
1733
+ await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
1734
+ };
1735
+
1736
+ // ...
1737
+ }
1738
+ \`\`\`` : `
1739
+ ### Client Setup
1740
+
1741
+ \`\`\`typescript
1742
+ import { Amba } from '${sdkPackage}';
1743
+
1744
+ await Amba.configure({
1745
+ projectId: process.env.AMBA_PROJECT_ID!,
1746
+ apiKey: process.env.AMBA_API_KEY!,
1747
+ });
1748
+
1749
+ // Track an event
1750
+ await Amba.events.track('page_viewed', { page: '/pricing' });
1751
+
1752
+ // Read remote config
1753
+ const config = await Amba.config.fetch();
1754
+
1755
+ // Email sign-in
1756
+ await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
1757
+ \`\`\``;
1758
+ return `# Amba Project Context
1759
+
1760
+ > This file provides context about the Amba integration for AI coding agents.
1761
+
1762
+ ## Project Info
1763
+
1764
+ | Key | Value |
1765
+ |-----|-------|
1766
+ | Project ID | \`${opts.projectId}\` |
1767
+ | Project Name | ${opts.projectName} |
1768
+ | Framework | ${opts.framework} |
1769
+ | SDK | \`${sdkPackage}\` |
1770
+
1771
+ ## Environment Variables
1772
+
1773
+ These are configured in \`.env.local\`:
1774
+
1775
+ - \`AMBA_PROJECT_ID\` — Your project identifier
1776
+ - \`AMBA_API_KEY\` — Client API key (safe for client-side use)
1777
+ - \`AMBA_API_URL\` — API endpoint (defaults to https://api.amba.dev)
1778
+
1779
+ ## SDK Usage
1780
+ ${providerExample}
1781
+
1782
+ ## Available Features
1783
+
1784
+ - **Push Notifications** — Send targeted push notifications to user segments
1785
+ - **Remote Config** — Key-value configuration that updates without app releases
1786
+ - **Segments** — Group users by behavior, properties, or entitlements
1787
+ - **Streaks** — Track user engagement streaks (daily, weekly)
1788
+ - **Content Libraries** — Scheduled content delivery (daily tips, weekly challenges)
1789
+ - **Entitlements** — Subscription status via RevenueCat integration
1790
+ - **Analytics** — DAU, MAU, retention, and custom event tracking
1791
+
1792
+ ## API Reference
1793
+
1794
+ - Admin API: \`https://api.amba.dev/v1/admin\`
1795
+ - Client API: \`https://api.amba.dev/v1/client\`
1796
+ - Docs: \`https://docs.amba.dev\`
1797
+
1798
+ ## CLI Commands
1799
+
1800
+ \`\`\`bash
1801
+ amba status # Check project health
1802
+ amba push test # Send a test push notification
1803
+ amba config list # List remote config values
1804
+ amba config set <key> <value> # Set a config value
1805
+ \`\`\`
1806
+ `;
1807
+ }
1808
+ /**
1809
+ * Generate .cursor/rules/amba.mdc Cursor rules file.
1810
+ */
1811
+ function generateCursorRules(opts) {
1812
+ const sdk = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
1813
+ return `---
1814
+ description: Rules for working with the Amba SDK in this project
1815
+ globs: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
1816
+ ---
1817
+
1818
+ # Amba SDK Rules
1819
+
1820
+ ## Project Setup
1821
+ - Project ID: \`${opts.projectId}\`
1822
+ - SDK: \`${sdk}\`
1823
+ - API URL: \`https://api.amba.dev\`
1824
+
1825
+ ## Environment Variables
1826
+ - Always read Amba config from environment variables, never hardcode
1827
+ - Use \`process.env.AMBA_PROJECT_ID\` and \`process.env.AMBA_API_KEY\`
1828
+ - The .env.local file contains the project credentials
1829
+
1830
+ ## SDK Patterns
1831
+ ${opts.framework === "expo" ? `- Import the \`Amba\` singleton from \`@layers/amba-expo\`
1832
+ - Call \`Amba.init({ projectId, apiKey })\` once in the root layout (inside a \`useEffect\`)
1833
+ - The Expo wrapper auto-wires AsyncStorage, push tokens, and Apple/Google sign-in
1834
+ - Use \`Amba.signInWithApple()\` / \`Amba.signInWithGoogle()\` for social auth one-liners
1835
+ - Call \`Amba.track()\` for engagement events, don't build custom analytics` : `- Initialize the Amba client once and export it as a singleton
1836
+ - Use \`Amba.client.track()\` for all engagement events
1837
+ - Use \`Amba.client.config.get()\` for remote configuration
1838
+ - Use \`Amba.client.auth\` for sign-up / sign-in flows`}
1839
+
1840
+ ## Push Notifications
1841
+ - Register push tokens via the SDK \`registerPushToken()\` method
1842
+ - Handle notification payloads using the SDK's notification listener
1843
+ - Don't implement custom push token management
1844
+
1845
+ ## Remote Config
1846
+ - Use remote config for feature flags and dynamic values
1847
+ - Always provide sensible defaults when reading config values
1848
+ - Config values are cached — don't fetch on every render
1849
+
1850
+ ## Streaks
1851
+ - Streaks are server-managed; the SDK provides read-only access
1852
+ - Use \`track()\` to record qualifying events — the server evaluates streaks
1853
+ - Show streak state from \`streak.current()\`, don't calculate manually
1854
+
1855
+ ## Best Practices
1856
+ - Don't store Amba API keys in source code or commit them to git
1857
+ - Use \`.env.local\` for local development credentials
1858
+ - The client API key (prefixed \`amb_dev_ck_\` or \`amb_live_ck_\`) is safe for client-side use
1859
+ - Server keys (prefixed \`amb_dev_sk_\` or \`amb_live_sk_\`) must stay server-side only
1860
+ `;
1861
+ }
1862
+ /**
1863
+ * Write both context files to the project directory.
1864
+ */
1865
+ async function generateContextFiles(opts) {
1866
+ const files = [];
1867
+ await writeFile(join(opts.cwd, "AMBA.md"), generateAmbaMarkdown(opts), "utf-8");
1868
+ files.push("AMBA.md");
1869
+ const cursorDir = join(opts.cwd, ".cursor", "rules");
1870
+ await mkdir(cursorDir, { recursive: true });
1871
+ await writeFile(join(cursorDir, "amba.mdc"), generateCursorRules(opts), "utf-8");
1872
+ files.push(".cursor/rules/amba.mdc");
1873
+ return files;
1874
+ }
1875
+ //#endregion
1876
+ //#region src/skill-installer.ts
1877
+ /**
1878
+ * Amba skill bundle installer.
1879
+ *
1880
+ * The bundled `skill-bundle/` directory contains `SKILL.md` plus a
1881
+ * `references/` folder with one file per Amba surface area. The skill
1882
+ * teaches the agent the classify → confirm → wire-up playbook for
1883
+ * adding Amba primitives to a developer's codebase. See
1884
+ * `packages/cli/skill-bundle/SKILL.md` for the source.
1885
+ *
1886
+ * Why ship a bundled skill (instead of `npx skills add layers/amba`):
1887
+ * the CLI run is the same install step. Bundling avoids a second
1888
+ * fetch, keeps the skill version locked to the CLI version, and means
1889
+ * `amba init` produces a fully-wired agent on offline networks too.
1890
+ *
1891
+ * Cross-agent install — we drop the same body into every detected
1892
+ * coding agent's skill directory. Agents read their own location:
1893
+ *
1894
+ * - Claude Code: `.claude/skills/amba/`
1895
+ * - Cursor: `.cursor/skills/amba/`
1896
+ * - Codex CLI: `.codex/skills/amba/`
1897
+ * - Windsurf: `.windsurf/skills/amba/`
1898
+ *
1899
+ * We also write a project-root copy at `.agents/skills/amba/` which
1900
+ * the `npx skills add ...` distribution tool reads from (and which any
1901
+ * agent that pre-registers an `.agents/skills/` lookup picks up). Five
1902
+ * locations, one body — same fan-out pattern `writeAllSetupTargets`
1903
+ * already uses for the legacy setup guide.
1904
+ *
1905
+ * Idempotency: re-running `amba init` overwrites the bundled
1906
+ * `SKILL.md` and `references/*.md` so every developer ends up on the
1907
+ * latest playbook. We back up a pre-existing `SKILL.md` to a sibling
1908
+ * `.bak-<unix-ms>` ONLY when its first frontmatter key (`name:`) is
1909
+ * not `amba` — that's the signal it was hand-authored / unrelated and
1910
+ * shouldn't be silently clobbered. Bundled Amba files are refreshed
1911
+ * without backup.
1912
+ */
1913
+ /**
1914
+ * Resolve the bundled skill source directory.
1915
+ *
1916
+ * The bundle lives at `<package-root>/skill-bundle/` in both the
1917
+ * source tree and the published tarball (via `files[]` in
1918
+ * `package.json`). From a built `dist/commands/init.js` the path is
1919
+ * `../../skill-bundle/`. From the source tree
1920
+ * (`src/skill-installer.ts`) the path is `../skill-bundle/`. We try
1921
+ * both relative to `import.meta.url` and pick the one that exists.
1922
+ */
1923
+ async function resolveBundleDir() {
1924
+ const here = fileURLToPath(import.meta.url);
1925
+ const candidates = [
1926
+ join(dirname(here), "..", "skill-bundle"),
1927
+ join(dirname(here), "..", "..", "skill-bundle"),
1928
+ join(dirname(here), "..", "..", "..", "skill-bundle")
1929
+ ];
1930
+ for (const candidate of candidates) try {
1931
+ await access(join(candidate, "SKILL.md"));
1932
+ return candidate;
1933
+ } catch {}
1934
+ throw new Error(`Amba skill bundle not found. Looked at: ${candidates.join(", ")}. This is a CLI packaging bug — please file an issue at https://github.com/layers/amba/issues.`);
1935
+ }
1936
+ /**
1937
+ * List the five install targets the CLI fans out to. Project-local
1938
+ * directories (`<cwd>/.claude/skills/amba/`, etc.) — the agent reads
1939
+ * project-local skills with priority over global ones, so this is the
1940
+ * canonical install location for a tool meant to wire up THIS project.
1941
+ */
1942
+ function skillInstallTargets(cwd) {
1943
+ return [
1944
+ {
1945
+ kind: "claude-code",
1946
+ path: join(cwd, ".claude", "skills", "amba")
1947
+ },
1948
+ {
1949
+ kind: "cursor",
1950
+ path: join(cwd, ".cursor", "skills", "amba")
1951
+ },
1952
+ {
1953
+ kind: "codex",
1954
+ path: join(cwd, ".codex", "skills", "amba")
1955
+ },
1956
+ {
1957
+ kind: "windsurf",
1958
+ path: join(cwd, ".windsurf", "skills", "amba")
1959
+ },
1960
+ {
1961
+ kind: "generic-agents",
1962
+ path: join(cwd, ".agents", "skills", "amba")
1963
+ }
1964
+ ];
1965
+ }
1966
+ /**
1967
+ * Copy SKILL.md + every file under references/ into the target
1968
+ * directory. Creates the directory tree if missing. Returns the list
1969
+ * of files touched and any backup paths.
1970
+ *
1971
+ * Backup rule: a pre-existing `SKILL.md` is backed up to
1972
+ * `SKILL.md.bak-<unix-ms>` ONLY when its first `name:` frontmatter
1973
+ * line is NOT `name: amba`. That's the signal it was authored by the
1974
+ * user for an unrelated purpose and shouldn't be silently overwritten.
1975
+ * Amba-owned files get refreshed without backup so developers
1976
+ * tracking the latest playbook don't accumulate junk.
1977
+ */
1978
+ async function installSkillBundle(cwd, options = {}) {
1979
+ const bundleDir = options.bundleDir ?? await resolveBundleDir();
1980
+ const targets = skillInstallTargets(cwd);
1981
+ const results = [];
1982
+ const skillBody = await readFile(join(bundleDir, "SKILL.md"), "utf-8");
1983
+ const referencesDir = join(bundleDir, "references");
1984
+ let referenceEntries = [];
1985
+ try {
1986
+ referenceEntries = await readdir(referencesDir);
1987
+ } catch {
1988
+ referenceEntries = [];
1989
+ }
1990
+ const referenceBodies = /* @__PURE__ */ new Map();
1991
+ for (const entry of referenceEntries) {
1992
+ if (!entry.endsWith(".md")) continue;
1993
+ const body = await readFile(join(referencesDir, entry), "utf-8");
1994
+ referenceBodies.set(entry, body);
1995
+ }
1996
+ for (const target of targets) {
1997
+ await mkdir(join(target.path, "references"), { recursive: true });
1998
+ const files = [];
1999
+ const skillPath = join(target.path, "SKILL.md");
2000
+ const skillBackup = await backupIfForeignSkill(skillPath);
2001
+ await writeFile(skillPath, skillBody, "utf-8");
2002
+ files.push({
2003
+ path: skillPath,
2004
+ backedUpTo: skillBackup
2005
+ });
2006
+ for (const [name, body] of referenceBodies) {
2007
+ const refPath = join(target.path, "references", name);
2008
+ await writeFile(refPath, body, "utf-8");
2009
+ files.push({
2010
+ path: refPath,
2011
+ backedUpTo: null
2012
+ });
2013
+ }
2014
+ results.push({
2015
+ target,
2016
+ files
2017
+ });
2018
+ }
2019
+ return results;
2020
+ }
2021
+ /**
2022
+ * If a pre-existing `SKILL.md` at `path` has a different `name:`
2023
+ * frontmatter value than `amba`, copy it to a timestamped backup and
2024
+ * return the backup path. Otherwise return null (no backup needed).
2025
+ *
2026
+ * Frontmatter parsing is intentionally cheap — just the first
2027
+ * occurrence of `^name:\s*<value>` within the leading `---` block. A
2028
+ * malformed file falls through to "back up" (safe default).
2029
+ */
2030
+ async function backupIfForeignSkill(path) {
2031
+ let raw;
2032
+ try {
2033
+ raw = await readFile(path, "utf-8");
2034
+ } catch {
2035
+ return null;
2036
+ }
2037
+ const nameMatch = raw.slice(0, 512).match(/^name:\s*([A-Za-z0-9_-]+)/m);
2038
+ if (nameMatch && nameMatch[1] === "amba") return null;
2039
+ const backupPath = `${path}.bak-${Date.now()}`;
2040
+ await writeFile(backupPath, raw, "utf-8");
2041
+ return backupPath;
2042
+ }
2043
+ /**
2044
+ * Convenience: returns the count of skill files written and the list
2045
+ * of target kinds, for the CLI's done-message summary.
2046
+ */
2047
+ function summarizeSkillInstall(results) {
2048
+ return {
2049
+ totalFiles: results.reduce((sum, r) => sum + r.files.length, 0),
2050
+ targetKinds: results.map((r) => r.target.kind)
2051
+ };
2052
+ }
2053
+ //#endregion
2054
+ //#region ../mcp/dist/expo-build-prompt.js
2055
+ /**
2056
+ * Canonical long-form Amba setup guide — markdown body.
2057
+ *
2058
+ * Companion to the short-form `instructions` field served by the MCP
2059
+ * server's initialize response. The pointer "Full guide: amba://setup"
2060
+ * in those instructions tells the agent to fetch this resource when it
2061
+ * needs more detail than the ~1 KB summary provides.
2062
+ *
2063
+ * Consumed by:
2064
+ *
2065
+ * - The MCP resource at `amba://setup`, registered by
2066
+ * `registerAllResources()` in `./index.ts` and exposed by the
2067
+ * hosted MCP server at `mcp.amba.dev`. Any client (Claude Code,
2068
+ * Cursor, Codex, Cowork, etc.) can fetch it via `resources/read`.
2069
+ *
2070
+ * Twin: this body is the server-side mirror of
2071
+ * `packages/cli/skill-bundle/SKILL.md`, which the CLI installs locally
2072
+ * during `npx @layers/amba init`. The two surfaces target two
2073
+ * different audiences:
2074
+ *
2075
+ * - `SKILL.md` ships to a local `.claude/skills/amba/` and assumes
2076
+ * the agent CAN shell out (the bootstrap path can `npx @layers/amba
2077
+ * signup`). It also writes credentials into `.env.local` + `~/.amba/`.
2078
+ * - `AMBA_SETUP_GUIDE_MD` (this constant) is served by the hosted MCP
2079
+ * and assumes the agent CANNOT shell out (e.g. Claude.ai web).
2080
+ * The bootstrap path must therefore use the `amba_developer_signup`
2081
+ * MCP tool (the only pre-auth tool the server registers).
2082
+ *
2083
+ * The playbook shape (Step 0 → Step 1 classify → Step 2 confirm →
2084
+ * Step 3 wire → Step 4 report) is identical between the two, so an
2085
+ * agent reading either ends up at the same outcome. A drift gate test
2086
+ * in `amba-setup.test.ts` asserts the structural anchors match.
2087
+ *
2088
+ * Taxonomy: the SDK capability map below is grouped under the canonical
2089
+ * 7-category taxonomy (Identity / Engagement / Gamification / Economy /
2090
+ * Social / Analytics / Infrastructure) — same shape as `categories.ts`,
2091
+ * the marketing-site feature grid, and the docs IA. Drift is caught by
2092
+ * `amba-setup.test.ts`.
2093
+ *
2094
+ * The body is plain CommonMark — no MDX, no JSX — so it renders
2095
+ * identically wherever it lands.
2096
+ */
2097
+ const AMBA_SETUP_GUIDE_MD = `# Amba
2098
+
2099
+ ## What Amba is
2100
+
2101
+ Amba is the backend your app calls at runtime via the SDK. The MCP tools (\`amba_*\`) are for provisioning at build time — call them as the coding agent.
2102
+
2103
+ One Amba project = one isolated tenant database + one set of API keys + a hosted MCP at \`mcp.amba.dev\` for agent-driven provisioning. The runtime traffic goes through an SDK (\`@layers/amba-*\` on npm, \`Amba\` on SPM / Maven / pub.dev). The provisioning traffic — what you're about to do — goes through the tools listed below.
2104
+
2105
+ Your job is to take the user from "I have an app" to "Amba is wired in and the features are real" in one pass, without 12 follow-up questions. The journey below is the standard playbook. Don't deviate without a reason.
2106
+
2107
+ ## Step 0 — Bootstrap credentials
2108
+
2109
+ You're in an MCP client (Claude.ai web, Claude Desktop, Cursor, Windsurf, etc.) without a local CLI. The MCP server has one tool that works **without** a Bearer token: \`amba_developer_signup\`. Use it to mint a personal access token (PAT), project, and client/server keys in a single round trip — no browser, no email verification.
2110
+
2111
+ Call:
2112
+
2113
+ \`\`\`
2114
+ amba_developer_signup({
2115
+ email: "sandbox-<unix-seconds>-<6char-nonce>@layers.com",
2116
+ password: "<32 random base64url chars>",
2117
+ name: "amba-sandbox-agent"
2118
+ })
2119
+ \`\`\`
2120
+
2121
+ The response shape:
2122
+
2123
+ \`\`\`json
2124
+ {
2125
+ "data": {
2126
+ "pat": "amb_dpat_…",
2127
+ "developer": { "id": "…", "email": "…", "tier": "agent_sandbox" },
2128
+ "project": {
2129
+ "project_id": "…",
2130
+ "client_key": "amb_ck_…",
2131
+ "server_key": "amb_sk_…",
2132
+ "provisioning_status": "provisioning",
2133
+ "verify_url": "https://app.amba.dev/verify?token=…",
2134
+ "verify_token": "…"
2135
+ }
2136
+ }
2137
+ }
2138
+ \`\`\`
2139
+
2140
+ After signup, the PAT is in your hand — keep building in THIS session immediately. Pass \`pat: "<the PAT from the response>"\` as an inline argument on every subsequent \`amba_*\` tool call; the inline arg overrides the inbound Bearer for that one call so no client config change is needed mid-session. For future sessions, write the matching snippet from \`mcp_config\` to the customer's MCP-client config file so the static Bearer takes over automatically on the next launch.
2141
+
2142
+ The project status is \`"provisioning"\` immediately after signup; the per-tenant DB activates within ~5 seconds. Don't block — your next call may briefly retry, that's fine. If you want to be polite, call \`amba_projects_get_provisioning_status({ project_id })\` once and proceed when it returns \`"active"\` (or after 15s, whichever first).
2143
+
2144
+ Tell the user where their credentials live:
2145
+
2146
+ - \`pat\` — the Bearer they should configure in this MCP client's settings (and treat like a password).
2147
+ - \`project_id\`, \`client_key\` — the values they paste into their app's \`.env.local\` / \`.env\`.
2148
+ - \`server_key\` — never ship to user devices; only into a server \`.env\` or a secret manager. The \`amb_dev_sk_\` / \`amb_live_sk_\` prefix is the marker.
2149
+
2150
+ **Already have a PAT?** Skip the signup. Call \`amba_developer_me({})\` to verify the Bearer; if it succeeds, either reuse the most recent project (\`amba_projects_list\`) or call \`amba_projects_create({ name: "<app-name>", platform: "all" })\` and then \`amba_api_keys_create\` twice to mint client + server keys for \`environment: "development"\`.
2151
+
2152
+ ## Step 1 — Classify the app
2153
+
2154
+ Look at what the user told you and at any files they shared. You're trying to pick one of ten presets in 30 seconds, not write a treatise. Inputs:
2155
+
2156
+ - The user's prompt — "I'm building a fitness tracker" / "a marketplace for…" / "a Duolingo for X".
2157
+ - README content if shared.
2158
+ - \`package.json\` / \`pubspec.yaml\` / \`build.gradle.kts\` / \`Package.swift\` — framework + dependencies.
2159
+ - Screen / view names — \`WorkoutScreen\`, \`MatchView\`, \`LessonPage\`, \`CartView\`, \`ProductDetail\`, \`ChatThread\`.
2160
+
2161
+ Pick the closest match:
2162
+
2163
+ | Preset | When | Default Amba surfaces |
2164
+ | --- | --- | --- |
2165
+ | **fitness** | health / fitness tracker (workouts, steps, meditation) | identity (Apple+Google), push, XP, achievements, streaks, leaderboards, content (daily tips) |
2166
+ | **social** | social network / community (friends, feeds, groups) | identity, push, friends, groups, feeds, messaging, moderation, content |
2167
+ | **marketplace** | commerce / marketplace (catalog, stores, payments) | identity, push, catalog, stores, currencies (loyalty), reviews, segments |
2168
+ | **productivity** | productivity / SaaS tool (collaboration, milestones) | identity (Apple+Google+OTP), push, collections, achievements, content (changelog), segments |
2169
+ | **education** | education / learning app (courses, progress, rewards) | identity, push, XP, achievements, streaks, leaderboards, content (lessons), onboarding |
2170
+ | **game** | game / casual gaming | identity (anon-first), push, XP, achievements, currencies, inventory, leaderboards, challenges, stores |
2171
+ | **dating** | dating / matching app | identity (phone-OTP), push, friends (matches), messaging, moderation (heavy), reviews |
2172
+ | **content_creator** | content platform (feeds, subscriptions, tips) | identity, push, feeds, content, currencies (tips), referrals, stores (subscriptions) |
2173
+ | **ai_chatbot** | AI / chatbot / assistant app | identity, push, AI prompts, currencies (credits), content (system prompts), onboarding |
2174
+ | **custom** | none of the above | pick features individually |
2175
+
2176
+ Detection heuristics, in priority order:
2177
+
2178
+ 1. The user's own description — most direct signal.
2179
+ 2. Filename match in \`screens/\` or \`views/\` (high signal).
2180
+ 3. Dependency in \`package.json\` — \`react-native-health\` → fitness, \`@stream-io/*\` → social or dating, \`@stripe/*\` → marketplace, \`revenuecat\` → marketplace or content_creator.
2181
+ 4. README copy — "fitness", "habit", "match", "chat", "store", "subscription".
2182
+
2183
+ If two presets tie, pick the one the user's filenames match more closely. If still tied or no signal, fall back to **custom** and let them pick.
2184
+
2185
+ ## Step 2 — Confirm with the user
2186
+
2187
+ Use a single multi-choice. Quote the surfaces from the table above so they know what they're getting.
2188
+
2189
+ **Question 1: classification + scope**
2190
+
2191
+ > I'm reading this as a **\\{kind\\}** app. I'd wire up: **\\{surfaces\\}**. Sound right?
2192
+ >
2193
+ > 1. Yes, wire it up as proposed (Recommended)
2194
+ > 2. Same kind but I want to pick features individually
2195
+ > 3. Wrong kind — let me pick from the list
2196
+ > 4. Custom — I'll pick features manually
2197
+
2198
+ If the user picks 1, go to Step 3. If 2 or 4, follow up with a multi-select of surfaces. If 3, present the table again and pick a different preset.
2199
+
2200
+ **Question 2 (preset-specific):** see the per-surface sub-resources (\`amba://setup/<surface>\`) for the full "Common follow-ups" list. Examples:
2201
+
2202
+ - **fitness / game / education** — leaderboard scope? (all-time, weekly, daily, none)
2203
+ - **game / content_creator** — virtual currency name? (\`gold\`, \`gems\`, \`coins\`, \`credits\` — defaults to \`coins\`)
2204
+ - **content_creator** — monetization? (tips, subscriptions, both)
2205
+ - **dating** — phone OTP or email-only? (phone strongly recommended)
2206
+ - **ai_chatbot** — daily free credit cap?
2207
+
2208
+ Batch the follow-ups into one or two multi-choice rounds. Don't drip-feed six separate questions.
2209
+
2210
+ ## Step 3 — Wire it up
2211
+
2212
+ For each surface in the confirmed set, read the relevant sub-resource and execute its procedure. Each sub-resource is the full per-surface playbook (MCP tools + SDK init per stack + common follow-ups + re-run behavior):
2213
+
2214
+ - **identity** (auth, anonymous/Apple/Google/OTP/magic-link, link/unlink) → \`amba://setup/identity\`
2215
+ - **engagement** (push, segments, content libraries, onboarding flows, deeplinks, referrals, tracked links) → \`amba://setup/engagement\`
2216
+ - **gamification** (XP rules, achievements, streaks, leaderboards, challenges) → \`amba://setup/gamification\`
2217
+ - **economy** (currencies, catalog, stores, inventory) → \`amba://setup/economy\`
2218
+ - **social** (friends, groups, feeds, messaging, moderation, reviews) → \`amba://setup/social\`
2219
+ - **infrastructure** (collections / DB tables, functions, analytics, AI prompts, media, secrets, configs, integrations, sites) → \`amba://setup/infrastructure\`
2220
+
2221
+ The general flow for every surface:
2222
+
2223
+ 1. **Detect stack.** Look at \`package.json\`, \`pubspec.yaml\`, \`build.gradle.kts\`, \`ios/*.xcodeproj\`. The detection rules:
2224
+ - \`pubspec.yaml\` present → Flutter.
2225
+ - \`package.json\` with \`expo\` → Expo.
2226
+ - \`package.json\` with \`react-native\` (no \`expo\`) → bare React Native.
2227
+ - \`package.json\` with \`react\` (no \`react-native\`) → web (or Next.js — same SDK).
2228
+ - \`Package.swift\` or \`*.xcodeproj\` only → iOS Swift.
2229
+ - \`build.gradle.kts\` or \`build.gradle\` with \`com.android.application\` → Android Kotlin.
2230
+ - Multiple (e.g. \`ios/\` + \`android/\` inside an Expo repo) → Expo wins.
2231
+
2232
+ 2. **Create resources via MCP.** Call the \`amba_<surface>_create\` tools to mint the definitions. Always include \`project_id\` from the project you created in Step 0. Always show the user the tool call before making destructive changes (creating a resource isn't destructive — but creating 30 of them is noisy).
2233
+
2234
+ 3. **Write SDK init code.** Drop the per-stack snippet (from the sub-resource) into the user's entry file. Detection:
2235
+ - Expo / React Native: \`app/_layout.tsx\`, \`App.tsx\`, \`index.js\` (in that order)
2236
+ - web / Next.js: \`app/layout.tsx\`, \`pages/_app.tsx\`, \`src/main.tsx\`, \`src/App.tsx\`
2237
+ - iOS Swift: \`Sources/<App>/<App>App.swift\`, \`App/AppDelegate.swift\`
2238
+ - Android Kotlin: \`app/src/main/java/.../<App>.kt\` (the \`Application\` subclass — create one if missing)
2239
+ - Flutter: \`lib/main.dart\`
2240
+
2241
+ Always make additive edits — \`await Amba.configure(...)\` next to existing init, not replacing it. Never refactor existing auth or storage code; if the user has Firebase Auth or Supabase, leave it. Amba's auth is opt-in per call.
2242
+
2243
+ 4. **Run the project's existing test command** to confirm nothing broke. Detection:
2244
+ - \`package.json\` \`scripts.test\` → \`npm test\` (or \`pnpm test\` if \`pnpm-lock.yaml\` present)
2245
+ - \`pubspec.yaml\` → \`flutter test\`
2246
+ - \`build.gradle.kts\` → \`./gradlew test\` (skip on first wire-up — slow)
2247
+ - iOS — skip (need a simulator).
2248
+
2249
+ If tests fail because of your edits, undo the offending edit and surface a clear error. If they fail for unrelated reasons (pre-existing red), note it and proceed.
2250
+
2251
+ 5. **Verify with the SDK.** Tell the user to call \`Amba.diagnostics.ping()\` (\`Amba.Diagnostics.Ping()\` on Unity) in their entry file. It returns \`{ ok, server_project_id, environment, key_fingerprint, latency_ms }\`. \`ok: true\` with the expected \`server_project_id\` confirms the wiring.
2252
+
2253
+ ## Step 4 — Report
2254
+
2255
+ Tell the user a structured summary. Use this exact shape so they can skim it fast:
2256
+
2257
+ \`\`\`
2258
+ Amba is wired in. Here's what changed:
2259
+
2260
+ DONE
2261
+ - identity: Apple + Google sign-in available; signInAnonymously() called at app start
2262
+ - gamification: 3 achievements, 1 streak, 1 leaderboard created
2263
+ resources: first_workout, week_warrior, century_club / daily_workout / weekly_xp
2264
+ - engagement: push registration wired; default segment "active_users" created
2265
+
2266
+ SKIPPED (low signal — re-run with /amba <feature> if you want them)
2267
+ - economy: no in-app currency UI found in your screens
2268
+ - social: no friends/feed surfaces found
2269
+
2270
+ NEEDS YOUR INPUT
2271
+ - Apple Sign In: add the "Sign in with Apple" capability in Xcode > Signing & Capabilities.
2272
+ - Google Sign In: paste your Google OAuth client ID into amba_projects_update({ google_oauth_client_id: "..." }).
2273
+ - APNs / FCM: upload credentials in app.amba.dev before push delivers.
2274
+
2275
+ NEXT STEPS
2276
+ - Paste AMBA_CLIENT_KEY into your build env (already shown above)
2277
+ - Trigger a workout in your existing flow — watch the achievement unlock + XP land
2278
+ - Open https://app.amba.dev to see users pour in
2279
+ \`\`\`
2280
+
2281
+ Be specific. List resources by key, not "some achievements". If something needs the user's input (third-party credentials, OAuth client IDs, push certs), say it clearly with the exact next action.
2282
+
2283
+ ## Stance (read this once)
2284
+
2285
+ - **Don't ask which surfaces to use.** Classify, then confirm in one multi-choice. The taxonomy is the whole point.
2286
+ - **Default to additive, non-breaking changes.** Don't refactor existing auth, storage, or networking code. Drop in \`await Amba.configure(...)\` next to whatever the user already has.
2287
+ - **Never create resources without the user's confirmation in Step 2.** A 3rd-party "convenience" achievement called \`first_login\` is debt.
2288
+ - **If something is genuinely ambiguous** (leaderboard scope, currency real-money vs virtual, dating phone vs email), ask via a follow-up multi-choice. Don't guess and don't paragraph-it.
2289
+ - **clientKey vs serverKey.** \`AMBA_CLIENT_KEY\` (\`amb_dev_ck_…\` in dev, \`amb_live_ck_…\` in prod) ships to user devices. \`AMBA_SERVER_KEY\` (\`amb_dev_sk_…\` / \`amb_live_sk_…\`) never does — only into server \`.env\` or a secret manager. Mixing them is the #1 security mistake; if you're writing into a file that ships with the app binary, it's the client key, period.
2290
+ - **Don't echo the PAT in chat output on every call.** Showing it once after signup is fine; do not repeat it.
2291
+
2292
+ ## Get credentials (cheat sheet)
2293
+
2294
+ - No terminal, in an MCP client: call \`amba_developer_signup\` (no Bearer required) — this guide's Step 0.
2295
+ - With a terminal: \`npx -y @layers/amba init\` signs up, mints a project + client/server keys, writes \`.env.local\` + \`AMBA.md\`, installs the \`/amba\` skill, and wires \`mcpServers.amba\` into every detected MCP-client config in one command. Auto-detects non-TTY invocations (the coding-agent bash-tool case) and runs headlessly.
2296
+ - Bind the sandbox account to a real email later: \`npx @layers/amba claim me@example.com\`. The backend emails a one-click magic link; clicking it lifts the sandbox cap to the Free tier.
2297
+ - Hosted MCP endpoint: \`https://mcp.amba.dev/mcp\` (Streamable HTTP, Bearer auth).
2298
+
2299
+ ## SDKs
2300
+
2301
+ | Stack | Registry | Package |
2302
+ |---|---|---|
2303
+ | Browser / Node / React / React Native / Expo | npm | \`@layers/amba-{web,node,react,react-native,expo}\` |
2304
+ | Swift | SPM | \`https://github.com/layers/amba-sdk-ios\` |
2305
+ | Kotlin | Maven Central | \`com.layers.amba:amba-sdk-android\` |
2306
+ | Flutter | pub.dev | \`amba\` |
2307
+ | Unity | UPM (git) | \`https://github.com/layers/amba-sdk-unity.git\` |
2308
+
2309
+ All SDKs expose the same surface: \`Amba.configure({ projectId, apiKey })\`, then \`Amba.events.track(...)\`, \`Amba.users.*\`, \`Amba.collections.*\`, etc. Per-stack quickstart pages with the exact initialization snippet: \`https://docs.amba.dev/sdk/<framework>\`.
2310
+
2311
+ ## What Amba does
2312
+
2313
+ ### Identity
2314
+ - **users** — app-user registry. Auto-created on first SDK call; admin via \`amba_users_*\`.
2315
+ - **roles + permissions** — RBAC. Define with \`amba_roles_create\`; assign via \`amba_roles_assign\`.
2316
+ - **api_keys** — client + server keys per project. Mint via \`amba_api_keys_create\`.
2317
+
2318
+ ### Engagement
2319
+ - **onboarding** — multi-step first-run flows. Define with \`amba_onboarding_create\`; SDK \`Amba.onboarding.next()\`.
2320
+ - **segments** — user cohorts. Define with \`amba_segments_create\`; used as push/feed targets.
2321
+ - **push** — scheduled or triggered notifications. Chain: configure integrations (apns/fcm) → \`amba_push_campaigns_create\` → \`amba_push_campaigns_send\` (or schedule).
2322
+ - **referrals** — referral codes. Define with \`amba_referrals_create\`.
2323
+ - **deeplinks** — universal links. Set domain with \`amba_deeplinks_set_config\`.
2324
+ - **tracked_links** — UTM-tagged outbound links. Define with \`amba_tracked_links_create\`.
2325
+ - **content** — episodic delivery (lessons, quotes, daily prompts). Chain: \`amba_content_libraries_create\` → \`amba_content_items_add\` → \`amba_content_schedules_create\`.
2326
+
2327
+ ### Gamification
2328
+ - **xp** — experience points + level. Define rules with \`amba_xp_rules_create\`; SDK \`Amba.xp.getBalance\`.
2329
+ - **achievements** — earnable badges. Define with \`amba_achievements_create\`; unlock via xp rules or \`amba_inventory_grant_item\`.
2330
+ - **streaks** — recurring engagement counters. Define with \`amba_streaks_create\`; client calls \`Amba.streaks.qualify(key)\`.
2331
+ - **leaderboards** — ranked user lists. Define with \`amba_leaderboards_create\`; populated from events.
2332
+ - **challenges** — time-bounded goals. Define with \`amba_challenges_create\`; progress via SDK.
2333
+
2334
+ ### Economy
2335
+ - **currencies** — virtual currencies (coins, gems). Define with \`amba_currencies_create\`; grant via \`amba_currencies_grant\` or event rules via \`amba_currency_grant_rules_create\`; debit via \`amba_currencies_spend\` (atomic, rejects on insufficient funds).
2336
+ - **catalog + stores** — purchasable items + storefronts. Chain: \`amba_catalog_items_create\` → \`amba_catalog_items_set_price\` → \`amba_stores_create\` → \`amba_stores_add_listing\`. (Define currency first.)
2337
+ - **inventory** — items users own. Read via SDK \`Amba.inventory.*\`; grant with \`amba_inventory_grant_item\`.
2338
+
2339
+ ### Social
2340
+ - **friendships** — friend graph. SDK \`Amba.friends.*\`; admin via \`amba_friendships_*\`.
2341
+ - **groups** — guilds/parties/chats. Define with \`amba_groups_create\`; members managed via SDK + admin tools.
2342
+ - **messaging** — DMs + group chat. Enabled by default; moderate via \`amba_messaging_*\`.
2343
+ - **feeds** — algorithmic activity feeds. Define ranking with \`amba_feeds_rules_create\`.
2344
+ - **reviews** — user-submitted reviews. Enabled by default; moderate via \`amba_reviews_*\`.
2345
+ - **moderation** — content review queue + trust scores. Configure with \`amba_moderation_configure\`; review via \`amba_moderation_queue_list\`.
2346
+
2347
+ ### Analytics
2348
+ - **events** — track user actions. SDK \`Amba.events.track()\`; query via \`amba_events_count\`.
2349
+ - **sessions** — session telemetry. Tracked automatically; query via \`amba_sessions_list\`.
2350
+ - **analytics** — funnels + retention. Query via \`amba_analytics_get\`.
2351
+
2352
+ ### Infrastructure
2353
+ - **collections** — your own typed key-value tables. Define with \`amba_collections_create\`; read/write from SDK \`Amba.client.*\`.
2354
+ - **functions** — serverless TypeScript handlers. Deploy with \`amba_functions_deploy\`; schedule with \`amba_functions_schedule\`.
2355
+ - **sites** — static site hosting at \`*.app.amba.host\`. Deploy with \`amba_sites_deploy\`.
2356
+ - **media** — file storage + CDN. Upload via \`amba_media_upload\`.
2357
+ - **secrets** — env vars for functions. Set via \`amba_secrets_set\`.
2358
+ - **configs** — remote config flags. Define with \`amba_remote_configs_create\`.
2359
+ - **integrations** — third-party webhooks (RevenueCat, Superwall, AppsFlyer, etc.). Configure with \`amba_integrations_configure\`.
2360
+ - **ai_prompts** — versioned LLM prompts callable from SDK. Define with \`amba_ai_prompts_create\`; call via \`amba_ai_prompts_invoke\`.
2361
+ `;
2362
+ /**
2363
+ * Canonical Amba Expo build prompt — markdown body (no MDX frontmatter).
2364
+ *
2365
+ * Source of truth for three customer-facing surfaces:
2366
+ *
2367
+ * 1. The published docs page at
2368
+ * `https://docs.amba.dev/prompts/expo-build` — the MDX file at
2369
+ * `apps/docs/content/docs/prompts/expo-build.mdx` ships the same
2370
+ * body wrapped in fumadocs frontmatter.
2371
+ * 2. The MCP resource `amba://prompts/expo-build` registered by
2372
+ * `registerAllResources()` in `./index.ts` and exposed by the
2373
+ * hosted MCP server at `mcp.amba.dev`.
2374
+ * 3. The inlined snapshot baked into the `/amba-build` Claude Code
2375
+ * skill by `amba init --sandbox` (see `packages/cli/src/skills.ts`).
1331
2376
  *
1332
2377
  * Drift between this constant and the MDX file is caught by
1333
2378
  * `expo-build-prompt.test.ts` — that test reads the MDX from disk,
@@ -1344,8 +2389,8 @@ function formatManualMcpSnippet(pat) {
1344
2389
  * so it renders identically as `.md` (the MCP / skill consumers) and
1345
2390
  * as `.mdx` (the docs site).
1346
2391
  */
1347
- const EXPO_BUILD_PROMPT_MD = `> **Last reviewed:** 2026-05-16. The canonical version of this page lives
1348
- > at [docs.amba.dev/docs/prompts/expo-build](https://docs.amba.dev/docs/prompts/expo-build).
2392
+ const EXPO_BUILD_PROMPT_MD = `> **Last reviewed:** 2026-05-17. The canonical version of this page lives
2393
+ > at [docs.amba.dev/prompts/expo-build](https://docs.amba.dev/prompts/expo-build).
1349
2394
  > If you're reading an inlined snapshot from your
1350
2395
  > \`.claude/skills/amba-build/SKILL.md\`, check the URL above for updates.
1351
2396
 
@@ -1361,7 +2406,7 @@ The CLI handles signup, project provisioning, env-file writes, and MCP
1361
2406
  client config wiring in one command:
1362
2407
 
1363
2408
  \`\`\`bash
1364
- npx @layers/amba init --sandbox
2409
+ npx -y @layers/amba init
1365
2410
  \`\`\`
1366
2411
 
1367
2412
  That's the entire setup. The CLI:
@@ -1374,14 +2419,17 @@ That's the entire setup. The CLI:
1374
2419
  4. Writes \`AMBA.md\` (project-scoped context for the agent).
1375
2420
  5. Auto-wires \`mcpServers.amba\` into every MCP client config it
1376
2421
  detects on disk — Claude Code, Cursor, Windsurf.
1377
- 6. Prints a per-client restart instruction.
2422
+ 6. Verifies the PAT against the API and confirms it's good.
1378
2423
 
1379
- After restarting your MCP client, the Amba MCP toolset is available
1380
- (\`amba_*\` tools — ~130 of them) and the agent can drive the backend
1381
- end-to-end.
2424
+ The Amba MCP toolset (\`amba_*\` tools — ~130 of them) is available to
2425
+ the agent immediately: pass the freshly-minted \`pat\` as an inline
2426
+ argument on every \`amba_*\` call in the current session. The next time
2427
+ your MCP client starts it picks the PAT up from the config as the
2428
+ inbound Bearer automatically — at that point the \`pat\` arg becomes
2429
+ optional. No restart needed; nothing for you to do.
1382
2430
 
1383
2431
  If you have the \`/amba-build\` skill installed (via
1384
- \`npx @layers/amba init --sandbox\`), invoke it directly:
2432
+ \`npx -y @layers/amba init\`), invoke it directly:
1385
2433
 
1386
2434
  \`\`\`
1387
2435
  /amba-build <DESIGN_HASH>
@@ -1401,11 +2449,13 @@ DX cascade is fixed.
1401
2449
  - **React Native bundle size** — the React Native SDK adds ~4 MB to
1402
2450
  the JS bundle today. Functional, just heavier than the long-term
1403
2451
  goal. Tracked separately.
1404
- - **Sandbox MAU cap (50)** — the agent-mode sandbox tier caps at 50
2452
+ - **Sandbox MAU cap (100)** — the agent-mode sandbox tier caps at 100
1405
2453
  monthly active users. If you blow through it during testing, call
1406
2454
  \`amba_users_reset_sandbox\` to clear the counter — that tool exists
1407
- specifically for this. Upgrade to the Free tier (claim the email)
1408
- to lift the cap to 1,000.
2455
+ specifically for this. Upgrade to the Free tier (1,000 MAU, 500 MB
2456
+ DB) by running \`amba claim me@example.com\` from the terminal — the
2457
+ backend emails a one-click magic link to the address you pass in;
2458
+ clicking it binds the account to that email and lifts the cap.
1409
2459
 
1410
2460
  ## How to read the design
1411
2461
 
@@ -1537,6 +2587,12 @@ gate.
1537
2587
  \`expo export --platform ios\`, and \`expo export --platform android\`
1538
2588
  must all succeed. If any one fails, the build fails. No
1539
2589
  "shipped iOS-only, web is broken" — the rule is parity.
2590
+ - **Don't name a tab \`settings.tsx\`.** Use \`account.tsx\` or
2591
+ \`preferences.tsx\` instead. Expo Router's static web export generates
2592
+ \`settings.html\` correctly but does not resolve direct URL navigation
2593
+ to \`/settings\` — the client-side router shows an unmatched-route
2594
+ error while other tab names work fine. (Observed in dogfood; upstream
2595
+ behavior, not an Amba issue.)
1540
2596
 
1541
2597
  ## Verification gate
1542
2598
 
@@ -1618,33 +2674,47 @@ If \`BUILD_REPORT.md\` is missing any required section, or
1618
2674
  //#endregion
1619
2675
  //#region src/skills.ts
1620
2676
  /**
1621
- * Claude Code skill installer for `amba init --sandbox`.
1622
- *
1623
- * Writes a project-local `.claude/skills/amba-build/SKILL.md` file
1624
- * containing the canonical "/goal" prompt for building a full Expo
1625
- * app with Amba as the only backend. Two behaviors:
1626
- *
1627
- * 1. **Live fetch first.** The skill's runtime instructions tell
1628
- * Claude Code to `curl` the live MDX from
1629
- * `https://docs.amba.dev/docs/prompts/expo-build.md`. So as long
1630
- * as docs is reachable, the agent always uses the latest version.
1631
- *
1632
- * 2. **Inlined snapshot fallback.** The same SKILL.md file embeds a
1633
- * verbatim copy of the prompt body — captured at CLI install
1634
- * time from `@layers/amba-mcp/prompts`. Offline agents (or ones
1635
- * whose curl 404s during the docs deploy gap) still get a
1636
- * usable prompt.
1637
- *
1638
- * Out of scope (per DX-16): Cursor / Windsurf shortcut files. Those
1639
- * editors don't ingest Claude Code skills; their users paste the
1640
- * URL directly. The CLI's success line still surfaces the
1641
- * `/amba-build` command + the docs URL so both audiences are served.
1642
- *
1643
- * Project-local install is the default because skills written into
1644
- * `~/.claude/skills/` are user-global and would persist across
1645
- * unrelated projects (and accumulate stale copies of the inlined
1646
- * snapshot from old `amba init` runs). A project-scoped install
1647
- * disappears when the user `rm -rf`s the project.
2677
+ * Per-agent skill / rule file installer for `amba init`.
2678
+ *
2679
+ * Two distinct surfaces, both project-local:
2680
+ *
2681
+ * 1. **Build task skill** — `.claude/skills/amba-build/SKILL.md`.
2682
+ * Scaffolds a full Expo app via the canonical "/goal" prompt.
2683
+ * Task-shaped: the user invokes it explicitly. Lives behind
2684
+ * `writeAmbaBuildSkill` (legacy export, unchanged).
2685
+ *
2686
+ * 2. **Reference / setup skill** — fanned out into five locations,
2687
+ * one per agent family, so the same Amba setup guide reaches
2688
+ * whatever coding agent the user has installed:
2689
+ *
2690
+ * | Surface | Path | Wrapper |
2691
+ * |------------------------------------------|-------------------------------|--------------------------|
2692
+ * | Claude Code (proactive, auto-injected) | \`CLAUDE.md\` (append) | plain markdown |
2693
+ * | Claude Code (invokable skill) | \`.claude/skills/amba/SKILL.md\` | \`description:\` frontmatter |
2694
+ * | Cursor | \`.cursor/rules/amba.mdc\` | \`alwaysApply\`/\`description\`/\`globs\` |
2695
+ * | Codex / Aider / Zed / Copilot / Gemini | \`AGENTS.md\` (append) | plain markdown |
2696
+ * | Windsurf | \`.windsurf/rules/amba.md\` | \`trigger: always_on\` |
2697
+ *
2698
+ * The two append targets (\`CLAUDE.md\`, \`AGENTS.md\`) use marker
2699
+ * fencing — \`<!-- AMBA-SETUP-START -->\` / \`<!-- AMBA-SETUP-END -->\` —
2700
+ * so a re-init refreshes only Amba's section without clobbering user
2701
+ * edits to the surrounding file. The standalone targets (\`.cursor\`,
2702
+ * \`.windsurf\`, \`.claude/skills/amba\`) live in their own files and
2703
+ * are overwritten wholesale per re-init.
2704
+ *
2705
+ * The shared body comes from \`@layers/amba-mcp/prompts\`
2706
+ * (\`AMBA_SETUP_GUIDE_MD\`) — one canonical source, five wrappers. The
2707
+ * CLI bundles that constant at publish time via tsdown's
2708
+ * \`noExternal: [/^@layers\\/amba-/]\` rule (same path \`EXPO_BUILD_PROMPT_MD\`
2709
+ * already uses).
2710
+ *
2711
+ * Vendor-name discipline
2712
+ * ----------------------
2713
+ * Everything written by this module is customer-facing. The body
2714
+ * (sourced from the MCP package) is vetted there; the wrappers below
2715
+ * intentionally avoid naming Cloudflare / GCP / Neon / Temporal /
2716
+ * Rust / WASM / UniFFI / Resend / Doppler. See \`skills.test.ts\` for
2717
+ * the per-writer drift gate.
1648
2718
  */
1649
2719
  /**
1650
2720
  * Build the contents of `.claude/skills/amba-build/SKILL.md`.
@@ -1652,17 +2722,6 @@ If \`BUILD_REPORT.md\` is missing any required section, or
1652
2722
  * Exported as a pure function so the unit tests can assert structural
1653
2723
  * properties (frontmatter, fetcher block, inlined snapshot fence)
1654
2724
  * without round-tripping through the filesystem.
1655
- *
1656
- * Structure:
1657
- * 1. YAML frontmatter — `description` so Claude Code's skill
1658
- * indexer picks it up.
1659
- * 2. Skill body — invocation instructions, fetcher one-liner,
1660
- * fallback rule.
1661
- * 3. Inlined snapshot — fenced code block containing the
1662
- * EXPO_BUILD_PROMPT_MD body verbatim. The snapshot is bounded
1663
- * by a marker comment so a future `amba update-skills` command
1664
- * can find and refresh just the inlined region without
1665
- * clobbering user customizations above it.
1666
2725
  */
1667
2726
  function buildAmbaBuildSkillContent() {
1668
2727
  return `---
@@ -1672,7 +2731,7 @@ description: Canonical /goal prompt for building a full Expo app with Amba as th
1672
2731
  # /amba-build
1673
2732
 
1674
2733
  When invoked, fetch the canonical prompt from
1675
- \`https://docs.amba.dev/docs/prompts/expo-build.md\` and use it as the
2734
+ \`https://docs.amba.dev/prompts/expo-build.md\` and use it as the
1676
2735
  \`/goal\` directive for building a full Expo app with Amba as the only
1677
2736
  backend. The user supplies a design hash (URL or description) as the
1678
2737
  argument; substitute it for every \`<DESIGN_HASH>\` placeholder in the
@@ -1692,7 +2751,7 @@ Replace \`<DESIGN_HASH>\` with:
1692
2751
  ## Fetcher
1693
2752
 
1694
2753
  \`\`\`bash
1695
- curl -sf https://docs.amba.dev/docs/prompts/expo-build.md
2754
+ curl -sf https://docs.amba.dev/prompts/expo-build.md
1696
2755
  \`\`\`
1697
2756
 
1698
2757
  If \`curl\` fails (404, 5xx, network error, no internet), fall back to
@@ -1715,17 +2774,8 @@ ${EXPO_BUILD_PROMPT_MD}<!-- AMBA-BUILD-PROMPT-END -->
1715
2774
  }
1716
2775
  /**
1717
2776
  * Write `.claude/skills/amba-build/SKILL.md` into the target project.
1718
- *
1719
- * Always overwrites — the skill file is meant to be regenerated each
1720
- * time `amba init --sandbox` runs (so the inlined snapshot stays
1721
- * fresh). Anything the user customized above the snapshot markers
1722
- * would be lost on a re-init; that's an accepted trade-off for the
1723
- * agentic single-command flow.
1724
- *
1725
- * If a future need for "preserve user edits across re-init" surfaces,
1726
- * the right shape is a separate `amba update-skills` command that
1727
- * surgically rewrites the inlined-snapshot region only — leaving the
1728
- * surrounding text untouched. Out of scope for DX-16.
2777
+ * Always overwrites — the inlined snapshot is meant to be regenerated
2778
+ * on each `amba init --sandbox` run.
1729
2779
  */
1730
2780
  async function writeAmbaBuildSkill(options = {}) {
1731
2781
  const skillDir = join(options.baseDir ?? process.cwd(), ".claude", "skills", "amba-build");
@@ -1734,6 +2784,334 @@ async function writeAmbaBuildSkill(options = {}) {
1734
2784
  await writeFile(skillPath, buildAmbaBuildSkillContent(), "utf-8");
1735
2785
  return { path: skillPath };
1736
2786
  }
2787
+ /**
2788
+ * Marker fence sentinels for the two append-targets (`CLAUDE.md`,
2789
+ * `AGENTS.md`). Used by `markerFencedAppend` to find + refresh the
2790
+ * Amba section without clobbering surrounding user content.
2791
+ */
2792
+ const AMBA_SETUP_START_MARKER = "<!-- AMBA-SETUP-START -->";
2793
+ const AMBA_SETUP_END_MARKER = "<!-- AMBA-SETUP-END -->";
2794
+ var AmbaSkillFileCorrupted = class extends Error {
2795
+ path;
2796
+ shape;
2797
+ constructor(filePath, shape) {
2798
+ super(`Found ${shape.startCount} AMBA-SETUP-START marker(s) and ${shape.endCount} AMBA-SETUP-END marker(s) in ${filePath}; expected exactly 1 of each in order. Refusing to auto-fix: cutting either side could discard user content. Please remove the extras (or the orphan marker) manually and re-run \`amba init\`.`);
2799
+ this.name = "AmbaSkillFileCorrupted";
2800
+ this.path = filePath;
2801
+ this.shape = shape;
2802
+ }
2803
+ };
2804
+ /**
2805
+ * Insert or refresh a marker-fenced block in a file.
2806
+ *
2807
+ * Marker-shape invariant: the target file must have either
2808
+ * (a) zero start/end markers (clean append), or
2809
+ * (b) exactly one start marker and one end marker, with the end
2810
+ * marker after the start (clean in-place refresh).
2811
+ *
2812
+ * Any other shape — orphan single marker, duplicate paired blocks,
2813
+ * end-before-start, mixed counts (e.g. 1 start + 2 ends) — is
2814
+ * treated as corruption and throws `AmbaSkillFileCorrupted`. The
2815
+ * caller's `warn` sink surfaces the error to the developer; the
2816
+ * file is left untouched. Earlier revisions tried to auto-recover
2817
+ * malformed states via strip-and-replace, but every recovery
2818
+ * heuristic risked deleting user content outside the Amba block
2819
+ * (BugBot cycle-5..7 all flagged adjacent failure modes — the
2820
+ * strict invariant kills the whole class).
2821
+ *
2822
+ * Behavior:
2823
+ *
2824
+ * - File does not exist (`ENOENT`) → create with just the block.
2825
+ * **Any other read error (EACCES, EISDIR, transient I/O) is
2826
+ * re-thrown** — we never silently overwrite a file we couldn't
2827
+ * read.
2828
+ * - File exists, zero markers → append the block (with a blank-
2829
+ * line separator so it doesn't fuse onto the last paragraph).
2830
+ * - File exists, well-formed 1+1 pair (end after start) → replace
2831
+ * the content between markers, preserving surrounding text.
2832
+ * - Any other marker shape → throw `AmbaSkillFileCorrupted` with
2833
+ * the observed (startCount, endCount, startIdx, endIdx).
2834
+ *
2835
+ * Returns the absolute path + whether this was a fresh create or a
2836
+ * refresh.
2837
+ *
2838
+ * The `body` argument is the text we want **inside** the markers —
2839
+ * the markers themselves are added by this helper. Callers must NOT
2840
+ * include the start/end marker lines in `body`.
2841
+ */
2842
+ async function markerFencedAppend(filePath, body, startMarker, endMarker) {
2843
+ await mkdir(dirname(filePath), { recursive: true });
2844
+ let existing = null;
2845
+ try {
2846
+ existing = await readFile(filePath, "utf-8");
2847
+ } catch (err) {
2848
+ if (err?.code === "ENOENT") existing = null;
2849
+ else throw err;
2850
+ }
2851
+ const block = `${startMarker}\n${body}\n${endMarker}`;
2852
+ if (existing === null) {
2853
+ await writeFile(filePath, block + "\n", "utf-8");
2854
+ return {
2855
+ path: filePath,
2856
+ mode: "created"
2857
+ };
2858
+ }
2859
+ const startCount = countOccurrences(existing, startMarker);
2860
+ const endCount = countOccurrences(existing, endMarker);
2861
+ const startIdx = existing.indexOf(startMarker);
2862
+ const endIdx = existing.indexOf(endMarker);
2863
+ if (startCount === 1 && endCount === 1 && endIdx > startIdx) {
2864
+ const before = existing.slice(0, startIdx);
2865
+ const after = existing.slice(endIdx + endMarker.length);
2866
+ await writeFile(filePath, before + block + after, "utf-8");
2867
+ return {
2868
+ path: filePath,
2869
+ mode: "refreshed"
2870
+ };
2871
+ }
2872
+ if (startCount === 0 && endCount === 0) {
2873
+ const separator = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2874
+ await writeFile(filePath, existing + separator + block + "\n", "utf-8");
2875
+ return {
2876
+ path: filePath,
2877
+ mode: "refreshed"
2878
+ };
2879
+ }
2880
+ throw new AmbaSkillFileCorrupted(filePath, {
2881
+ startCount,
2882
+ endCount,
2883
+ startIdx,
2884
+ endIdx
2885
+ });
2886
+ }
2887
+ /**
2888
+ * Count non-overlapping occurrences of `needle` in `haystack`.
2889
+ * Used to classify the marker state of an existing file.
2890
+ */
2891
+ function countOccurrences(haystack, needle) {
2892
+ if (needle.length === 0) return 0;
2893
+ let count = 0;
2894
+ let pos = 0;
2895
+ while (true) {
2896
+ const idx = haystack.indexOf(needle, pos);
2897
+ if (idx === -1) break;
2898
+ count += 1;
2899
+ pos = idx + needle.length;
2900
+ }
2901
+ return count;
2902
+ }
2903
+ /**
2904
+ * Build the canonical setup body. Sourced from the MCP package so
2905
+ * docs + MCP + every coding-agent surface stay in sync.
2906
+ *
2907
+ * Exposed as a function (not a const) so future versions can swap in
2908
+ * a build-time generator without breaking import sites.
2909
+ */
2910
+ function buildAmbaSetupBody() {
2911
+ return AMBA_SETUP_GUIDE_MD;
2912
+ }
2913
+ /**
2914
+ * Build the Cursor `.cursor/rules/amba.mdc` flavor.
2915
+ *
2916
+ * `alwaysApply: true` makes Cursor inject the rule at the start of
2917
+ * every turn (Cursor's most-proactive mode). `globs: ""` keeps the
2918
+ * rule globally-scoped instead of file-pattern-attached.
2919
+ */
2920
+ function buildCursorRuleContent() {
2921
+ return `---
2922
+ alwaysApply: true
2923
+ description: "Amba SDK + MCP guide"
2924
+ globs: ""
2925
+ ---
2926
+
2927
+ ${buildAmbaSetupBody()}
2928
+ `;
2929
+ }
2930
+ /**
2931
+ * Build the Windsurf `.windsurf/rules/amba.md` flavor.
2932
+ *
2933
+ * `trigger: always_on` is Windsurf's equivalent of Cursor's
2934
+ * `alwaysApply: true`. Workspace rules **cap at 12k chars** — a hard
2935
+ * Windsurf limit, not negotiable. The canonical
2936
+ * `AMBA_SETUP_GUIDE_MD` body is the full classify → confirm →
2937
+ * wire-up playbook (~17k) and won't fit, so Windsurf gets a
2938
+ * trimmed-down summary that points at the long-form resource
2939
+ * (`amba://setup`) for full detail. Same posture as
2940
+ * `AMBA_INIT_INSTRUCTIONS` in the hosted MCP server.
2941
+ */
2942
+ function buildWindsurfRuleContent() {
2943
+ return `---
2944
+ trigger: always_on
2945
+ ---
2946
+
2947
+ ${buildWindsurfSummaryBody()}
2948
+ `;
2949
+ }
2950
+ /**
2951
+ * Shorter summary of the Amba setup playbook for Windsurf rules.
2952
+ *
2953
+ * Constraints:
2954
+ * - Wrapped frontmatter + body must fit under Windsurf's 12k cap.
2955
+ * - Must name the same five-step journey shape so an agent acting
2956
+ * on this summary doesn't drift from the canonical guide.
2957
+ * - Customer-facing — no vendor leakage.
2958
+ *
2959
+ * For full detail (per-surface MCP tool tables, per-stack SDK init
2960
+ * snippets, common follow-ups, re-run rules), the agent fetches
2961
+ * `amba://setup` (or per-surface \`amba://setup/<surface>\`) from the
2962
+ * hosted MCP server.
2963
+ */
2964
+ function buildWindsurfSummaryBody() {
2965
+ return `# Amba
2966
+
2967
+ Amba is the backend your app calls at runtime via the SDK; the MCP tools (\`amba_*\`) are for provisioning, called by the coding agent.
2968
+
2969
+ ## Bootstrap
2970
+
2971
+ \`amba_developer_signup\` is the only pre-auth MCP tool — call it with no Bearer to mint a PAT, project, and client/server keys in one round trip. After signup, set \`Authorization: Bearer <pat>\` on every subsequent MCP call. Save \`project_id\` + \`client_key\` to \`.env.local\`; never write \`server_key\` next to client code.
2972
+
2973
+ ## Playbook
2974
+
2975
+ When the user asks you to wire Amba into this codebase, run this journey end-to-end:
2976
+
2977
+ 1. **Step 0 — Bootstrap credentials.** Call \`amba_developer_signup\` (pre-auth) OR \`amba_developer_me\` if a PAT already exists. Persist \`project_id\` + \`client_key\` to \`.env.local\`.
2978
+ 2. **Step 1 — Classify the app.** Pick one of ten presets (fitness / social / marketplace / productivity / education / game / dating / content_creator / ai_chatbot / custom) using README + filenames + dependencies.
2979
+ 3. **Step 2 — Confirm with the user.** One multi-choice question listing the surfaces the preset implies. Don't drip-feed.
2980
+ 4. **Step 3 — Wire it up.** For each surface in the confirmed scope, fetch its sub-resource (\`amba://setup/<surface>\`) and execute its MCP tools + drop in the per-stack SDK init snippet.
2981
+ 5. **Step 4 — Report.** Structured DONE / SKIPPED / NEEDS YOUR INPUT / NEXT STEPS summary.
2982
+
2983
+ ## Surfaces
2984
+
2985
+ - **identity** — anonymous + Apple + Google + OTP + magic link (see \`amba://setup/identity\`).
2986
+ - **engagement** — push, segments, content, onboarding, deeplinks, referrals, tracked links (\`amba://setup/engagement\`).
2987
+ - **gamification** — XP, achievements, streaks, leaderboards, challenges (\`amba://setup/gamification\`).
2988
+ - **economy** — currencies, catalog, stores, inventory (\`amba://setup/economy\`).
2989
+ - **social** — friends, groups, feeds, messaging, moderation, reviews (\`amba://setup/social\`).
2990
+ - **infrastructure** — collections (typed tables), functions, AI prompts, secrets, configs, integrations, media, sites (\`amba://setup/infrastructure\`).
2991
+
2992
+ ## SDKs
2993
+
2994
+ | Stack | Registry | Package |
2995
+ |---|---|---|
2996
+ | Browser / Node / React / RN / Expo | npm | \`@layers/amba-{web,node,react,react-native,expo}\` |
2997
+ | Swift | SPM | \`https://github.com/layers/amba-sdk-ios\` |
2998
+ | Kotlin | Maven Central | \`com.layers.amba:amba-sdk-android\` |
2999
+ | Flutter | pub.dev | \`amba\` |
3000
+ | Unity | UPM (git) | \`https://github.com/layers/amba-sdk-unity.git\` |
3001
+
3002
+ All SDKs expose \`Amba.configure({ projectId, apiKey })\` then \`Amba.events.track(...)\`, \`Amba.users.*\`, \`Amba.collections.*\`, etc.
3003
+
3004
+ ## Stance
3005
+
3006
+ - **clientKey vs serverKey.** \`AMBA_CLIENT_KEY\` (\`amb_dev_ck_…\` / \`amb_live_ck_…\`) ships to user devices. \`AMBA_SERVER_KEY\` (\`amb_dev_sk_…\` / \`amb_live_sk_…\`) never does — server \`.env\` or a secret manager. Mixing them is the #1 security mistake.
3007
+ - **Default to additive, non-breaking edits.** Drop \`await Amba.configure(...)\` next to existing init, don't refactor.
3008
+ - **Don't create resources without Step 2 confirmation.**
3009
+ - **Use canonical (post-DX-12) tool names** — \`amba_<resource>_<verb>\` form. Legacy verb-leading aliases (\`amba_create_*\`, \`amba_list_*\`, etc.) still resolve but the canonical names are what to advertise.
3010
+
3011
+ For the full playbook (per-surface MCP tool tables, per-stack SDK init snippets, common follow-ups, re-run behavior), read \`amba://setup\` and \`amba://setup/<surface>\` from the hosted MCP at \`mcp.amba.dev\`.
3012
+ `;
3013
+ }
3014
+ /**
3015
+ * Body for the marker-fenced append into `CLAUDE.md` or `AGENTS.md`.
3016
+ *
3017
+ * Plain markdown, no frontmatter — both conventions are
3018
+ * frontmatter-free. Returns the inner body only; the marker fence is
3019
+ * added by `markerFencedAppend`.
3020
+ */
3021
+ function buildAppendableSetupBody() {
3022
+ return buildAmbaSetupBody();
3023
+ }
3024
+ /** Write `.cursor/rules/amba.mdc`. */
3025
+ async function writeCursorRule(options = {}) {
3026
+ const ruleDir = join(options.baseDir ?? process.cwd(), ".cursor", "rules");
3027
+ await mkdir(ruleDir, { recursive: true });
3028
+ const rulePath = join(ruleDir, "amba.mdc");
3029
+ let mode = "created";
3030
+ try {
3031
+ await readFile(rulePath, "utf-8");
3032
+ mode = "refreshed";
3033
+ } catch {
3034
+ mode = "created";
3035
+ }
3036
+ await writeFile(rulePath, buildCursorRuleContent(), "utf-8");
3037
+ return {
3038
+ path: rulePath,
3039
+ mode
3040
+ };
3041
+ }
3042
+ /** Write `.windsurf/rules/amba.md`. */
3043
+ async function writeWindsurfRule(options = {}) {
3044
+ const ruleDir = join(options.baseDir ?? process.cwd(), ".windsurf", "rules");
3045
+ await mkdir(ruleDir, { recursive: true });
3046
+ const rulePath = join(ruleDir, "amba.md");
3047
+ let mode = "created";
3048
+ try {
3049
+ await readFile(rulePath, "utf-8");
3050
+ mode = "refreshed";
3051
+ } catch {
3052
+ mode = "created";
3053
+ }
3054
+ await writeFile(rulePath, buildWindsurfRuleContent(), "utf-8");
3055
+ return {
3056
+ path: rulePath,
3057
+ mode
3058
+ };
3059
+ }
3060
+ /**
3061
+ * Append (or refresh) the Amba setup section in `CLAUDE.md` at the
3062
+ * project root. Marker-fenced so it can be safely refreshed by
3063
+ * subsequent re-inits.
3064
+ */
3065
+ async function writeClaudeMd(options = {}) {
3066
+ return markerFencedAppend(join(options.baseDir ?? process.cwd(), "CLAUDE.md"), buildAppendableSetupBody(), AMBA_SETUP_START_MARKER, AMBA_SETUP_END_MARKER);
3067
+ }
3068
+ /**
3069
+ * Append (or refresh) the Amba setup section in `AGENTS.md` at the
3070
+ * project root. The `AGENTS.md` convention is read by 20+ agentic
3071
+ * tools (Codex, Aider, Zed, Copilot, Gemini CLI, Warp, etc.) so this
3072
+ * single file covers most of the long tail.
3073
+ */
3074
+ async function writeAgentsMd(options = {}) {
3075
+ return markerFencedAppend(join(options.baseDir ?? process.cwd(), "AGENTS.md"), buildAppendableSetupBody(), AMBA_SETUP_START_MARKER, AMBA_SETUP_END_MARKER);
3076
+ }
3077
+ async function writeAllSetupTargets(options = {}) {
3078
+ const baseDir = options.baseDir ?? process.cwd();
3079
+ const warn = options.warn ?? (() => {});
3080
+ const written = [];
3081
+ const tasks = [
3082
+ {
3083
+ target: "claude-md",
3084
+ write: () => writeClaudeMd({ baseDir })
3085
+ },
3086
+ {
3087
+ target: "cursor-rule",
3088
+ write: () => writeCursorRule({ baseDir })
3089
+ },
3090
+ {
3091
+ target: "agents-md",
3092
+ write: () => writeAgentsMd({ baseDir })
3093
+ },
3094
+ {
3095
+ target: "windsurf-rule",
3096
+ write: () => writeWindsurfRule({ baseDir })
3097
+ }
3098
+ ];
3099
+ for (const task of tasks) try {
3100
+ const res = await task.write();
3101
+ written.push({
3102
+ target: task.target,
3103
+ path: res.path,
3104
+ mode: res.mode
3105
+ });
3106
+ } catch (err) {
3107
+ const message = err instanceof Error ? err.message : String(err);
3108
+ warn(` ! Skipped ${task.target} setup file: ${message}`);
3109
+ }
3110
+ return {
3111
+ written,
3112
+ bodyVersion: "v2"
3113
+ };
3114
+ }
1737
3115
  //#endregion
1738
3116
  //#region src/commands/init.ts
1739
3117
  function prompt(question) {
@@ -1748,6 +3126,85 @@ function prompt(question) {
1748
3126
  });
1749
3127
  });
1750
3128
  }
3129
+ function createSpinner(label) {
3130
+ if (!process.stdout.isTTY) return {
3131
+ setLabel: () => {},
3132
+ stop: () => {},
3133
+ get stopped() {
3134
+ return true;
3135
+ }
3136
+ };
3137
+ const frames = [
3138
+ "⠋",
3139
+ "⠙",
3140
+ "⠹",
3141
+ "⠸",
3142
+ "⠼",
3143
+ "⠴",
3144
+ "⠦",
3145
+ "⠧",
3146
+ "⠇",
3147
+ "⠏"
3148
+ ];
3149
+ let i = 0;
3150
+ let current = label;
3151
+ let isStopped = false;
3152
+ const render = () => {
3153
+ const frame = frames[i % frames.length];
3154
+ process.stdout.write(`\r ${pc.cyan(frame)} ${pc.dim(current)}${" ".repeat(8)}`);
3155
+ i += 1;
3156
+ };
3157
+ render();
3158
+ const handle = setInterval(render, 80);
3159
+ return {
3160
+ setLabel: (next) => {
3161
+ current = next;
3162
+ },
3163
+ stop: () => {
3164
+ if (isStopped) return;
3165
+ isStopped = true;
3166
+ clearInterval(handle);
3167
+ process.stdout.write("\r" + " ".repeat(80) + "\r");
3168
+ },
3169
+ get stopped() {
3170
+ return isStopped;
3171
+ }
3172
+ };
3173
+ }
3174
+ /**
3175
+ * Pretty-print the elapsed time. Sub-minute renders as seconds
3176
+ * ("12s"); above that renders as "1m 04s". The spinner ends with this
3177
+ * stamped into the first line of the success block so a developer who
3178
+ * just ran the command knows how long the network round trips took.
3179
+ */
3180
+ function formatElapsed(ms) {
3181
+ const totalSec = Math.max(0, Math.round(ms / 1e3));
3182
+ if (totalSec < 60) return `${totalSec}s`;
3183
+ const m = Math.floor(totalSec / 60);
3184
+ const s = totalSec % 60;
3185
+ return `${m}m ${String(s).padStart(2, "0")}s`;
3186
+ }
3187
+ /**
3188
+ * Truncate a long id to a compact preview — first 7 chars + ellipsis.
3189
+ * Mirrors how the API surfaces `id.slice(0, 8)` in other places. Keeps
3190
+ * the success block readable when project ids are full UUIDs.
3191
+ */
3192
+ function shortId(id) {
3193
+ if (id.length <= 10) return id;
3194
+ return `${id.slice(0, 7)}…`;
3195
+ }
3196
+ /**
3197
+ * Strip the project root from an absolute path for compact display in
3198
+ * the success block — `/Users/me/proj/.env.local` → `.env.local`,
3199
+ * `/Users/me/.claude.json` → `~/.claude.json` when a home dir is
3200
+ * provided. Pure cosmetic, never used for actual fs operations.
3201
+ */
3202
+ function relPathForDisplay(absPath, cwd, home) {
3203
+ if (absPath.startsWith(cwd + "/")) return absPath.slice(cwd.length + 1);
3204
+ const homeDir = home ?? process.env["HOME"] ?? "";
3205
+ if (homeDir && absPath.startsWith(homeDir + "/")) return "~/" + absPath.slice(homeDir.length + 1);
3206
+ return absPath;
3207
+ }
1751
3208
  async function fileExists(path) {
1752
3209
  try {
1753
3210
  await access(path);
@@ -1847,7 +3304,8 @@ Docs: https://docs.amba.dev
1847
3304
  }
1848
3305
  async function initCommand(options = {}) {
1849
3306
  const cwd = process.cwd();
1850
- if (options.sandbox) {
3307
+ const isNonTTY = process.stdin.isTTY !== true;
3308
+ if (options.sandbox === true || options.json === true || isNonTTY) {
1851
3309
  const result = await runSandboxInit(cwd, {
1852
3310
  sandboxEmail: options.sandboxEmail,
1853
3311
  noMcpConfig: options.noMcpConfig,
@@ -1860,226 +3318,298 @@ async function initCommand(options = {}) {
1860
3318
  return;
1861
3319
  }
1862
3320
  const environment = options.env ?? "development";
1863
- console.log();
1864
- console.log(pc.bold(" amba init"));
1865
- console.log(pc.dim(" ─────────────────────────────────"));
1866
- console.log();
1867
- console.log(pc.bold(" Step 1/7 ") + pc.dim("Authenticate"));
1868
- console.log();
1869
- const headlessActive = resolveTokenSource({
1870
- flagToken: void 0,
1871
- envToken: process.env["AMBA_PAT"]
1872
- }) !== null || process.argv.includes("--token") && process.argv.length > 2;
1873
- let needsAuth = true;
1874
- if (headlessActive) {
1875
- console.log(pc.green(" ✓") + " Headless auth — using PAT from --token / AMBA_PAT");
1876
- console.log(pc.dim(" (browser flow skipped; no credentials written to disk)"));
1877
- console.log();
1878
- needsAuth = false;
1879
- } else try {
1880
- if (!isTokenExpired(await loadCredentials())) {
1881
- console.log(pc.green(" ✓") + " Already authenticated");
1882
- console.log();
1883
- needsAuth = false;
1884
- }
1885
- } catch {}
1886
- if (needsAuth) {
1887
- const creds = await browserAuthFlow();
1888
- console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
1889
- await storeCredentials(creds);
1890
- console.log(pc.green(" ✓") + " Credentials saved to ~/.amba/credentials.json");
1891
- console.log();
1892
- } else {
1893
- console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
1894
- if (headlessActive) console.log(pc.green(" ✓") + " (skipped — PAT supplied)");
1895
- else console.log(pc.green(" ✓") + " Using existing credentials");
1896
- console.log();
1897
- }
1898
- console.log(pc.bold(" Step 3/7 ") + pc.dim("Select project"));
1899
- console.log();
1900
- let projectId;
1901
- let projectName;
3321
+ const startedAt = Date.now();
3322
+ const overridePat = getBearerOverride() ?? resolveTokenSource({ envToken: process.env["AMBA_PAT"] });
3323
+ const headlessActive = overridePat !== null;
3324
+ let identityPat = null;
3325
+ let identity = null;
3326
+ let credsBackedUpTo = null;
3327
+ let spinner = createSpinner("authenticating");
1902
3328
  try {
1903
- const projects = (await listProjects()).data;
1904
- if (projects.length > 0) {
1905
- console.log(" Existing projects:");
1906
- projects.forEach((p, i) => {
1907
- console.log(pc.dim(` ${i + 1}.`) + ` ${p.name} ` + pc.dim(`(${p.id})`));
1908
- });
1909
- console.log(pc.dim(` ${projects.length + 1}.`) + " Create new project");
1910
- console.log();
1911
- const choice = await prompt(` Select project (1-${projects.length + 1}): `);
1912
- const choiceNum = parseInt(choice, 10);
1913
- if (choiceNum > 0 && choiceNum <= projects.length) {
1914
- const selected = projects[choiceNum - 1];
1915
- if (!selected) throw new Error("Invalid selection");
1916
- projectId = selected.id;
1917
- projectName = selected.name;
1918
- console.log(pc.green(" ✓") + ` Selected: ${projectName}`);
1919
- } else {
1920
- const name = await prompt(" Project name: ");
1921
- if (!name) {
1922
- console.log(pc.red(" ✗") + " Project name is required");
1923
- process.exit(1);
3329
+ if (headlessActive) {
3330
+ identityPat = overridePat;
3331
+ try {
3332
+ identity = await verifyPat(identityPat);
3333
+ } catch (err) {
3334
+ const reason = err instanceof Error ? err.message : String(err);
3335
+ spinner.stop();
3336
+ console.error(pc.red(" ✗") + ` Could not verify supplied token: ${reason}`);
3337
+ process.exit(1);
3338
+ }
3339
+ if (!identity) {
3340
+ spinner.stop();
3341
+ console.error(pc.red(" ✗") + " Supplied token failed verification. Check --token / AMBA_PAT and try again.");
3342
+ process.exit(1);
3343
+ }
3344
+ } else {
3345
+ let needsBrowser = false;
3346
+ try {
3347
+ const ensured = await ensureDeveloperIdentity({ signupOnMissing: false });
3348
+ identity = ensured.developer;
3349
+ identityPat = ensured.credentials.pat;
3350
+ credsBackedUpTo = ensured.credentialsBackedUpTo;
3351
+ setBearerOverride(ensured.credentials.pat);
3352
+ } catch {
3353
+ needsBrowser = true;
3354
+ }
3355
+ if (needsBrowser) {
3356
+ spinner.stop();
3357
+ await storeCredentials(await browserAuthFlow());
3358
+ const ensured = await ensureDeveloperIdentity({ signupOnMissing: false });
3359
+ identity = ensured.developer;
3360
+ identityPat = ensured.credentials.pat;
3361
+ credsBackedUpTo = ensured.credentialsBackedUpTo;
3362
+ }
3363
+ }
3364
+ if (!identityPat || !identity) {
3365
+ spinner.stop();
3366
+ console.error(pc.red(" ✗") + " Could not establish an Amba identity.");
3367
+ process.exit(1);
3368
+ return;
3369
+ }
3370
+ const linkedProject = await loadProjectCredentials(cwd);
3371
+ const defaultProjectName = basename(cwd) || "amba-project";
3372
+ let projectId;
3373
+ let projectName;
3374
+ if (linkedProject) {
3375
+ projectId = linkedProject.project_id;
3376
+ projectName = linkedProject.project_name;
3377
+ } else {
3378
+ spinner.setLabel("loading projects");
3379
+ let projectsList = [];
3380
+ try {
3381
+ projectsList = (await listProjects()).data;
3382
+ } catch (err) {
3383
+ if (err instanceof Error && err.message.includes("authenticate")) {
3384
+ spinner.stop();
3385
+ throw err;
3386
+ }
3387
+ projectsList = [];
3388
+ }
3389
+ spinner.stop();
3390
+ if (projectsList.length > 0) {
3391
+ console.log();
3392
+ console.log(" Existing projects:");
3393
+ projectsList.forEach((p, i) => {
3394
+ const envBadge = p.environment ? pc.dim(` [${p.environment}]`) : "";
3395
+ console.log(pc.dim(` ${i + 1}.`) + ` ${p.name}${envBadge} ` + pc.dim(`(${p.id.slice(0, 8)}…)`));
3396
+ });
3397
+ const newOptionIdx = projectsList.length + 1;
3398
+ console.log(pc.dim(` ${newOptionIdx}.`) + ` Create new project ` + pc.dim(`(default name: ${defaultProjectName})`));
3399
+ console.log();
3400
+ const choice = await prompt(` Select project (1-${newOptionIdx}, default ${newOptionIdx}): `);
3401
+ const choiceNum = choice.length === 0 ? newOptionIdx : parseInt(choice, 10);
3402
+ if (choiceNum > 0 && choiceNum <= projectsList.length) {
3403
+ const selected = projectsList[choiceNum - 1];
3404
+ if (!selected) throw new Error("Invalid selection");
3405
+ projectId = selected.id;
3406
+ projectName = selected.name;
3407
+ } else {
3408
+ const name = await prompt(` Project name (default: ${defaultProjectName}): `);
3409
+ const finalName = name.length > 0 ? name : defaultProjectName;
3410
+ projectId = (await createProject({
3411
+ name: finalName,
3412
+ environment
3413
+ })).data.id;
3414
+ projectName = finalName;
1924
3415
  }
3416
+ } else {
3417
+ const name = await prompt(` Project name (default: ${defaultProjectName}): `);
3418
+ const finalName = name.length > 0 ? name : defaultProjectName;
1925
3419
  projectId = (await createProject({
1926
- name,
3420
+ name: finalName,
1927
3421
  environment
1928
3422
  })).data.id;
1929
- projectName = name;
1930
- console.log(pc.green(" ✓") + ` Created: ${projectName} ${pc.dim(`(${environment})`)}`);
1931
- }
1932
- } else {
1933
- const name = await prompt(" Project name: ");
1934
- if (!name) {
1935
- console.log(pc.red(" ✗") + " Project name is required");
1936
- process.exit(1);
3423
+ projectName = finalName;
1937
3424
  }
1938
- projectId = (await createProject({ name })).data.id;
1939
- projectName = name;
1940
- console.log(pc.green(" ✓") + ` Created: ${projectName}`);
1941
3425
  }
1942
- } catch (err) {
1943
- if (err instanceof Error && err.message.includes("authenticate")) throw err;
1944
- const name = await prompt(" Project name: ");
1945
- if (!name) {
1946
- console.log(pc.red(" ✗") + " Project name is required");
1947
- process.exit(1);
3426
+ if (spinner.stopped) spinner = createSpinner("minting keys");
3427
+ const work = spinner;
3428
+ work.setLabel("minting keys");
3429
+ let clientKey;
3430
+ let serverKey;
3431
+ if (linkedProject) {
3432
+ clientKey = linkedProject.client_key;
3433
+ if (linkedProject.server_key) serverKey = linkedProject.server_key;
3434
+ else serverKey = (await createApiKey(projectId, "server", environment)).data.key;
3435
+ } else {
3436
+ const clientRes = await createApiKey(projectId, "client", environment);
3437
+ const serverRes = await createApiKey(projectId, "server", environment);
3438
+ clientKey = clientRes.data.key;
3439
+ serverKey = serverRes.data.key;
1948
3440
  }
1949
- projectId = (await createProject({ name })).data.id;
1950
- projectName = name;
1951
- console.log(pc.green(" ✓") + ` Created: ${projectName}`);
1952
- }
1953
- console.log();
1954
- console.log(pc.bold(" Step 4/7 ") + pc.dim("Generate API keys"));
1955
- const keyRes = await createApiKey(projectId, "client", "development");
1956
- const apiKey = keyRes.data.key;
1957
- console.log(pc.green(" ✓") + " Development client key created");
1958
- console.log(pc.dim(` ${keyRes.data.key_prefix}...`));
1959
- console.log();
1960
- console.log(pc.bold(" Step 5/7 ") + pc.dim("Write environment file"));
1961
- const envPath = join(cwd, ".env.local");
1962
- const envLines = [
1963
- "# Amba SDK Configuration",
1964
- `AMBA_PROJECT_ID=${projectId}`,
1965
- `AMBA_API_KEY=${apiKey}`,
1966
- `AMBA_API_URL=https://api.amba.dev`,
1967
- ""
1968
- ];
1969
- if (await fileExists(envPath)) {
1970
- const existing = await readFile(envPath, "utf-8");
1971
- if (existing.includes("AMBA_PROJECT_ID")) {
1972
- console.log(pc.yellow(" !") + " .env.local already contains Amba config — updating");
1973
- let updated = existing;
1974
- updated = updated.replace(/AMBA_PROJECT_ID=.*/, `AMBA_PROJECT_ID=${projectId}`);
1975
- updated = updated.replace(/AMBA_API_KEY=.*/, `AMBA_API_KEY=${apiKey}`);
1976
- updated = updated.replace(/AMBA_API_URL=.*/, `AMBA_API_URL=https://api.amba.dev`);
1977
- await writeFile(envPath, updated, "utf-8");
1978
- } else await writeFile(envPath, existing + (existing.endsWith("\n") ? "\n" : "\n\n") + envLines.join("\n"), "utf-8");
1979
- } else await writeFile(envPath, envLines.join("\n"), "utf-8");
1980
- console.log(pc.green(" ✓") + " .env.local written");
1981
- console.log();
1982
- console.log(pc.bold(" Step 6/7 ") + pc.dim("Detect framework"));
1983
- const framework = await detectFramework(cwd);
1984
- const sdkPkg = getSdkPackage(framework);
1985
- if (framework !== "unknown") console.log(pc.green(" ✓") + ` Detected: ${pc.bold(framework)}`);
1986
- else console.log(pc.yellow(" !") + " Could not detect framework");
1987
- let installCmd = `npm install ${sdkPkg}`;
1988
- if (await fileExists(join(cwd, "bun.lockb"))) installCmd = `bun add ${sdkPkg}`;
1989
- else if (await fileExists(join(cwd, "pnpm-lock.yaml"))) installCmd = `pnpm add ${sdkPkg}`;
1990
- else if (await fileExists(join(cwd, "yarn.lock"))) installCmd = `yarn add ${sdkPkg}`;
1991
- console.log(pc.dim(` Install SDK: ${installCmd}`));
1992
- console.log();
1993
- console.log(pc.bold(" Step 7/7 ") + pc.dim("Generate context files"));
1994
- const generatedFiles = await generateContextFiles({
1995
- projectId,
1996
- projectName,
1997
- apiKey,
1998
- framework,
1999
- cwd
2000
- });
2001
- for (const file of generatedFiles) console.log(pc.green(" ✓") + ` ${file}`);
2002
- if (options.withExample) {
2003
- const exampleFiles = await writeExampleScaffold(cwd, framework, projectName);
2004
- if (exampleFiles.length > 0) for (const file of exampleFiles) console.log(pc.green(" ✓") + ` ${file} ` + pc.dim("(example)"));
2005
- else console.log(pc.dim(" -") + " example files already present — skipping");
2006
- }
2007
- console.log();
2008
- console.log(pc.dim(" ─────────────────────────────────"));
2009
- console.log();
2010
- console.log(pc.bold(pc.green(" ✓ Project initialized!")));
2011
- console.log();
2012
- console.log(" Quick start:");
2013
- console.log();
2014
- console.log(pc.dim(" 1.") + ` Install the SDK`);
2015
- console.log(` ${pc.cyan(installCmd)}`);
2016
- console.log();
2017
- console.log(pc.dim(" 2.") + ` Add the provider to your app`);
2018
- if (framework === "expo") console.log(pc.dim(` See AMBA.md for Amba.init() setup`));
2019
- else if (framework === "react-native") console.log(pc.dim(` See AMBA.md for client initialization`));
2020
- else console.log(pc.dim(` See AMBA.md for client initialization`));
2021
- console.log();
2022
- console.log(pc.dim(" 3.") + ` Test the integration`);
2023
- console.log(` ${pc.cyan("amba status")}`);
2024
- console.log();
2025
- console.log(pc.dim(" 4.") + ` Send a test notification`);
2026
- console.log(` ${pc.cyan("amba push test")}`);
2027
- console.log();
2028
- console.log(` Docs: ${pc.underline("https://docs.amba.dev")}`);
2029
- console.log();
2030
- }
2031
- async function runSandboxInit(cwd, options) {
2032
- if (!options.json) {
3441
+ work.setLabel("writing project state");
3442
+ const apiUrl = process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
3443
+ const envLocalPath = await writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl, serverKey);
3444
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
3445
+ const projectJsonPath = await writeProjectCredentials(cwd, linkedProject ? {
3446
+ ...linkedProject,
3447
+ client_key: clientKey,
3448
+ server_key: serverKey,
3449
+ environment,
3450
+ api_url: apiUrl,
3451
+ updated_at: nowIso
3452
+ } : {
3453
+ version: 1,
3454
+ project_id: projectId,
3455
+ project_name: projectName,
3456
+ environment,
3457
+ client_key: clientKey,
3458
+ server_key: serverKey,
3459
+ api_url: apiUrl,
3460
+ wired_surfaces: [],
3461
+ created_at: nowIso,
3462
+ updated_at: nowIso
3463
+ });
3464
+ work.setLabel("detecting framework");
3465
+ const framework = await detectFramework(cwd);
3466
+ const sdkPkg = getSdkPackage(framework);
3467
+ let installCmd = `npm install ${sdkPkg}`;
3468
+ if (await fileExists(join(cwd, "bun.lockb"))) installCmd = `bun add ${sdkPkg}`;
3469
+ else if (await fileExists(join(cwd, "pnpm-lock.yaml"))) installCmd = `pnpm add ${sdkPkg}`;
3470
+ else if (await fileExists(join(cwd, "yarn.lock"))) installCmd = `yarn add ${sdkPkg}`;
3471
+ work.setLabel("wiring agents");
3472
+ await generateContextFiles({
3473
+ projectId,
3474
+ projectName,
3475
+ apiKey: clientKey,
3476
+ framework,
3477
+ cwd
3478
+ });
3479
+ let mcpResults = [];
3480
+ let mcpManualSnippetNeeded = false;
3481
+ if (!options.noMcpConfig) {
3482
+ mcpResults = await writeAllMcpConfigs(cwd, identityPat, {
3483
+ homeDir: options.homeDir,
3484
+ warn: (msg) => process.stderr.write(msg + "\n")
3485
+ });
3486
+ mcpManualSnippetNeeded = mcpResults.length === 0;
3487
+ }
3488
+ if (options.withExample) await writeExampleScaffold(cwd, framework, projectName);
3489
+ work.setLabel("verifying");
3490
+ const finalVerify = await verifyPat(identityPat);
3491
+ work.stop();
3492
+ if (!finalVerify) {
3493
+ console.error(pc.red(" ✗") + " Post-write verify failed. PAT was minted but no longer accepted.");
3494
+ console.error(pc.dim(" Inspect ~/.amba/credentials.json + ") + pc.dim(".env.local — your provisioning may be incomplete."));
3495
+ process.exit(1);
3496
+ }
3497
+ const elapsed = formatElapsed(Date.now() - startedAt);
3498
+ const homeForDisplay = options.homeDir;
3499
+ const envRel = relPathForDisplay(envLocalPath, cwd, homeForDisplay);
3500
+ const projectJsonRel = relPathForDisplay(projectJsonPath, cwd, homeForDisplay);
2033
3501
  console.log();
2034
- console.log(pc.bold(" amba init --sandbox"));
2035
- console.log(pc.dim(" ─────────────────────────────────"));
3502
+ console.log(pc.green(" ✓") + pc.bold(` Amba ready in ${elapsed}`));
3503
+ console.log(pc.dim(" project: ") + projectName + pc.dim(` (id: ${shortId(projectId)})`));
3504
+ console.log(pc.dim(" keys → ") + envRel + pc.dim(` · state → ${projectJsonRel}`));
3505
+ if (mcpResults.length > 0) {
3506
+ const mcpPathsRel = mcpResults.map((m) => relPathForDisplay(m.path, cwd, homeForDisplay)).join(", ");
3507
+ console.log(pc.dim(" mcp → ") + mcpPathsRel + pc.dim(" (active next agent launch)"));
3508
+ for (const m of mcpResults) if (m.backedUpTo) console.log(pc.yellow(" note: previous amba entry backed up to ") + relPathForDisplay(m.backedUpTo, cwd, homeForDisplay));
3509
+ } else if (mcpManualSnippetNeeded) console.log(pc.dim(" mcp → ") + "no MCP client config detected (paste snippet below)");
3510
+ if (credsBackedUpTo) console.log(pc.dim(" note: previous credentials backed up to ") + credsBackedUpTo);
2036
3511
  console.log();
3512
+ console.log(pc.dim(" next: ") + pc.cyan(installCmd));
3513
+ console.log(pc.dim(" later: ") + pc.cyan("amba claim <your-email>") + pc.dim(" to upgrade past sandbox"));
3514
+ console.log();
3515
+ if (mcpManualSnippetNeeded && !options.noMcpConfig) {
3516
+ console.log(pc.dim(" Paste into your MCP client config:"));
3517
+ for (const line of formatManualMcpSnippet(identityPat).split("\n")) console.log(pc.dim(" ") + line);
3518
+ console.log();
3519
+ }
3520
+ } finally {
3521
+ spinner.stop();
2037
3522
  }
2038
- const signup = await performSandboxSignup({
2039
- email: options.sandboxEmail?.trim() || generateSandboxEmail(),
2040
- password: generateSandboxPassword()
3523
+ }
3524
+ async function runSandboxInit(cwd, options) {
3525
+ const warn = (msg) => {
3526
+ process.stderr.write(msg + "\n");
3527
+ };
3528
+ const overridePat = getBearerOverride() ?? resolveTokenSource({ envToken: process.env["AMBA_PAT"] });
3529
+ let identity;
3530
+ if (overridePat) {
3531
+ const verified = await verifyPat(overridePat);
3532
+ if (!verified) throw new Error("Supplied --token / AMBA_PAT failed verification against /developer/me. Check that the token is valid and try again.");
3533
+ identity = {
3534
+ credentials: {
3535
+ pat: overridePat,
3536
+ email: verified.email
3537
+ },
3538
+ newlySignedUp: false,
3539
+ developer: verified,
3540
+ firstProject: null,
3541
+ credentialsBackedUpTo: null,
3542
+ credentialsPath: "(supplied via --token / AMBA_PAT — not persisted)"
3543
+ };
3544
+ } else identity = await ensureDeveloperIdentity({
3545
+ homeDir: options.homeDir,
3546
+ sandboxEmail: options.sandboxEmail
2041
3547
  });
2042
- const envLocalPath = await writeSandboxEnvLocal(cwd, signup.project_id, signup.client_key, signup.api_url);
3548
+ setBearerOverride(identity.credentials.pat);
2043
3549
  const framework = await detectFramework(cwd);
2044
3550
  const sdkPackage = getSdkPackage(framework);
3551
+ const project = await ensureProjectForCwd(cwd, {
3552
+ pat: identity.credentials.pat,
3553
+ signupFirstProject: identity.firstProject ?? void 0,
3554
+ defaultName: basename(cwd) || "amba-sandbox"
3555
+ });
3556
+ const envLocalPath = await writeSandboxEnvLocal(cwd, project.credentials.project_id, project.credentials.client_key, project.credentials.api_url, project.credentials.server_key);
2045
3557
  const ambaMdPath = await writeSandboxAmbaMd(cwd, {
2046
- projectId: signup.project_id,
2047
- email: signup.email,
2048
- verifyUrl: signup.verify_url ?? null,
3558
+ projectId: project.credentials.project_id,
3559
+ email: identity.credentials.email,
3560
+ verifyUrl: identity.firstProject?.verify_url ?? null,
2049
3561
  sdkPackage,
2050
3562
  framework,
2051
- apiUrl: signup.api_url
3563
+ apiUrl: project.credentials.api_url
2052
3564
  });
2053
- const warn = options.json ? (msg) => process.stderr.write(msg + "\n") : (msg) => console.warn(msg);
2054
3565
  let mcpConfigsWritten = [];
2055
- if (!options.noMcpConfig) mcpConfigsWritten = await writeAllMcpConfigs(cwd, signup.pat, {
3566
+ if (!options.noMcpConfig) mcpConfigsWritten = await writeAllMcpConfigs(cwd, identity.credentials.pat, {
2056
3567
  homeDir: options.homeDir,
2057
3568
  warn
2058
3569
  });
2059
- const credentialsResult = await writeSandboxCredentials(signup.pat, { homeDir: options.homeDir });
2060
3570
  let skillPath = null;
2061
- if (!options.noSkills) try {
2062
- skillPath = (await writeAmbaBuildSkill({ baseDir: cwd })).path;
2063
- } catch (err) {
2064
- warn(` ! Skipped /amba-build skill install: ${err instanceof Error ? err.message : String(err)}`);
3571
+ let setupTargets = [];
3572
+ if (!options.noSkills) {
3573
+ try {
3574
+ const installResults = await installSkillBundle(cwd);
3575
+ summarizeSkillInstall(installResults);
3576
+ skillPath = (installResults.find((r) => r.target.kind === "claude-code")?.files.find((f) => f.path.endsWith("SKILL.md")))?.path ?? null;
3577
+ } catch (err) {
3578
+ warn(` ! Skipped amba skill bundle install: ${err instanceof Error ? err.message : String(err)}`);
3579
+ }
3580
+ try {
3581
+ await writeAmbaBuildSkill({ baseDir: cwd });
3582
+ } catch (err) {
3583
+ warn(` ! Skipped legacy /amba-build skill: ${err instanceof Error ? err.message : String(err)}`);
3584
+ }
3585
+ setupTargets = (await writeAllSetupTargets({
3586
+ baseDir: cwd,
3587
+ warn
3588
+ })).written.map((w) => ({
3589
+ target: w.target,
3590
+ path: w.path,
3591
+ mode: w.mode
3592
+ }));
2065
3593
  }
3594
+ if (!await verifyPat(identity.credentials.pat, { apiUrl: project.credentials.api_url })) throw new Error("Post-write verify failed: PAT was provisioned but no longer accepted by /developer/me. This usually means the control-plane signup race hasn't settled yet — retry in 5s.");
2066
3595
  return {
2067
- email: signup.email,
2068
- projectId: signup.project_id,
2069
- pat: signup.pat,
2070
- patPreview: `${signup.pat.slice(0, 12)}…${signup.pat.slice(-4)}`,
2071
- clientKey: signup.client_key,
2072
- apiUrl: signup.api_url,
2073
- credentialsPath: credentialsResult.path,
2074
- credentialsBackedUpTo: credentialsResult.backedUpTo,
3596
+ email: identity.credentials.email,
3597
+ projectId: project.credentials.project_id,
3598
+ pat: identity.credentials.pat,
3599
+ patPreview: `${identity.credentials.pat.slice(0, 12)}…${identity.credentials.pat.slice(-4)}`,
3600
+ clientKey: project.credentials.client_key,
3601
+ apiUrl: project.credentials.api_url,
3602
+ credentialsPath: identity.credentialsPath,
3603
+ credentialsBackedUpTo: identity.credentialsBackedUpTo,
2075
3604
  envLocalPath,
2076
3605
  ambaMdPath,
2077
3606
  mcpConfigsWritten,
2078
3607
  sdkPackage,
2079
3608
  framework,
2080
- verifyUrl: signup.verify_url ?? null,
2081
- provisioningStatus: signup.provisioning_status ?? "unknown",
2082
- skillPath
3609
+ verifyUrl: identity.firstProject?.verify_url ?? null,
3610
+ provisioningStatus: identity.firstProject?.provisioning_status ?? "active",
3611
+ skillPath,
3612
+ setupTargets
2083
3613
  };
2084
3614
  }
2085
3615
  function sandboxResultToJson(r) {
@@ -2102,83 +3632,83 @@ function sandboxResultToJson(r) {
2102
3632
  backed_up_to: m.backedUpTo
2103
3633
  })),
2104
3634
  skill_path: r.skillPath,
3635
+ setup_targets: r.setupTargets.map((t) => ({
3636
+ target: t.target,
3637
+ path: t.path,
3638
+ mode: t.mode
3639
+ })),
2105
3640
  verify_url: r.verifyUrl,
2106
3641
  provisioning_status: r.provisioningStatus,
2107
- next_steps: ["restart your MCP client"]
3642
+ next_steps: [`npm install ${r.sdkPackage}`, "call Amba.configure({ projectId, clientKey }) at app startup"],
3643
+ runtime_mcp: {
3644
+ configs_written: r.mcpConfigsWritten.map((m) => m.path),
3645
+ activates_on: "next agent launch",
3646
+ in_session_inline_pat: true
3647
+ }
2108
3648
  };
2109
3649
  }
2110
3650
  /**
2111
3651
  * Build the plaintext (no ANSI) success-output block printed at the
2112
- * end of `amba init --sandbox`. Pure function — exported only for the
2113
- * vitest cases that assert on per-line content. The CLI wraps each
2114
- * line with picocolors in `printSandboxNextSteps` below.
2115
- *
2116
- * Structure (in order):
2117
- * 1. Per-artifact checklist (`✓ ...` lines)
2118
- * 2. SDK install + configure hint
2119
- * 3. "Done." + per-client restart bullets — ONLY for clients we
2120
- * actually wrote configs to. Never instruct the user to "restart
2121
- * Cursor" if we only touched `~/.claude.json`. When no config
2122
- * was written (manual-paste path), falls back to a generic
2123
- * "quit + reopen" line.
2124
- * 4. Tail-line resume hint + sandbox limits + verify URL.
2125
- *
2126
- * We intentionally do NOT print any "kill -HUP / SIGHUP" advanced
2127
- * workaround. Telling the agent to surface a clean "restart your MCP
2128
- * client" instruction to the human is the whole optimization — adding
2129
- * an experimental fallback just dilutes the signal and risks the
2130
- * agent surfacing the workaround instead.
3652
+ * end of `amba init --sandbox`. Pure function — exported for the
3653
+ * vitest cases that assert on per-line content. The CLI wraps the
3654
+ * output with picocolors in `printSandboxNextSteps` below.
3655
+ *
3656
+ * Design: silent-until-done. The install (provision account, mint
3657
+ * keys, write .env.local, write MCP config, install skill) is COMPLETE
3658
+ * the moment this output lands. The MCP config has been persisted —
3659
+ * it activates on the next launch of the developer's coding agent. We
3660
+ * do NOT instruct the developer to restart anything; we just state
3661
+ * what's wired and what's next.
3662
+ *
3663
+ * Shape (~6 lines, Vercel/Stripe aesthetic):
3664
+ * ✓ Amba ready
3665
+ * project: <id>
3666
+ * keys → .env.local
3667
+ * mcp → <paths> (active next agent launch)
3668
+ * skill → <skill paths> (when installed)
3669
+ *
3670
+ * next: npm install <sdk-pkg>
3671
+ * Amba.configure({ projectId, clientKey }) at app startup
3672
+ *
3673
+ * The fallback for "no MCP client config detected" is a one-line
3674
+ * note + a paste-ready snippet — still no restart copy.
2131
3675
  */
2132
3676
  function buildSandboxNextStepsLines(r) {
2133
3677
  const lines = [];
2134
- lines.push(`✓ Sandbox account provisioned (${r.email})`);
2135
- lines.push(`✓ Project created: ${r.projectId}`);
2136
- lines.push(`✓ Credentials saved to ~/.amba/credentials.json`);
2137
- if (r.credentialsBackedUpTo) lines.push(` ! Existing non-sandbox credentials backed up to ${r.credentialsBackedUpTo}`);
2138
- lines.push(`✓ .env.local updated with AMBA_PROJECT_ID + AMBA_CLIENT_KEY + AMBA_API_URL`);
2139
- lines.push(`✓ AMBA.md written (sandbox guide)`);
2140
- if (r.skillPath) lines.push(`✓ /amba-build skill installed: ${r.skillPath}`);
2141
- const clientKinds = clientKindsFromPaths(r.mcpConfigsWritten.map((m) => m.path));
2142
- if (r.mcpConfigsWritten.length > 0) for (const m of r.mcpConfigsWritten) {
2143
- lines.push(`✓ MCP config updated: ${m.path} (entry: 'amba')`);
2144
- if (m.backedUpTo) lines.push(` ! Existing amba entry backed up to ${m.backedUpTo}`);
2145
- }
2146
- else {
2147
- lines.push(`! No MCP client config detected — paste this into your MCP client's config manually:`);
3678
+ lines.push(`✓ Amba ready`);
3679
+ lines.push(` project: ${shortId(r.projectId)} (${r.email})`);
3680
+ lines.push(` keys → ${r.envLocalPath}`);
3681
+ if (r.mcpConfigsWritten.length > 0) {
3682
+ const mcpPaths = r.mcpConfigsWritten.map((m) => m.path).join(", ");
3683
+ lines.push(` mcp → ${mcpPaths} (active next agent launch)`);
3684
+ for (const m of r.mcpConfigsWritten) if (m.backedUpTo) lines.push(` note: previous amba entry backed up to ${m.backedUpTo}`);
3685
+ } else lines.push(` mcp → no MCP client config detected (paste snippet below)`);
3686
+ if (r.skillPath) lines.push(` skill → ${r.skillPath}`);
3687
+ if (r.credentialsBackedUpTo) lines.push(` note: previous non-sandbox credentials backed up to ${r.credentialsBackedUpTo}`);
3688
+ lines.push("");
3689
+ lines.push(` next: npm install ${r.sdkPackage}`);
3690
+ lines.push(` Amba.configure({ projectId: process.env.AMBA_PROJECT_ID, clientKey: process.env.AMBA_CLIENT_KEY })`);
3691
+ lines.push(` later: amba claim <your-email> to upgrade past sandbox`);
3692
+ if (r.mcpConfigsWritten.length === 0) {
2148
3693
  lines.push("");
3694
+ lines.push(` Paste into your MCP client config:`);
2149
3695
  for (const snippetLine of formatManualMcpSnippet(r.pat).split("\n")) lines.push(` ${snippetLine}`);
2150
- lines.push("");
2151
3696
  }
2152
- lines.push("");
2153
- lines.push("Next:");
2154
- lines.push(` → npm install ${r.sdkPackage}`);
2155
- lines.push(` → Initialize the SDK in your app entry: Amba.configure({ projectId: process.env.AMBA_PROJECT_ID, clientKey: process.env.AMBA_CLIENT_KEY })`);
2156
- lines.push("");
2157
- lines.push("Done. Restart your MCP client so it loads the Amba MCP server:");
2158
- if (clientKinds.length === 0) lines.push(" • Quit your MCP client (Cmd+Q on macOS) and reopen it.");
2159
- else for (const kind of clientKinds) lines.push(` • ${restartHintForClient(kind)}`);
2160
- lines.push("");
2161
- lines.push("After the restart, re-ask the original question — Amba's MCP toolset will be available.");
2162
- if (r.skillPath) lines.push("Tip: paste `/amba-build <design-url>` to scaffold a full Expo app with Amba as the backend.");
2163
- lines.push("");
2164
- lines.push(`Sandbox limits: 100 MAU, 10 MB DB. Verify ${r.email} in the console to upgrade to Free.`);
2165
- if (r.verifyUrl) lines.push(`Verify URL: ${r.verifyUrl}`);
2166
3697
  return lines;
2167
3698
  }
2168
- function restartHintForClient(kind) {
2169
- switch (kind) {
2170
- case "claude-code": return "Claude Code: Cmd+Q, then reopen";
2171
- case "cursor": return "Cursor: Cmd+Q, then reopen";
2172
- case "windsurf": return "Windsurf: quit + relaunch";
2173
- }
2174
- }
2175
3699
  function printSandboxNextSteps(r) {
2176
- for (const line of buildSandboxNextStepsLines(r)) if (line.startsWith("✓ ")) console.log(pc.green(" " + line.slice(0, 2)) + line.slice(2));
2177
- else if (line.startsWith("! ")) console.log(pc.yellow(" " + line.slice(0, 2)) + line.slice(2));
2178
- else if (line.startsWith(" ! ")) console.log(pc.yellow(" ! ") + line.slice(4));
2179
- else if (line.startsWith("Next:") || line.startsWith("Done. Restart your MCP client")) console.log(pc.bold(" " + line));
2180
- else if (line.startsWith("Sandbox limits:") || line.startsWith("Verify URL:") || line.startsWith("After the restart")) console.log(pc.dim(" " + line));
2181
- else console.log(" " + line);
3700
+ const lines = buildSandboxNextStepsLines(r);
3701
+ console.log();
3702
+ for (const line of lines) if (line.startsWith("✓ ")) console.log(" " + pc.green("✓") + pc.bold(line.slice(1)));
3703
+ else if (line.startsWith(" note:")) console.log(" " + pc.yellow(line.slice(2)));
3704
+ else if (line.startsWith(" next:")) {
3705
+ const cmd = line.slice(8);
3706
+ console.log(" " + pc.dim("next: ") + pc.cyan(cmd));
3707
+ } else if (line.startsWith(" later:")) {
3708
+ const cmd = line.slice(9);
3709
+ console.log(" " + pc.dim("later: ") + pc.cyan(cmd));
3710
+ } else if (line.startsWith(" project:") || line.startsWith(" keys") || line.startsWith(" mcp") || line.startsWith(" skill")) console.log(pc.dim(line));
3711
+ else console.log(line);
2182
3712
  console.log();
2183
3713
  }
2184
3714
  //#endregion
@@ -2539,7 +4069,7 @@ async function configSetCommand(key, rawValue) {
2539
4069
  }
2540
4070
  //#endregion
2541
4071
  //#region src/commands/projects.ts
2542
- function confirm(question) {
4072
+ function confirm$1(question) {
2543
4073
  const rl = createInterface({
2544
4074
  input: process.stdin,
2545
4075
  output: process.stdout
@@ -2551,7 +4081,7 @@ function confirm(question) {
2551
4081
  });
2552
4082
  });
2553
4083
  }
2554
- function handleError(err) {
4084
+ function handleError$1(err) {
2555
4085
  if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
2556
4086
  else console.log(pc.red(" ✗") + ` ${err.message}`);
2557
4087
  else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
@@ -2601,7 +4131,7 @@ async function projectsListCommand() {
2601
4131
  console.log(pc.dim(` ${projects.length} project${projects.length === 1 ? "" : "s"}`));
2602
4132
  console.log();
2603
4133
  } catch (err) {
2604
- handleError(err);
4134
+ handleError$1(err);
2605
4135
  }
2606
4136
  }
2607
4137
  async function projectsCreateCommand(input) {
@@ -2627,6 +4157,7 @@ async function projectsCreateCommand(input) {
2627
4157
  const res = await createProject({
2628
4158
  name: input.name,
2629
4159
  bundle_id: input.bundleId,
4160
+ google_oauth_client_id: input.googleOauthClientId,
2630
4161
  platform: input.platform,
2631
4162
  environment
2632
4163
  });
@@ -2637,7 +4168,7 @@ async function projectsCreateCommand(input) {
2637
4168
  try {
2638
4169
  const s = (await getProvisioningStatus(id)).data;
2639
4170
  console.log(pc.dim(` Status: ${s.status}`));
2640
- if (s.errorMessage) console.log(pc.yellow(" !") + ` ${s.errorMessage}`);
4171
+ if (s.region) console.log(pc.dim(` Region: ${s.region}`));
2641
4172
  } catch {
2642
4173
  console.log(pc.dim(" (Provisioning runs asynchronously.)"));
2643
4174
  }
@@ -2646,7 +4177,33 @@ async function projectsCreateCommand(input) {
2646
4177
  console.log(pc.dim(" Next: ") + pc.cyan(`amba projects show ${id}`));
2647
4178
  console.log();
2648
4179
  } catch (err) {
2649
- handleError(err);
4180
+ handleError$1(err);
4181
+ }
4182
+ }
4183
+ async function projectsUpdateCommand(projectId, input) {
4184
+ console.log();
4185
+ console.log(pc.bold(` amba projects update ${projectId}`));
4186
+ console.log(pc.dim(" ─────────────────────────────────"));
4187
+ console.log();
4188
+ const patch = {};
4189
+ if (input.name !== void 0) patch.name = input.name;
4190
+ if (input.bundleId !== void 0) patch.bundle_id = input.bundleId;
4191
+ if (input.googleOauthClientId !== void 0) patch.google_oauth_client_id = input.googleOauthClientId;
4192
+ if (input.platform !== void 0) patch.platform = input.platform;
4193
+ if (input.environment !== void 0) patch.environment = input.environment;
4194
+ try {
4195
+ const res = await updateProject(projectId, patch);
4196
+ console.log(pc.green(" ✓") + ` Updated ${pc.bold(res.data.name)} ${pc.dim(`(${res.data.id})`)}`);
4197
+ if (res.data.bundle_id) console.log(pc.dim(` Bundle ID: ${res.data.bundle_id}`));
4198
+ if (res.data.google_oauth_client_id) console.log(pc.dim(` Google OAuth client id: ${res.data.google_oauth_client_id}`));
4199
+ console.log();
4200
+ } catch (err) {
4201
+ if (err instanceof ApiClientError && err.statusCode === 404) {
4202
+ console.log(pc.red(" ✗") + ` Project not found: ${projectId}`);
4203
+ console.log();
4204
+ process.exit(1);
4205
+ }
4206
+ handleError$1(err);
2650
4207
  }
2651
4208
  }
2652
4209
  async function projectsShowCommand(projectId) {
@@ -2664,7 +4221,7 @@ async function projectsShowCommand(projectId) {
2664
4221
  console.log();
2665
4222
  process.exit(1);
2666
4223
  }
2667
- handleError(err);
4224
+ handleError$1(err);
2668
4225
  }
2669
4226
  }
2670
4227
  async function projectsDeleteCommand(projectId, opts = {}) {
@@ -2673,7 +4230,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
2673
4230
  console.log(pc.dim(" ─────────────────────────────────"));
2674
4231
  console.log();
2675
4232
  if (!opts.yes) {
2676
- if (!await confirm(pc.yellow(` Delete project ${projectId}? This is irreversible. (y/N) `))) {
4233
+ if (!await confirm$1(pc.yellow(` Delete project ${projectId}? This is irreversible. (y/N) `))) {
2677
4234
  console.log(pc.dim(" Aborted."));
2678
4235
  console.log();
2679
4236
  return;
@@ -2689,7 +4246,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
2689
4246
  console.log();
2690
4247
  process.exit(1);
2691
4248
  }
2692
- handleError(err);
4249
+ handleError$1(err);
2693
4250
  }
2694
4251
  }
2695
4252
  //#endregion
@@ -2993,14 +4550,14 @@ async function dbMigrateCommand(opts = {}) {
2993
4550
  console.log(pc.dim(" Running tenant migrations..."));
2994
4551
  console.log();
2995
4552
  try {
2996
- const workflowId = (await reprovisionProject(projectId)).data.workflowId;
2997
- console.log(pc.green(" ✓") + " Reprovision workflow started");
2998
- if (workflowId) console.log(pc.dim(` workflowId: ${workflowId}`));
4553
+ const jobId = (await reprovisionProject(projectId)).data.job_id;
4554
+ console.log(pc.green(" ✓") + " Reprovision started");
4555
+ if (jobId) console.log(pc.dim(` job: ${jobId}`));
2999
4556
  console.log();
3000
4557
  try {
3001
4558
  const status = await getProvisioningStatus(projectId);
3002
4559
  console.log(pc.dim(` Status: ${status.data.status}`));
3003
- if (status.data.errorMessage) console.log(pc.yellow(" !") + ` ${status.data.errorMessage}`);
4560
+ if (status.data.region) console.log(pc.dim(` Region: ${status.data.region}`));
3004
4561
  } catch {}
3005
4562
  console.log();
3006
4563
  console.log(pc.dim(" Check again with: ") + pc.cyan(`amba projects show ${projectId}`));
@@ -3410,36 +4967,44 @@ function parseEnv(content) {
3410
4967
  /**
3411
4968
  * Customer-function bundling for `amba functions deploy`.
3412
4969
  *
3413
- * Uses esbuild (the Workers ecosystem bundler-of-record). The shared
3414
- * runtime stdlib is marked `external` so customer bundles don't
3415
- * re-include megabytes of `@anthropic-ai/sdk`, `postgres`, `zod`, etc.;
3416
- * these resolve at dispatch time via platform-level bindings.
4970
+ * Uses esbuild. Customer code is bundled into a single self-contained
4971
+ * ES module — the upstream runtime resolves nothing at dispatch time
4972
+ * except built-in JavaScript globals.
3417
4973
  *
3418
4974
  * Two checks gate the bundle before upload:
3419
4975
  * 1. Pre-upload size check against `BUNDLE_MAX_SIZE_BYTES` (8 MB
3420
4976
  * default — the platform's 10 MB compressed cap minus 2 MB
3421
4977
  * headroom) with a clear error pointing at the externalization
3422
4978
  * config.
3423
- * 2. Bundle-shape report — the CLI prints what's externalized vs
3424
- * bundled at deploy time so size issues are debuggable.
4979
+ * 2. Bundle-shape report — the CLI prints what's bundled vs
4980
+ * externalized at deploy time so size issues are debuggable.
4981
+ *
4982
+ * History note (2026-05-27): the prior version of this file pinned
4983
+ * `@layers/amba-functions` + `@layers/amba-api-middleware` as
4984
+ * "platform-level bindings" externals. Neither is — they were
4985
+ * server-side packages, and `@layers/amba-functions` was unpublished
4986
+ * in the 4.0.2 cutover (a deprecated wrapper that never matched the
4987
+ * actual runtime). Any function importing one of them was rejected
4988
+ * upstream as "no such module." The default externals list is now
4989
+ * empty; customer code is expected to be self-contained.
3425
4990
  */
3426
4991
  /**
3427
- * Modules customer code MUST externalize. The runtime exposes these as
3428
- * platform-level bindings; bundling them per-script wastes hundreds of
3429
- * KB to MBs and quickly hits the script-size cap.
4992
+ * Modules the bundler treats as `external` by default. The Amba
4993
+ * function runtime exposes zero npm packages — there is no "platform
4994
+ * stdlib" for customer functions to import. Keep this list empty.
4995
+ *
4996
+ * The `extraExternals` field on `BundleOptions` is a programmatic
4997
+ * escape hatch (used by tests + future CLI wiring). It's intentionally
4998
+ * not exposed as a `amba functions deploy` flag today — externalizing
4999
+ * a module that isn't actually provided at runtime is exactly the
5000
+ * footgun this list-defaults-to-empty change closes.
3430
5001
  */
3431
- const RUNTIME_STDLIB_EXTERNALS = [
3432
- "@layers/amba-functions",
3433
- "@layers/amba-api-middleware",
3434
- "@anthropic-ai/sdk",
3435
- "postgres",
3436
- "zod"
3437
- ];
5002
+ const RUNTIME_STDLIB_EXTERNALS = [];
3438
5003
  var BundleSizeError = class extends Error {
3439
5004
  sizeBytes;
3440
5005
  maxBytes;
3441
5006
  constructor(sizeBytes, maxBytes) {
3442
- super(`Function bundle is ${formatBytes$1(sizeBytes)} which exceeds the ${formatBytes$1(maxBytes)} cap. Externalize heavy dependencies via the runtime stdlib (see RUNTIME_STDLIB_EXTERNALS) or split your function into smaller pieces.`);
5007
+ super(`Function bundle is ${formatBytes$1(sizeBytes)} which exceeds the ${formatBytes$1(maxBytes)} cap. Split the function into smaller pieces or drop heavy dependencies. (There is no runtime-provided npm stdlib to externalize against — every import must bundle.)`);
3443
5008
  this.sizeBytes = sizeBytes;
3444
5009
  this.maxBytes = maxBytes;
3445
5010
  this.name = "BundleSizeError";
@@ -3496,8 +5061,10 @@ function printBundleReport(bundle) {
3496
5061
  console.log(pc.dim(" Bundle:"));
3497
5062
  console.log(pc.dim(" size: ") + `${formatBytes$1(bundle.compressedSize)} compressed ` + pc.dim(`(${formatBytes$1(bundle.uncompressedSize)} raw)`));
3498
5063
  console.log(pc.dim(" sha: ") + bundle.sha256.slice(0, 16) + pc.dim("…"));
3499
- console.log(pc.dim(" externalized:"));
3500
- for (const e of bundle.externals) console.log(pc.dim(" • ") + e);
5064
+ if (bundle.externals.length > 0) {
5065
+ console.log(pc.dim(" externalized:"));
5066
+ for (const e of bundle.externals) console.log(pc.dim(" • ") + e);
5067
+ }
3501
5068
  console.log();
3502
5069
  }
3503
5070
  async function gzipSizeOf(bytes) {
@@ -3541,11 +5108,21 @@ async function functionsDeployCommand(entryPoint, options = {}) {
3541
5108
  return;
3542
5109
  }
3543
5110
  console.log(pc.dim(" Uploading…"));
3544
- const result = await deployFunctionViaApi(projectId, {
3545
- name: functionName,
3546
- bundleCode: bundle.code,
3547
- rate_limit: rateLimit
3548
- });
5111
+ let result;
5112
+ try {
5113
+ result = await deployFunctionViaApi(projectId, {
5114
+ name: functionName,
5115
+ bundleCode: bundle.code,
5116
+ rate_limit: rateLimit
5117
+ });
5118
+ } catch (err) {
5119
+ if (err instanceof ApiClientError && err.statusCode === 503) {
5120
+ const base = err.message.trimEnd();
5121
+ const sep = /[.!?]$/.test(base) ? " " : ". ";
5122
+ throw new Error(`${base}${sep}The function platform is temporarily unavailable; retry in ~30s.`);
5123
+ }
5124
+ throw err;
5125
+ }
3549
5126
  console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version}`)}`);
3550
5127
  console.log(pc.green(" ✓") + ` URL: ${pc.underline(result.fn_url)}`);
3551
5128
  if (rateLimit) console.log(pc.dim(` Rate limit: ${rateLimit.max} per ${rateLimit.window} (key=${rateLimit.key}) — enforced pre-dispatch`));
@@ -3600,11 +5177,12 @@ async function functionsScheduleCommand(name, cron, options = {}) {
3600
5177
  * — externalization rules, size cap, and source-map handling are
3601
5178
  * identical so dev → prod parity is automatic.
3602
5179
  *
3603
- * Handler shape: `export default async function (req: Request): Promise<Response>`.
5180
+ * Handler shape: `export default { async fetch(req, env, ctx) { ... } }`.
3604
5181
  * `.env.local` values in the customer's working directory are passed
3605
- * through to the child process so the handler can read them via
3606
- * `process.env.MY_KEY`. Existing values in the parent's env take
3607
- * priority (so `MY_KEY=x amba functions dev …` wins).
5182
+ * through to the child process and bridged into the `env` parameter so
5183
+ * the handler reads them the same way as in production (`env.MY_KEY`).
5184
+ * Existing values in the parent's env take priority (so
5185
+ * `MY_KEY=x amba functions dev …` wins).
3608
5186
  */
3609
5187
  async function functionsDevCommand(entryPoint, options = {}) {
3610
5188
  const port = validatePort(options.port);
@@ -3879,11 +5457,37 @@ try {
3879
5457
  // \`pathToFileURL\` is a no-op equivalent on POSIX (returns file:///...).
3880
5458
  const bundleUrl = pathToFileURL(bundlePath).href;
3881
5459
  const mod = await import(bundleUrl);
3882
- if (typeof mod.default !== 'function') {
3883
- process.stderr.write(' Error: entry file must export default async function (req: Request): Promise<Response>\\n');
5460
+ // Support the canonical modules export shape: export default { async fetch(req, env, ctx) { ... } }
5461
+ // .env.local values are already in process.env (parent passes childEnv to the child process).
5462
+ // Bridge them into the env parameter so dev/prod parity holds — production injects the same
5463
+ // names as env bindings; local dev reads them from .env.local via process.env.
5464
+ if (mod.default && typeof mod.default.fetch === 'function') {
5465
+ const env = Object.assign(Object.create(null), process.env);
5466
+ const fetchFn = mod.default.fetch.bind(mod.default);
5467
+ handler = (req) => {
5468
+ // Fresh ctx per request — production gives each request its own
5469
+ // ExecutionContext; a shared stub would leak per-request state
5470
+ // across concurrent requests in dev but not in prod.
5471
+ const ctx = {
5472
+ waitUntil: (p) => {
5473
+ // Swallow rejections from background promises — prod handles
5474
+ // waitUntil rejections without crashing. Leaving the rejection
5475
+ // unhandled would cause Node 15+ to crash the child process
5476
+ // even though the HTTP response was already sent.
5477
+ Promise.resolve(p).catch(() => {});
5478
+ },
5479
+ passThroughOnException: () => {},
5480
+ };
5481
+ return fetchFn(req, env, ctx);
5482
+ };
5483
+ } else if (typeof mod.default === 'function') {
5484
+ // Legacy plain-function shape — still accepted for backward compat but env
5485
+ // parameter is not available (secrets only reachable via process.env).
5486
+ handler = mod.default;
5487
+ } else {
5488
+ process.stderr.write(' Error: entry file must export default { async fetch(req, env, ctx) { ... } }\\n');
3884
5489
  process.exit(2);
3885
5490
  }
3886
- handler = mod.default;
3887
5491
  } catch (err) {
3888
5492
  process.stderr.write(' Bundle import failed: ' + (err && err.stack ? err.stack : err) + '\\n');
3889
5493
  process.exit(2);
@@ -4033,8 +5637,8 @@ function parseRateLimitFlags(options) {
4033
5637
  * Output formatting:
4034
5638
  * - `--json`: NDJSON to stdout (one event per line) so `| jq` works.
4035
5639
  * - default: human-readable lines:
4036
- * 2026-05-08T12:34:56.789Z scan-letter-v3 [info] "scanning"
4037
- * 2026-05-08T12:34:57.012Z scan-letter-v3 [exception] TypeError: …
5640
+ * 2026-05-08T12:34:56.789Z scan-letter@v3 [info] "scanning"
5641
+ * 2026-05-08T12:34:57.012Z scan-letter@v3 [exception] TypeError: …
4038
5642
  *
4039
5643
  * Server-side scoping is enforced — the API filters by ScriptName
4040
5644
  * prefix so a developer can never read another tenant's logs.
@@ -4087,7 +5691,7 @@ function printEvents(events, asJson) {
4087
5691
  }
4088
5692
  for (const e of ordered) {
4089
5693
  const ts = e.EventTimestampMs ? new Date(e.EventTimestampMs).toISOString() : "????-??-??T??:??:??Z";
4090
- const script = e.ScriptName ?? "<unknown-script>";
5694
+ const script = e.function_name ? `${e.function_name}@v${e.version ?? "?"}` : "<unknown-function>";
4091
5695
  if (e.Logs && e.Logs.length > 0) for (const logLine of e.Logs) {
4092
5696
  const level = renderLevel(logLine.Level);
4093
5697
  const msg = renderMessage(logLine.Message);
@@ -4148,7 +5752,6 @@ async function aiProvidersAddCommand(provider, options) {
4148
5752
  });
4149
5753
  console.log(pc.green(" ✓") + ` Registered ${provider}`);
4150
5754
  if (res.data.api_key_preview) console.log(pc.dim(` key preview: ${res.data.api_key_preview}`));
4151
- console.log(pc.dim(` secret_name: ${res.data.api_key_secret_name ?? "(unset)"}`));
4152
5755
  console.log();
4153
5756
  }
4154
5757
  async function aiProvidersListCommand() {
@@ -4160,7 +5763,7 @@ async function aiProvidersListCommand() {
4160
5763
  console.log();
4161
5764
  return;
4162
5765
  }
4163
- for (const p of res.data) console.log(` ${pc.bold(p.name)} ` + pc.dim(`secret=${p.api_key_secret_name ?? "(unset)"}`) + (p.updated_at ? pc.dim(` updated=${p.updated_at}`) : ""));
5766
+ for (const p of res.data) console.log(` ${pc.bold(p.name)} ` + pc.dim(`configured=${p.configured ? "yes" : "no"}`) + (p.updated_at ? pc.dim(` updated=${p.updated_at}`) : ""));
4164
5767
  console.log();
4165
5768
  }
4166
5769
  async function aiProvidersDeleteCommand(provider) {
@@ -4194,11 +5797,20 @@ async function resolveSecretValue(opts) {
4194
5797
  //#endregion
4195
5798
  //#region src/commands/secrets.ts
4196
5799
  /**
4197
- * `amba secrets ...` — write per-function secrets through the platform
4198
- * API. The CLI sends `{name, value, function}` to the admin endpoint;
4199
- * the server stores the value, validates reserved binding names, and
4200
- * propagates it to the deployed Worker's binding. Sync is eventual —
4201
- * `amba secrets list` shows progress.
5800
+ * `amba secrets ...` — write secrets through the platform API. The CLI
5801
+ * sends `{name, value, function?}` to the admin endpoint; the server
5802
+ * stores the value, validates reserved binding names, and propagates it to
5803
+ * the deployed Worker binding(s). Sync is eventual — `amba secrets list`
5804
+ * shows progress.
5805
+ *
5806
+ * Two scopes:
5807
+ * - `--function <name>` → the secret binds to that one function.
5808
+ * - no `--function` → a PROJECT-WIDE secret, visible to every
5809
+ * function in the project (and to any function deployed later). Set it
5810
+ * once instead of repeating the same value per function.
5811
+ *
5812
+ * Secrets may be set BEFORE the function is deployed. The sync drains once
5813
+ * a deployment lands (and `amba functions deploy` re-syncs at the end).
4202
5814
  */
4203
5815
  async function secretsSetCommand(name, value, options) {
4204
5816
  validateSecretName(name);
@@ -4208,14 +5820,15 @@ async function secretsSetCommand(name, value, options) {
4208
5820
  });
4209
5821
  const projectConfig = await loadProjectConfig();
4210
5822
  if (options.env === "prod") console.log(pc.dim(" Note: --env is informational; prod and dev share one secret namespace per project."));
5823
+ const scopeLabel = options.function ? options.function : "project-wide";
4211
5824
  console.log();
4212
5825
  console.log(pc.bold(` amba secrets set ${pc.cyan(name)}`));
4213
- console.log(pc.dim(` function=${options.function} env=${options.env ?? "dev"}`));
5826
+ console.log(pc.dim(` scope=${scopeLabel} env=${options.env ?? "dev"}`));
4214
5827
  console.log();
4215
5828
  const res = await setSecretViaApi(projectConfig.projectId, {
4216
5829
  name,
4217
5830
  value: resolvedValue,
4218
- function: options.function
5831
+ ...options.function ? { function: options.function } : {}
4219
5832
  });
4220
5833
  console.log(pc.green(" ✓") + ` Secret ${pc.cyan(name)} stored ${pc.dim(`v${res.data.version}`)}`);
4221
5834
  console.log(pc.green(" ✓") + ` Workers Secret sync ${pc.dim(`(status=${res.data.sync_status})`)} queued`);
@@ -4223,17 +5836,22 @@ async function secretsSetCommand(name, value, options) {
4223
5836
  console.log(pc.dim(" Workers Secret will be live within ~30s. Run `amba secrets list` to check sync status."));
4224
5837
  console.log();
4225
5838
  }
4226
- async function secretsListCommand() {
4227
- const res = await listSecretsViaApi((await loadProjectConfig()).projectId);
5839
+ async function secretsListCommand(options = {}) {
5840
+ const res = await listSecretsViaApi((await loadProjectConfig()).projectId, { function: options.function });
4228
5841
  console.log();
5842
+ if (options.function) {
5843
+ console.log(pc.dim(` Secrets for function ${pc.bold(options.function)}:`));
5844
+ console.log();
5845
+ }
4229
5846
  if (res.data.length === 0) {
4230
- console.log(pc.dim(" No secrets configured."));
5847
+ console.log(pc.dim(options.function ? ` No secrets configured for ${options.function}.` : " No secrets configured."));
4231
5848
  console.log();
4232
5849
  return;
4233
5850
  }
4234
5851
  for (const row of res.data) {
4235
5852
  const status = renderStatus(row.sync_status);
4236
- console.log(` ${pc.bold(row.name)} ` + pc.dim(`(${row.function})`) + ` v${row.version} ${status}` + (row.last_error ? pc.dim(` — ${row.last_error}`) : ""));
5853
+ const scope = row.function ?? "project-wide";
5854
+ console.log(` ${pc.bold(row.name)} ` + pc.dim(`(${scope})`) + ` v${row.version} ${status}` + (row.last_error ? pc.dim(` — ${row.last_error}`) : ""));
4237
5855
  }
4238
5856
  console.log();
4239
5857
  }
@@ -4256,6 +5874,168 @@ function renderStatus(status) {
4256
5874
  }
4257
5875
  }
4258
5876
  //#endregion
5877
+ //#region src/commands/billing.ts
5878
+ /**
5879
+ * `amba billing *` subcommands — CLI access to the per-project billing
5880
+ * surface that ships behind `/v1/admin/projects/:id/billing/*`.
5881
+ *
5882
+ * amba billing status — tier, headroom on each
5883
+ * metered axis, next-bill date,
5884
+ * human_action_required.
5885
+ *
5886
+ * amba billing upgrade --tier <t> — print the Stripe Checkout
5887
+ * URL for tier ∈ {pro, scale}
5888
+ * [--interval month|year] at the chosen interval. CLI
5889
+ * deliberately does NOT auto-
5890
+ * open a browser: agents pipe
5891
+ * the URL into a confirmation
5892
+ * step, humans copy-paste.
5893
+ *
5894
+ * amba billing portal — print the Customer Portal
5895
+ * URL for card / cancel / etc.
5896
+ *
5897
+ * amba billing set-ceiling <usd|off> — cap (or remove) the monthly
5898
+ * spend ceiling.
5899
+ *
5900
+ * Project is resolved via `loadProjectConfig` (AMBA_PROJECT_ID env, then
5901
+ * .env / .env.local in the cwd) — same pattern as `amba secrets *`.
5902
+ */
5903
+ function handleError(err) {
5904
+ if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
5905
+ else console.log(pc.red(" ✗") + ` ${err.message}`);
5906
+ else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
5907
+ else console.log(pc.red(" ✗") + " Unknown error");
5908
+ console.log();
5909
+ process.exit(1);
5910
+ }
5911
+ /**
5912
+ * Issue a POST/PUT to the admin API. `api-client.ts` doesn't export a
5913
+ * generic POST helper for arbitrary paths, so this command file owns
5914
+ * its own request wrapper — same auth flow as `request()` in api-client,
5915
+ * intentionally not exported there so we don't grow the public surface.
5916
+ */
5917
+ async function adminWrite(method, path, body) {
5918
+ const token = await resolveBearerToken();
5919
+ const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
5920
+ const res = await fetch(url, {
5921
+ method,
5922
+ headers: {
5923
+ Authorization: `Bearer ${token}`,
5924
+ "Content-Type": "application/json",
5925
+ "User-Agent": "amba-cli/0.1.1"
5926
+ },
5927
+ body: body === void 0 ? void 0 : JSON.stringify(body)
5928
+ });
5929
+ if (!res.ok) {
5930
+ let message = `API request failed: ${res.status} ${res.statusText}`;
5931
+ let code;
5932
+ try {
5933
+ const errorBody = await res.json();
5934
+ if (errorBody.error?.message) {
5935
+ message = errorBody.error.message;
5936
+ code = errorBody.error.code;
5937
+ }
5938
+ } catch {}
5939
+ throw new ApiClientError(message, res.status, code);
5940
+ }
5941
+ return await res.json();
5942
+ }
5943
+ function formatAxis(label, axis, isStorage) {
5944
+ const used = axis.used === null ? "—" : isStorage ? axis.used >= 1024 ? `${(axis.used / 1024).toFixed(2)} GB` : `${axis.used} MB` : axis.used.toLocaleString();
5945
+ const limit = axis.limit === null ? "unlimited" : isStorage ? axis.limit >= 1024 ? `${(axis.limit / 1024).toFixed(0)} GB` : `${axis.limit} MB` : axis.limit.toLocaleString();
5946
+ const pct = axis.pct === null ? "—" : `${Math.round(axis.pct * 100)}%`;
5947
+ return ` ${pc.dim(label.padEnd(22))} ${used} / ${limit} ${pc.dim(`(${pct})`)}`;
5948
+ }
5949
+ async function billingStatusCommand() {
5950
+ console.log();
5951
+ console.log(pc.bold(" amba billing status"));
5952
+ console.log(pc.dim(" ─────────────────────────────────"));
5953
+ console.log();
5954
+ try {
5955
+ const { projectId } = await loadProjectConfig();
5956
+ const s = (await adminGet(`/projects/${projectId}/billing/status`)).data;
5957
+ console.log(` Tier: ${pc.bold(s.tier)}`);
5958
+ console.log(` Subscription: ${s.subscription_status ?? pc.dim("—")}`);
5959
+ console.log(` Next bill anchor: ${s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : pc.dim("—")}`);
5960
+ console.log(` Spend ceiling: ${s.ceiling_usd === null ? pc.dim("no cap") : `$${s.ceiling_usd}`}`);
5961
+ console.log(` Spend mode: ${s.mode}`);
5962
+ console.log(` Projected overage: $${s.projected_overage_usd_this_month.toFixed(2)}`);
5963
+ if (s.paused_at) console.log(` ${pc.yellow("Paused at:")} ${s.paused_at} ${pc.dim("(wakes on next request)")}`);
5964
+ console.log();
5965
+ console.log(pc.bold(" Usage — rolling 30 days"));
5966
+ console.log(formatAxis("Monthly active users", s.headroom.mau, false));
5967
+ console.log(formatAxis("Engagement events", s.headroom.engagement_events, false));
5968
+ console.log(formatAxis("Telemetry events", s.headroom.telemetry_events, false));
5969
+ console.log(formatAxis("Push deliveries", s.headroom.push, false));
5970
+ console.log(formatAxis("Database storage", s.headroom.db_storage_mb, true));
5971
+ console.log(formatAxis("Media storage", s.headroom.media_storage_mb, true));
5972
+ console.log();
5973
+ if (s.human_action_required !== "none") {
5974
+ const action = s.human_action_required.replaceAll("_", " ");
5975
+ console.log(pc.yellow(" Action required: ") + pc.bold(action));
5976
+ console.log();
5977
+ }
5978
+ } catch (err) {
5979
+ handleError(err);
5980
+ }
5981
+ }
5982
+ async function billingUpgradeCommand(input) {
5983
+ console.log();
5984
+ console.log(pc.bold(" amba billing upgrade"));
5985
+ console.log(pc.dim(" ─────────────────────────────────"));
5986
+ console.log();
5987
+ try {
5988
+ const { projectId } = await loadProjectConfig();
5989
+ const interval = input.interval ?? "month";
5990
+ const res = await adminWrite("POST", `/projects/${projectId}/billing/checkout`, {
5991
+ tier: input.tier,
5992
+ interval
5993
+ });
5994
+ console.log(` Tier: ${input.tier} (${interval}ly)`);
5995
+ console.log(` Session: ${pc.dim(res.data.session_id)}`);
5996
+ console.log();
5997
+ console.log(pc.bold(" Open this URL to complete checkout:"));
5998
+ console.log();
5999
+ console.log(` ${pc.cyan(res.data.url)}`);
6000
+ console.log();
6001
+ console.log(pc.dim(" Subscription status updates automatically once Stripe confirms (a few seconds)."));
6002
+ console.log();
6003
+ } catch (err) {
6004
+ handleError(err);
6005
+ }
6006
+ }
6007
+ async function billingPortalCommand() {
6008
+ console.log();
6009
+ console.log(pc.bold(" amba billing portal"));
6010
+ console.log(pc.dim(" ─────────────────────────────────"));
6011
+ console.log();
6012
+ try {
6013
+ const { projectId } = await loadProjectConfig();
6014
+ const res = await adminWrite("POST", `/projects/${projectId}/billing/portal`);
6015
+ console.log(pc.bold(" Open this URL to manage your subscription:"));
6016
+ console.log();
6017
+ console.log(` ${pc.cyan(res.data.url)}`);
6018
+ console.log();
6019
+ } catch (err) {
6020
+ handleError(err);
6021
+ }
6022
+ }
6023
+ async function billingSetCeilingCommand(input) {
6024
+ console.log();
6025
+ console.log(pc.bold(" amba billing set-ceiling"));
6026
+ console.log(pc.dim(" ─────────────────────────────────"));
6027
+ console.log();
6028
+ try {
6029
+ const { projectId } = await loadProjectConfig();
6030
+ await adminWrite(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
6031
+ if (input.ceiling === null) console.log(pc.green(" ✓") + " Spend ceiling removed (linear overage continues).");
6032
+ else console.log(pc.green(" ✓") + ` Spend ceiling set to $${input.ceiling}/mo.`);
6033
+ console.log();
6034
+ } catch (err) {
6035
+ handleError(err);
6036
+ }
6037
+ }
6038
+ //#endregion
4259
6039
  //#region src/commands/collections.ts
4260
6040
  /**
4261
6041
  * `amba collections ...` — thin shells over the admin collection routes.
@@ -4677,13 +6457,21 @@ function makeCodegenHttpClient() {
4677
6457
  * directory (mirrors `wrangler pages deploy ./out` semantics). Dynamic
4678
6458
  * logic belongs in `amba functions deploy`, not in a site directory.
4679
6459
  */
4680
- const SITE_NAME_RE = /^[a-z][a-z0-9_-]{0,49}$/;
4681
- const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
6460
+ const SITE_NAME_RE$1 = /^[a-z][a-z0-9_-]{0,49}$/;
6461
+ const HOSTNAME_RE$1 = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
6462
+ const MAX_CERT_CN_LENGTH = 64;
6463
+ const PLATFORM_DOMAINS = ["amba.host", "amba.dev"];
6464
+ function isPlatformDomain(host) {
6465
+ return PLATFORM_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`));
6466
+ }
4682
6467
  function validateSiteName(name) {
4683
- if (!SITE_NAME_RE.test(name)) throw new Error(`Invalid site name '${name}'. Must match /^[a-z][a-z0-9_-]{0,49}$/ (lowercase ASCII, digits, underscore or hyphen; ≤50 chars).`);
6468
+ if (!SITE_NAME_RE$1.test(name)) throw new Error(`Invalid site name '${name}'. Must match /^[a-z][a-z0-9_-]{0,49}$/ (lowercase ASCII, digits, underscore or hyphen; ≤50 chars).`);
4684
6469
  }
4685
6470
  function validateHostname(host) {
4686
- if (!HOSTNAME_RE.test(host)) throw new Error(`Invalid hostname '${host}'. Must be a DNS-shaped name (e.g. site.example.com).`);
6471
+ const h = host.trim().toLowerCase();
6472
+ if (!HOSTNAME_RE$1.test(h)) throw new Error(`Invalid hostname '${host}'. Must be a DNS-shaped name (e.g. site.example.com).`);
6473
+ if (h.length > MAX_CERT_CN_LENGTH) throw new Error(`Hostname '${host}' is too long for certificate issuance (max ${MAX_CERT_CN_LENGTH} characters). Use a shorter subdomain.`);
6474
+ if (isPlatformDomain(h)) throw new Error(`'${host}' is reserved by the platform. Attach a domain you own, e.g. app.yourdomain.com.`);
4687
6475
  }
4688
6476
  /**
4689
6477
  * Per-deploy size cap. The hosting provider enforces 25 MiB per file +
@@ -4732,7 +6520,7 @@ async function sitesDeployCommand(inputDir, options = {}) {
4732
6520
  if (domains.data.length > 0) {
4733
6521
  console.log();
4734
6522
  console.log(pc.dim(" Domains:"));
4735
- for (const d of domains.data) console.log(` ${pc.bold(d.hostname)} ${formatCertStatus(d.cert_status)}`);
6523
+ for (const d of domains.data) console.log(` ${pc.bold(d.hostname)} ${formatDomainStatus(d)}`);
4736
6524
  }
4737
6525
  console.log();
4738
6526
  }
@@ -4782,32 +6570,51 @@ async function sitesDomainAddCommand(hostname, options) {
4782
6570
  console.log();
4783
6571
  const res = await addSiteDomainViaApi(projectId, options.site, hostname);
4784
6572
  console.log(pc.green(" ✓") + ` Custom hostname registered`);
6573
+ const validation = res.data.ssl_validation ?? [];
6574
+ const ownership = res.data.ownership_verification;
6575
+ const isApex = res.data.is_apex ?? false;
6576
+ if (validation.length > 0 || ownership) {
6577
+ console.log();
6578
+ console.log(pc.dim(" 1. Publish these DNS records to verify ownership:"));
6579
+ for (const rec of validation) if (rec.txt_name && rec.txt_value) console.log(` ${pc.bold("TXT")} ${rec.txt_name} → ${pc.cyan(rec.txt_value)}`);
6580
+ if (ownership?.name && ownership.value) console.log(` ${pc.bold("TXT")} ${ownership.name} → ${pc.cyan(ownership.value)}`);
6581
+ console.log();
6582
+ console.log(pc.dim(" 2. Point your hostname at the Amba target:"));
6583
+ } else {
6584
+ console.log();
6585
+ console.log(pc.dim(" Point your hostname at the Amba target:"));
6586
+ }
6587
+ console.log(` ${pc.bold("CNAME")} ${hostname} → ${pc.cyan(res.data.dns_target)}`);
4785
6588
  console.log();
4786
- console.log(pc.dim(" Point your DNS at:"));
4787
- console.log(` ${pc.bold("CNAME")} ${hostname} → ${pc.cyan(res.data.dns_target)}`);
4788
- console.log();
6589
+ if (isApex) {
6590
+ console.log(pc.dim(` ${hostname} is a root domain — the CNAME above works only if your DNS provider`));
6591
+ console.log(pc.dim(` flattens CNAME-at-root. If it doesn't, A-record apex support is coming soon;`));
6592
+ console.log(pc.dim(` use a subdomain like app.${hostname} in the meantime.`));
6593
+ console.log();
6594
+ }
4789
6595
  if (options.noWait) {
4790
- console.log(pc.yellow(" ! --no-wait — skipping cert poll. Run `amba sites describe` later."));
6596
+ console.log(pc.yellow(" ! --no-wait — skipping status poll. Run `amba sites describe` later."));
4791
6597
  return;
4792
6598
  }
4793
6599
  const timeout = (options.timeout ?? 600) * 1e3;
4794
6600
  const start = Date.now();
4795
- let lastStatus = "";
6601
+ let lastLine = "";
4796
6602
  while (Date.now() - start < timeout) {
4797
6603
  const row = (await listSiteDomains(projectId, options.site)).data.find((d) => d.hostname === hostname);
4798
6604
  if (!row) throw new Error(`Domain ${hostname} disappeared from listing — check API state.`);
4799
- if (row.cert_status !== lastStatus) {
4800
- console.log(pc.dim(` cert_status: ${formatCertStatus(row.cert_status)}`));
4801
- lastStatus = row.cert_status;
6605
+ const line = formatDomainStatus(row);
6606
+ if (line !== lastLine) {
6607
+ console.log(pc.dim(` ${line}`));
6608
+ lastLine = line;
4802
6609
  }
4803
- if (row.cert_status === "active") {
4804
- console.log(pc.green(" ✓") + ` ${hostname} live with valid cert.`);
6610
+ if (row.cert_status === "active" && row.ownership_status === "active") {
6611
+ console.log(pc.green(" ✓") + ` ${hostname} is live with a valid certificate.`);
4805
6612
  return;
4806
6613
  }
4807
- if (row.cert_status === "error") throw new Error(`Cert provisioning failed for ${hostname}. Check that the CNAME points at ${res.data.dns_target}.`);
6614
+ if (row.cert_status === "error" || row.ownership_status === "error") throw new Error(`Domain setup failed for ${hostname}. Confirm the TXT record(s) above are published and the CNAME points at ${res.data.dns_target}.`);
4808
6615
  await sleep(5e3);
4809
6616
  }
4810
- console.log(pc.yellow(` ! Timed out after ${options.timeout ?? 600}s waiting for cert. Re-run \`amba sites describe ${options.site}\` to check status.`));
6617
+ console.log(pc.yellow(` ! Timed out after ${options.timeout ?? 600}s. Re-run \`amba sites describe ${options.site}\` to check status.`));
4811
6618
  }
4812
6619
  async function sitesDomainListCommand(siteName) {
4813
6620
  validateSiteName(siteName);
@@ -4818,7 +6625,7 @@ async function sitesDomainListCommand(siteName) {
4818
6625
  console.log();
4819
6626
  return;
4820
6627
  }
4821
- for (const d of res.data) console.log(` ${pc.bold(d.hostname)} ${formatCertStatus(d.cert_status)}`);
6628
+ for (const d of res.data) console.log(` ${pc.bold(d.hostname)} ${formatDomainStatus(d)}`);
4822
6629
  console.log();
4823
6630
  }
4824
6631
  async function sitesDomainRemoveCommand(hostname, options) {
@@ -4927,10 +6734,157 @@ function formatCertStatus(s) {
4927
6734
  if (s === "error") return pc.red(s);
4928
6735
  return pc.yellow(s);
4929
6736
  }
6737
+ /**
6738
+ * Render BOTH lifecycle states for a domain row. A domain is only live when
6739
+ * ownership AND cert are both `active`, so display paths must show both —
6740
+ * showing cert alone reads "live" while ownership is still pending.
6741
+ */
6742
+ function formatDomainStatus(d) {
6743
+ return `ownership: ${formatCertStatus(d.ownership_status)} cert: ${formatCertStatus(d.cert_status)}`;
6744
+ }
4930
6745
  function sleep(ms) {
4931
6746
  return new Promise((r) => setTimeout(r, ms));
4932
6747
  }
4933
6748
  //#endregion
6749
+ //#region src/commands/domains.ts
6750
+ /**
6751
+ * `amba domains ...` commands — buy a domain through Amba.
6752
+ *
6753
+ * amba domains search <query> find available domains + prices
6754
+ * amba domains check <domain...> authoritative availability + price
6755
+ * amba domains buy <domain> --site s buy + connect to a site
6756
+ * amba domains list domains this project has purchased
6757
+ *
6758
+ * Search + check are free. `buy` surfaces the cost and requires explicit
6759
+ * confirmation (interactive y/N, or `--yes`) before it submits a purchase —
6760
+ * and the API itself only executes a real, paid registration when the
6761
+ * platform has live purchasing enabled. A registered domain is auto-
6762
+ * connected to the chosen site with no DNS setup by the customer.
6763
+ */
6764
+ const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
6765
+ const SITE_NAME_RE = /^[a-z][a-z0-9_-]{0,49}$/;
6766
+ function confirm(question) {
6767
+ const rl = createInterface({
6768
+ input: process.stdin,
6769
+ output: process.stdout
6770
+ });
6771
+ return new Promise((resolve) => {
6772
+ rl.question(question, (answer) => {
6773
+ rl.close();
6774
+ resolve(/^y(es)?$/i.test(answer.trim()));
6775
+ });
6776
+ });
6777
+ }
6778
+ function formatPrice(price, currency) {
6779
+ if (price === null) return pc.dim("price unavailable");
6780
+ return pc.bold(`$${price.toFixed(2)} ${currency}/yr`);
6781
+ }
6782
+ async function domainsSearchCommand(query, options = {}) {
6783
+ const { projectId } = await loadProjectConfig();
6784
+ const res = await searchDomainsViaApi(projectId, query, options.limit);
6785
+ console.log();
6786
+ if (res.data.length === 0) {
6787
+ console.log(pc.dim(` No suggestions for "${query}".`));
6788
+ console.log();
6789
+ return;
6790
+ }
6791
+ for (const s of res.data) {
6792
+ const tag = s.available ? pc.green("available") : pc.red("taken");
6793
+ const premium = s.premium ? pc.yellow(" premium") : "";
6794
+ console.log(` ${pc.bold(s.domain)} ${tag}${premium} ${formatPrice(s.price_usd, s.currency)}`);
6795
+ }
6796
+ console.log();
6797
+ console.log(pc.dim(" Buy one with: amba domains buy <domain> --site <site>"));
6798
+ console.log();
6799
+ }
6800
+ async function domainsCheckCommand(domains) {
6801
+ if (domains.length === 0) throw new Error("Pass at least one domain to check.");
6802
+ if (domains.length > 20) throw new Error("At most 20 domains per check.");
6803
+ const normalised = domains.map((d) => d.toLowerCase());
6804
+ for (const d of normalised) if (!HOSTNAME_RE.test(d)) throw new Error(`Invalid domain '${d}'.`);
6805
+ const { projectId } = await loadProjectConfig();
6806
+ const res = await checkDomainsViaApi(projectId, normalised);
6807
+ console.log();
6808
+ for (const a of res.data) {
6809
+ const tag = a.available ? pc.green("available") : pc.red("taken");
6810
+ const unsupported = a.supported ? "" : pc.yellow(" (extension not supported)");
6811
+ console.log(` ${pc.bold(a.domain)} ${tag}${unsupported} ${formatPrice(a.price_usd, a.currency)}`);
6812
+ }
6813
+ console.log();
6814
+ }
6815
+ async function domainsBuyCommand(domain, options) {
6816
+ const hostname = domain.toLowerCase();
6817
+ if (!HOSTNAME_RE.test(hostname)) throw new Error(`Invalid domain '${domain}'.`);
6818
+ if (!options.site || !SITE_NAME_RE.test(options.site)) throw new Error("--site <name> is required (the site to connect the domain to).");
6819
+ const { projectId } = await loadProjectConfig();
6820
+ console.log();
6821
+ console.log(pc.bold(` amba domains buy ${pc.cyan(hostname)}`));
6822
+ console.log(pc.dim(` → connect to site ${pc.cyan(options.site)}`));
6823
+ console.log();
6824
+ const check = await checkDomainsViaApi(projectId, [hostname]);
6825
+ const availability = check.data.find((d) => d.domain === hostname) ?? check.data[0];
6826
+ if (!availability) throw new Error("Could not determine availability for that domain.");
6827
+ if (!availability.available) throw new Error(`${hostname} is not available to register. Try \`amba domains search\`.`);
6828
+ if (!availability.supported) throw new Error(`${hostname} uses an extension we can't register yet.`);
6829
+ if (availability.price_usd === null) throw new Error(`${hostname} can't be priced right now — try again shortly.`);
6830
+ const price = availability.price_usd;
6831
+ console.log(` Price: ${formatPrice(price, availability.currency)}` + (availability.premium ? pc.yellow(" (premium)") : ""));
6832
+ console.log(pc.dim(` Includes: registration${options.noPrivacy ? "" : " + WHOIS privacy"}${options.noAutoRenew ? "" : " + auto-renew"}, auto-connected to ${options.site} (no DNS setup).`));
6833
+ console.log();
6834
+ if (!options.yes) {
6835
+ if (!await confirm(pc.yellow(` Buy ${hostname} for $${price.toFixed(2)} ${availability.currency}? `) + "(y/N) ")) {
6836
+ console.log(pc.dim(" Cancelled — nothing was purchased."));
6837
+ console.log();
6838
+ return;
6839
+ }
6840
+ }
6841
+ const r = (await purchaseDomainViaApi(projectId, {
6842
+ domain: hostname,
6843
+ site: options.site,
6844
+ accept_price_usd: price,
6845
+ confirm: true,
6846
+ privacy: !options.noPrivacy,
6847
+ auto_renew: !options.noAutoRenew,
6848
+ ...options.years ? { years: options.years } : {}
6849
+ })).data;
6850
+ if (!r.executed) {
6851
+ if (r.gated) {
6852
+ console.log(pc.yellow(" ! Domain purchasing is not enabled on this deployment."));
6853
+ console.log(pc.dim(" The domain is available at the price shown; no charge was made."));
6854
+ } else {
6855
+ console.log(pc.yellow(" ! Purchase not completed."));
6856
+ if (r.message) console.log(pc.dim(` ${r.message}`));
6857
+ }
6858
+ console.log();
6859
+ return;
6860
+ }
6861
+ console.log(pc.green(" ✓") + ` Registered ${pc.bold(hostname)}`);
6862
+ if (r.provisioned) {
6863
+ console.log(pc.green(" ✓") + ` Connected to ${options.site}${r.dns_autoconfigured ? " — no DNS setup needed" : ""}.`);
6864
+ if (r.public_url) console.log(pc.green(" ✓") + ` Live at ${pc.underline(r.public_url)}`);
6865
+ console.log(pc.dim(" The TLS certificate is issuing now (usually a minute or two)."));
6866
+ } else if (r.message) console.log(pc.dim(` ${r.message}`));
6867
+ console.log();
6868
+ }
6869
+ async function domainsListCommand() {
6870
+ const { projectId } = await loadProjectConfig();
6871
+ const res = await listPurchasedDomainsViaApi(projectId);
6872
+ console.log();
6873
+ if (res.data.length === 0) {
6874
+ console.log(pc.dim(" No domains purchased through Amba yet."));
6875
+ console.log(pc.dim(" Buy one with: amba domains buy <domain> --site <site>"));
6876
+ console.log();
6877
+ return;
6878
+ }
6879
+ for (const d of res.data) {
6880
+ const domain = String(d.domain ?? "");
6881
+ const status = String(d.status ?? "");
6882
+ const coloured = status === "active" ? pc.green(status) : status === "failed" ? pc.red(status) : pc.yellow(status);
6883
+ console.log(` ${pc.bold(domain)} ${coloured}`);
6884
+ }
6885
+ console.log();
6886
+ }
6887
+ //#endregion
4934
6888
  //#region src/index.ts
4935
6889
  const program = new Command();
4936
6890
  program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("0.1.0");
@@ -4982,6 +6936,9 @@ program.command("init").description("Initialize Amba in the current project (min
4982
6936
  json: opts.json
4983
6937
  }));
4984
6938
  });
6939
+ program.command("claim <email>").description("Bind your sandbox account to a real email (sends a one-click magic link)").action(async (email) => {
6940
+ await runAction(() => claimCommand(email));
6941
+ });
4985
6942
  program.command("login").description("Authenticate with Amba").action(async () => {
4986
6943
  await runAction(loginCommand);
4987
6944
  });
@@ -5010,14 +6967,24 @@ const projects = program.command("projects").description("Project management com
5010
6967
  projects.command("list").description("List all projects in the authenticated developer account").action(async () => {
5011
6968
  await runAction(projectsListCommand);
5012
6969
  });
5013
- projects.command("create").description("Create a new project").requiredOption("--name <name>", "Project name").option("--env <env>", "Environment hint (informational; new projects default to the 'development' environment)").option("--bundle-id <id>", "Bundle identifier (iOS/Android)").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").action(async (opts) => {
6970
+ projects.command("create").description("Create a new project").requiredOption("--name <name>", "Project name").option("--env <env>", "Environment hint (informational; new projects default to the 'development' environment)").option("--bundle-id <id>", "Bundle identifier (iOS/Android). Audience for Sign in with Apple.").option("--google-oauth-client-id <id>", "Google OAuth 2.0 client id. Audience for Sign in with Google.").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").action(async (opts) => {
5014
6971
  await runAction(() => projectsCreateCommand({
5015
6972
  name: opts.name,
5016
6973
  env: opts.env,
5017
6974
  bundleId: opts.bundleId,
6975
+ googleOauthClientId: opts.googleOauthClientId,
5018
6976
  platform: opts.platform
5019
6977
  }));
5020
6978
  });
6979
+ projects.command("update <projectId>").description("Update mutable fields on a project").option("--name <name>", "Display name").option("--bundle-id <id>", "Bundle identifier (Apple Sign In audience)").option("--google-oauth-client-id <id>", "Google OAuth 2.0 client id (Google Sign In audience). Public identifier, not a secret.").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").option("--environment <env>", "Environment label: 'development' | 'production'").action(async (projectId, opts) => {
6980
+ await runAction(() => projectsUpdateCommand(projectId, {
6981
+ name: opts.name,
6982
+ bundleId: opts.bundleId,
6983
+ googleOauthClientId: opts.googleOauthClientId,
6984
+ platform: opts.platform,
6985
+ environment: opts.environment
6986
+ }));
6987
+ });
5021
6988
  projects.command("show <projectId>").description("Show full project details as JSON").action(async (projectId) => {
5022
6989
  await runAction(() => projectsShowCommand(projectId));
5023
6990
  });
@@ -5100,19 +7067,53 @@ consumers.command("unbind <queue-name>").description("Remove the binding for a q
5100
7067
  await runAction(() => functionsConsumersUnbindCommand(queueName));
5101
7068
  });
5102
7069
  const secrets = program.command("secrets").description("Function secrets (GCP canonical, Workers-Secret synced)");
5103
- secrets.command("set <name> [value]").description("Write a secret to GCP Secret Manager and queue Workers Secret sync. Use --from-stdin to keep the value out of shell history.").requiredOption("--function <name>", "Function name the secret binds to. Secrets are scoped per dispatched script — there is no project-wide secret in v1; every secret belongs to exactly one function. Use the same name as the entry passed to 'amba functions deploy'.").option("--env <env>", "'dev' | 'prod' — INFORMATIONAL ONLY. Both share one secret namespace per project. Use distinct secret names (e.g. STRIPE_KEY_DEV / STRIPE_KEY_PROD) or two amba projects for real env isolation.").option("--from-stdin", "Read the secret value from stdin instead of the positional arg. Mutually exclusive with <value>. Pipe in: `echo $KEY | amba secrets set NAME --function fn --from-stdin`.").action(async (name, value, opts) => {
7070
+ secrets.command("set <name> [value]").description("Store a secret and queue its sync to your deployed function(s). Omit --function for a project-wide secret. Use --from-stdin to keep the value out of shell history.").option("--function <name>", "Function the secret binds to. OMIT this flag to set a PROJECT-WIDE secret — one value visible to every function in the project (and to any function deployed later). With it, the secret is scoped to just that function. Use the same name as the entry passed to 'amba functions deploy'. Secrets may be set before the function is deployed; the sync drains once you deploy.").option("--env <env>", "'dev' | 'prod' — INFORMATIONAL ONLY. Both share one secret namespace per project. Use distinct secret names (e.g. STRIPE_KEY_DEV / STRIPE_KEY_PROD) or two amba projects for real env isolation.").option("--from-stdin", "Read the secret value from stdin instead of the positional arg. Mutually exclusive with <value>. Pipe in: `echo $KEY | amba secrets set NAME --from-stdin`.").action(async (name, value, opts) => {
5104
7071
  await runAction(() => secretsSetCommand(name, value, {
5105
7072
  function: opts.function,
5106
7073
  env: opts.env ?? "dev",
5107
7074
  fromStdin: opts.fromStdin
5108
7075
  }));
5109
7076
  });
5110
- secrets.command("list").description("List secret sync status for the current project").action(async () => {
5111
- await runAction(secretsListCommand);
7077
+ secrets.command("list").description("List secret sync status for the current project").option("--function <name>", "Filter to secrets for a single function (avoids listing all functions at once)").action(async (opts) => {
7078
+ await runAction(() => secretsListCommand({ function: opts.function }));
5112
7079
  });
5113
- secrets.command("unset <name>").description("Remove a secret from GCP Secret Manager (Workers Secret cleared on next deploy)").requiredOption("--function <name>", "Function name the secret binds to").action(async (name, opts) => {
7080
+ secrets.command("unset <name>").description("Remove a secret (Worker binding cleared on next deploy)").option("--function <name>", "Function the secret was bound to. Omit to remove the project-wide secret of this name.").action(async (name, opts) => {
5114
7081
  await runAction(() => secretsUnsetCommand(name, opts));
5115
7082
  });
7083
+ const billing = program.command("billing").description("Per-project subscription, headroom, and spend controls");
7084
+ billing.command("status").description("Show current tier, headroom on each metered axis, and projected overage").action(async () => {
7085
+ await runAction(billingStatusCommand);
7086
+ });
7087
+ billing.command("upgrade").description("Print the Stripe Checkout URL for the chosen tier (does not auto-open a browser)").requiredOption("--tier <tier>", "'pro' or 'scale'").option("--interval <interval>", "'month' (default) or 'year' (20% off)", "month").action(async (opts) => {
7088
+ if (opts.tier !== "pro" && opts.tier !== "scale") {
7089
+ console.log(" ✗ --tier must be \"pro\" or \"scale\"");
7090
+ process.exit(1);
7091
+ }
7092
+ if (opts.interval !== "month" && opts.interval !== "year") {
7093
+ console.log(" ✗ --interval must be \"month\" or \"year\"");
7094
+ process.exit(1);
7095
+ }
7096
+ await runAction(() => billingUpgradeCommand({
7097
+ tier: opts.tier,
7098
+ interval: opts.interval
7099
+ }));
7100
+ });
7101
+ billing.command("portal").description("Print the Stripe Customer Portal URL (card, cancel, invoice download)").action(async () => {
7102
+ await runAction(billingPortalCommand);
7103
+ });
7104
+ billing.command("set-ceiling <amount>").description("Cap the monthly bill at <amount> USD, or pass 'off' to remove the cap").action(async (amount) => {
7105
+ let ceiling;
7106
+ if (amount.toLowerCase() === "off" || amount === "") ceiling = null;
7107
+ else {
7108
+ const parsed = Number(amount);
7109
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1e5) {
7110
+ console.log(" ✗ amount must be a number between 0 and 100000, or \"off\"");
7111
+ process.exit(1);
7112
+ }
7113
+ ceiling = parsed;
7114
+ }
7115
+ await runAction(() => billingSetCeilingCommand({ ceiling }));
7116
+ });
5116
7117
  const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
5117
7118
  collections.command("create <name>").description("Create a collection with the given fields").option("--field <spec>", "Field spec: name:type[:nullable] (e.g. user_id:uuid, parsed:jsonb:nullable). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--index <spec>", "Index spec: \"col1 [asc|desc], col2 [asc|desc]\". Repeatable.", (val, prev) => [...prev ?? [], val], []).action(async (name, opts) => {
5118
7119
  await runAction(() => collectionsCreateCommand(name, opts));
@@ -5169,6 +7170,25 @@ sitesDomain.command("remove <hostname>").description("Detach a custom hostname (
5169
7170
  zoneId: opts.zoneId
5170
7171
  }));
5171
7172
  });
7173
+ const domains = program.command("domains").description("Buy a domain through Amba and connect it to a site");
7174
+ domains.command("search <query>").description("Search for available domains + prices (free)").option("--limit <n>", "Max suggestions (1-50, default 20)", (v) => parseInt(v, 10)).action(async (query, opts) => {
7175
+ await runAction(() => domainsSearchCommand(query, { limit: opts.limit }));
7176
+ });
7177
+ domains.command("check <domains...>").description("Authoritative availability + price for specific domains (free)").action(async (domainArgs) => {
7178
+ await runAction(() => domainsCheckCommand(domainArgs));
7179
+ });
7180
+ domains.command("buy <domain>").description("Buy a domain + connect it to a site (surfaces cost, asks to confirm)").requiredOption("--site <name>", "Site name to connect the domain to").option("--yes", "Skip the interactive cost confirmation prompt").option("--no-privacy", "Disable WHOIS privacy (privacy is on by default)").option("--no-auto-renew", "Disable auto-renew (on by default)").option("--years <n>", "Registration length in years (default 1)", (v) => parseInt(v, 10)).action(async (domain, opts) => {
7181
+ await runAction(() => domainsBuyCommand(domain, {
7182
+ site: opts.site,
7183
+ yes: opts.yes,
7184
+ noPrivacy: opts.privacy === false,
7185
+ noAutoRenew: opts.autoRenew === false,
7186
+ years: opts.years
7187
+ }));
7188
+ });
7189
+ domains.command("list").description("List domains this project has purchased through Amba").action(async () => {
7190
+ await runAction(domainsListCommand);
7191
+ });
5172
7192
  const aiProviders = program.command("ai").description("AI gateway — manage provider keys + prompts").command("providers").description("Per-project provider key registration (Anthropic, OpenAI)");
5173
7193
  aiProviders.command("add <provider>").description("Register an AI provider key. Plaintext stored securely server-side; a preview (first-6+last-4) is printed back. Use --from-stdin to keep the key out of shell history.").option("--key <value>", "Provider API key plaintext (alternative: --from-stdin)").option("--from-stdin", "Read the key from stdin instead of --key").action(async (provider, opts) => {
5174
7194
  await runAction(() => aiProvidersAddCommand(provider, opts));