@layers/amba 1.0.1 → 1.1.0

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,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import pc from "picocolors";
4
- import { access, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
4
+ import { access, chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { basename, dirname, join, relative, resolve } from "node:path";
6
6
  import { createInterface } from "node:readline";
7
7
  import { createServer } from "node:http";
8
- import { homedir } from "node:os";
8
+ import { homedir, tmpdir } from "node:os";
9
9
  import open from "open";
10
- import { createWriteStream } from "node:fs";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import { createWriteStream, watch } from "node:fs";
12
+ import { spawn } from "node:child_process";
11
13
  import { build } from "esbuild";
12
- import { createHash } from "node:crypto";
13
14
  //#region src/_internal/shared.ts
14
15
  const DEFAULT_API_URL = "https://api.amba.dev";
15
16
  const CONSOLE_URL = "https://app.amba.dev";
@@ -73,11 +74,7 @@ function getReservationReason(name) {
73
74
  return null;
74
75
  }
75
76
  const RESERVED_BINDING_PREFIXES = ["AMBA_", "EDGE_"];
76
- const RESERVED_BINDING_EXACT_NAMES = [
77
- "STORAGE",
78
- "HYPERDRIVE",
79
- "EDGE_DB_PROXY"
80
- ];
77
+ const RESERVED_BINDING_EXACT_NAMES = ["STORAGE", "EDGE_DB_PROXY"];
81
78
  const VALID_BINDING_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
82
79
  const MAX_BINDING_NAME_LENGTH = 64;
83
80
  /** Return why a binding name is reserved/invalid, or `null` if acceptable. */
@@ -559,7 +556,7 @@ async function updateSite(projectId, name, patch) {
559
556
  }
560
557
  /**
561
558
  * Add a custom domain to a site. The server-side proxy registers the
562
- * custom hostname, persists the resulting `cf_hostname_id`, and returns
559
+ * custom hostname, persists the resulting `provider_hostname_id`, and returns
563
560
  * the CNAME target the customer should point their DNS at.
564
561
  */
565
562
  async function addSiteDomainViaApi(projectId, siteName, hostname) {
@@ -574,10 +571,10 @@ async function removeSiteDomainViaApi(projectId, siteName, hostname) {
574
571
  return request("DELETE", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/domains/${encodeURIComponent(hostname)}`);
575
572
  }
576
573
  /**
577
- * Roll a live CF Pages deployment back to a prior `deployment_id`. CF's
578
- * rollback creates a NEW deployment that serves the prior bundle (git-
579
- * revert semantics, not git-reset), so the response shape mirrors
580
- * `DeploySiteResult` and the new `deployment_id` is what's now live.
574
+ * Roll a live deployment back to a prior `deployment_id`. Rollback creates
575
+ * a NEW deployment that serves the prior bundle (git-revert semantics,
576
+ * not git-reset), so the response shape mirrors `DeploySiteResult` and
577
+ * the new `deployment_id` is what's now live.
581
578
  */
582
579
  async function rollbackSiteViaApi(projectId, siteName, deploymentId) {
583
580
  return request("POST", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/rollback`, { deployment_id: deploymentId });
@@ -811,6 +808,933 @@ async function generateContextFiles(opts) {
811
808
  return files;
812
809
  }
813
810
  //#endregion
811
+ //#region src/sandbox.ts
812
+ /**
813
+ * Headless agentic sandbox bootstrap.
814
+ *
815
+ * Implements `amba init --sandbox`: the zero-question, no-browser path
816
+ * an AI coding agent runs when a developer pastes the homepage prompt:
817
+ *
818
+ * Run `npx @layers/amba init --sandbox` and follow the
819
+ * instructions it prints.
820
+ *
821
+ * The CLI does everything: synthesize an anonymous email + password,
822
+ * sign the developer up via the public `/v1/auth/developer/signup`
823
+ * endpoint (no Bearer needed), pluck the returned PAT + project
824
+ * credentials, and write them into:
825
+ *
826
+ * - `~/.amba/credentials.json` (chmod 0600)
827
+ * - `<cwd>/.env.local` (.gitignored — SDK reads it)
828
+ * - `<cwd>/AMBA.md` (markdown context for the agent)
829
+ * - every detected MCP client config (`~/.claude.json`,
830
+ * `~/.cursor/mcp.json`, `~/.codeium/windsurf/mcp_config.json`,
831
+ * plus their project-local equivalents WHEN already present —
832
+ * never created from scratch, to avoid cluttering repos)
833
+ *
834
+ * The MCP-config merge is non-destructive: an existing `mcpServers.amba`
835
+ * entry is replaced with the new PAT, but the prior file is copied
836
+ * aside to `<path>.bak-<unix-ms>` first so a developer who had a real
837
+ * production PAT wired in can recover. Every other server entry is
838
+ * preserved. JSON files that already exist but lack an `mcpServers`
839
+ * key gain one; missing files for the global locations get scaffolded
840
+ * with a minimal `{ "mcpServers": { "amba": … }}`.
841
+ *
842
+ * Similarly, a pre-existing `~/.amba/credentials.json` whose `source`
843
+ * is not `'sandbox-init'` is backed up to `credentials.json.bak-<ms>`
844
+ * before the sandbox PAT replaces it. Idempotent re-runs from our own
845
+ * sandbox session do NOT trigger a backup.
846
+ *
847
+ * Provisioning polling: deliberately skipped. The server returns the
848
+ * project row with `provisioning_status: 'provisioning'` immediately and
849
+ * the workflow flips it to `'active'` within ~5s. The agent's next SDK
850
+ * call may briefly retry — fine. Blocking the CLI here would just hide
851
+ * the same wait behind a different progress indicator.
852
+ */
853
+ /**
854
+ * Generate a deterministic-looking but globally-unique sandbox email.
855
+ *
856
+ * Pattern: `sandbox-<epoch>-<6char>@layers.com`.
857
+ *
858
+ * The control DB's `developers` table has a UNIQUE(email) constraint and
859
+ * a 5-per-minute / 50-per-day per-IP rate limit on signup. Embedding the
860
+ * epoch + a 6-char nonce keeps the collision probability negligible even
861
+ * across a herd of CI agents all running `amba init --sandbox` from the
862
+ * same VPC.
863
+ *
864
+ * We use `@layers.com` (not the customer's own domain) because the
865
+ * sandbox tier is pre-verification — the developer never receives or
866
+ * actions a verification email for this address. When they want to
867
+ * upgrade, the CLI prints the verify URL the API returned so they can
868
+ * claim a real email in the console.
869
+ */
870
+ function generateSandboxEmail() {
871
+ return `sandbox-${Math.floor(Date.now() / 1e3)}-${randomBytes(4).toString("base64url").slice(0, 6).toLowerCase()}@layers.com`;
872
+ }
873
+ /**
874
+ * Random URL-safe password. 24 raw bytes → 32 base64url chars; well over
875
+ * the 8-char minimum the API enforces, with ~192 bits of entropy.
876
+ *
877
+ * The password is never shown to the developer or written anywhere — the
878
+ * PAT is what gets stored. We generate it solely because `POST /signup`
879
+ * requires it (and demands a non-empty value); a future API change could
880
+ * accept "agent signup" with no password and we'd drop this entirely.
881
+ */
882
+ function generateSandboxPassword() {
883
+ return randomBytes(24).toString("base64url");
884
+ }
885
+ /**
886
+ * POST the synthesized credentials at the public signup endpoint.
887
+ *
888
+ * No Bearer auth — this is the bootstrap call that mints one. We use
889
+ * `fetch` directly (not the api-client wrapper) because that wrapper
890
+ * always resolves a bearer token first, which is exactly what we don't
891
+ * have yet.
892
+ *
893
+ * Returns the unwrapped, flattened shape consumed by the rest of the
894
+ * sandbox flow. Throws with a human-readable message on any non-2xx so
895
+ * the CLI's `runAction` wrapper can surface it without crashing on a
896
+ * generic 'fetch failed'.
897
+ */
898
+ async function performSandboxSignup(req, options = {}) {
899
+ const apiUrl = options.apiUrl ?? process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
900
+ const res = await (options.fetchImpl ?? fetch)(`${apiUrl}/v1/auth/developer/signup`, {
901
+ method: "POST",
902
+ headers: {
903
+ "Content-Type": "application/json",
904
+ "User-Agent": "amba-cli/sandbox"
905
+ },
906
+ body: JSON.stringify({
907
+ email: req.email,
908
+ password: req.password,
909
+ name: req.name ?? "amba-sandbox-cli"
910
+ })
911
+ });
912
+ if (!res.ok) {
913
+ let detail = `${res.status} ${res.statusText}`;
914
+ try {
915
+ const body = await res.json();
916
+ if (body.error?.message) detail = body.error.message;
917
+ } catch {}
918
+ throw new Error(`Sandbox signup failed: ${detail}`);
919
+ }
920
+ let raw;
921
+ try {
922
+ raw = await res.json();
923
+ } catch (parseErr) {
924
+ const reason = parseErr instanceof Error ? parseErr.message : String(parseErr);
925
+ throw new Error(`Sandbox signup returned ${res.status} but the response body was not valid JSON: ${reason}`);
926
+ }
927
+ const data = raw.data;
928
+ if (!data?.pat || !data.project?.project_id || !data.project.client_key) throw new Error("Sandbox signup response missing required fields (pat / project_id / client_key)");
929
+ return {
930
+ pat: data.pat,
931
+ project_id: data.project.project_id,
932
+ client_key: data.project.client_key,
933
+ server_key: data.project.server_key,
934
+ api_url: apiUrl,
935
+ provisioning_status: data.project.provisioning_status,
936
+ 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
991
+ };
992
+ }
993
+ /**
994
+ * Write or update `<cwd>/.env.local` with the sandbox project's keys.
995
+ *
996
+ * Mirrors the `init` interactive flow exactly so the existing env-read
997
+ * conventions in the SDKs and CLI commands keep working. The merge
998
+ * 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
1000
+ * fresh stanza.
1001
+ */
1002
+ async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl) {
1003
+ const envPath = join(cwd, ".env.local");
1004
+ const stanza = [
1005
+ "# Amba SDK configuration (sandbox tier)",
1006
+ `AMBA_PROJECT_ID=${projectId}`,
1007
+ `AMBA_CLIENT_KEY=${clientKey}`,
1008
+ `AMBA_API_URL=${apiUrl}`,
1009
+ ""
1010
+ ].join("\n");
1011
+ let existing = "";
1012
+ try {
1013
+ existing = await readFile(envPath, "utf-8");
1014
+ } catch (err) {
1015
+ if (!isEnoent(err)) throw err;
1016
+ }
1017
+ if (existing.length === 0) {
1018
+ await writeFile(envPath, stanza, "utf-8");
1019
+ return envPath;
1020
+ }
1021
+ if (/^AMBA_PROJECT_ID=/m.test(existing)) {
1022
+ let updated = existing;
1023
+ updated = updated.replace(/^AMBA_PROJECT_ID=.*/m, () => `AMBA_PROJECT_ID=${projectId}`);
1024
+ updated = updated.replace(/^AMBA_API_URL=.*/m, () => `AMBA_API_URL=${apiUrl}`);
1025
+ const hadClientKey = /^AMBA_CLIENT_KEY=/m.test(updated);
1026
+ const hadApiKey = /^AMBA_API_KEY=/m.test(updated);
1027
+ if (hadClientKey && hadApiKey) {
1028
+ updated = updated.replace(/^AMBA_CLIENT_KEY=.*\n?/m, "");
1029
+ updated = updated.replace(/^AMBA_API_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
1030
+ } else if (hadApiKey) updated = updated.replace(/^AMBA_API_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
1031
+ else if (hadClientKey) updated = updated.replace(/^AMBA_CLIENT_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
1032
+ else updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_CLIENT_KEY=${clientKey}\n`;
1033
+ if (!/^AMBA_API_URL=/m.test(updated)) updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_API_URL=${apiUrl}\n`;
1034
+ await writeFile(envPath, updated, "utf-8");
1035
+ return envPath;
1036
+ }
1037
+ const separator = existing.endsWith("\n") ? "\n" : "\n\n";
1038
+ await writeFile(envPath, existing + separator + stanza, "utf-8");
1039
+ return envPath;
1040
+ }
1041
+ /**
1042
+ * Write the AMBA.md sandbox-tier guide to `<cwd>/AMBA.md`.
1043
+ *
1044
+ * Always overwrites — the file is meant to be regenerated, and the
1045
+ * interactive `init` flow's longer AMBA.md template is replaced here
1046
+ * with a sandbox-specific shorter one (with upgrade instructions).
1047
+ */
1048
+ async function writeSandboxAmbaMd(cwd, ctx) {
1049
+ const ambaMdPath = join(cwd, "AMBA.md");
1050
+ await writeFile(ambaMdPath, sandboxAmbaMdContent(ctx), "utf-8");
1051
+ return ambaMdPath;
1052
+ }
1053
+ function sandboxAmbaMdContent(ctx) {
1054
+ return `# Amba (Sandbox tier)
1055
+
1056
+ This project was provisioned by \`amba init --sandbox\`. The sandbox is
1057
+ real (real database, real API, real MCP) but capped so you can try Amba
1058
+ without a credit card or email verification.
1059
+
1060
+ ## What was provisioned
1061
+
1062
+ | Field | Value |
1063
+ | --- | --- |
1064
+ | Project ID | \`${ctx.projectId}\` |
1065
+ | Email | \`${ctx.email}\` |
1066
+ | API URL | \`${ctx.apiUrl}\` |
1067
+ | SDK | \`${ctx.sdkPackage}\` |
1068
+ | Framework | ${ctx.framework} |
1069
+
1070
+ The credentials live in **\`.env.local\`** (\`AMBA_PROJECT_ID\`,
1071
+ \`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.
1074
+
1075
+ ## SDK quickstart
1076
+
1077
+ \`\`\`ts
1078
+ import { Amba } from '${ctx.sdkPackage}';
1079
+
1080
+ Amba.configure({
1081
+ projectId: process.env.AMBA_PROJECT_ID!,
1082
+ clientKey: process.env.AMBA_CLIENT_KEY!,
1083
+ });
1084
+
1085
+ await Amba.events.track('app_opened');
1086
+ \`\`\`
1087
+
1088
+ ## Sandbox limits
1089
+
1090
+ | Limit | Sandbox | Free (after verification) |
1091
+ | --- | --- | --- |
1092
+ | Monthly active users | 100 | 1,000 |
1093
+ | Database size | 10 MB | 500 MB |
1094
+ | Push notifications / mo | 1,000 | 10,000 |
1095
+
1096
+ ## Upgrade past sandbox
1097
+
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.`}
1102
+
1103
+ ## Useful commands
1104
+
1105
+ \`\`\`bash
1106
+ amba status # health check
1107
+ amba projects list # list your sandbox project
1108
+ amba seed --preset=starter # populate sample data
1109
+ \`\`\`
1110
+
1111
+ Docs: https://docs.amba.dev
1112
+ `;
1113
+ }
1114
+ /**
1115
+ * The canonical Amba MCP entry. Used as the value of
1116
+ * `mcpServers.amba` in every detected client config.
1117
+ *
1118
+ * Shape note: Claude Code, Cursor, and Windsurf all read the same
1119
+ * `type` + `url` + `headers` triple. (Windsurf historically also
1120
+ * accepted `serverUrl` — we emit `url` to match the modern shape it
1121
+ * also accepts, and skip emitting the legacy alias to keep the JSON
1122
+ * minimal.)
1123
+ */
1124
+ function buildAmbaMcpEntry(pat) {
1125
+ return {
1126
+ type: "http",
1127
+ url: "https://mcp.amba.dev/mcp",
1128
+ headers: { Authorization: `Bearer ${pat}` }
1129
+ };
1130
+ }
1131
+ /**
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
+ * The list of MCP client config files we probe. Order matters only for
1169
+ * the printed report.
1170
+ *
1171
+ * Project-local entries are listed but only get touched when the file
1172
+ * already exists in CWD — we don't want to scatter `.mcp.json` /
1173
+ * `.cursor/mcp.json` files into random user repos that have never been
1174
+ * MCP-configured.
1175
+ */
1176
+ function mcpClientTargets(cwd, options = {}) {
1177
+ const home = options.homeDir ?? homedir();
1178
+ return [
1179
+ {
1180
+ label: "Claude Code (global)",
1181
+ path: join(home, ".claude.json"),
1182
+ scaffoldIfMissing: true
1183
+ },
1184
+ {
1185
+ label: "Claude Code (project)",
1186
+ path: join(cwd, ".mcp.json"),
1187
+ scaffoldIfMissing: false
1188
+ },
1189
+ {
1190
+ label: "Cursor (global)",
1191
+ path: join(home, ".cursor", "mcp.json"),
1192
+ scaffoldIfMissing: true
1193
+ },
1194
+ {
1195
+ label: "Cursor (project)",
1196
+ path: join(cwd, ".cursor", "mcp.json"),
1197
+ scaffoldIfMissing: false
1198
+ },
1199
+ {
1200
+ label: "Windsurf",
1201
+ path: join(home, ".codeium", "windsurf", "mcp_config.json"),
1202
+ scaffoldIfMissing: true
1203
+ }
1204
+ ];
1205
+ }
1206
+ /**
1207
+ * Merge the `amba` entry into a single client config file.
1208
+ *
1209
+ * Strategy:
1210
+ * - If the file exists, load + JSON-parse. If parse fails, throw with
1211
+ * a clear "we won't clobber malformed JSON" error.
1212
+ * - If the file doesn't exist and scaffolding is allowed, create the
1213
+ * parent dir and write `{ "mcpServers": { "amba": ... } }`.
1214
+ * - In all cases, `mcpServers.amba` ends up set; every other
1215
+ * `mcpServers.*` entry is preserved.
1216
+ *
1217
+ * Real-credential safety: if the existing file ALREADY has a
1218
+ * `mcpServers.amba` entry, we copy the whole file aside to
1219
+ * `<path>.bak-<unix-ms>` BEFORE merging. This protects a developer who
1220
+ * had a real production PAT wired in and then ran `amba init --sandbox`
1221
+ * to "try" the flow. Idempotent re-runs from the same sandbox session
1222
+ * still trigger a backup — cheap, and the developer can `rm *.bak-*`
1223
+ * any time.
1224
+ */
1225
+ async function mergeMcpConfigFile(target, pat) {
1226
+ let existing = null;
1227
+ let rawExisting = null;
1228
+ try {
1229
+ rawExisting = await readFile(target.path, "utf-8");
1230
+ const parsed = JSON.parse(rawExisting);
1231
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) existing = parsed;
1232
+ else throw new Error(`Existing config at ${target.path} is not a JSON object`);
1233
+ } catch (err) {
1234
+ if (isEnoent(err)) {
1235
+ if (!target.scaffoldIfMissing) return {
1236
+ path: null,
1237
+ backedUpTo: null
1238
+ };
1239
+ existing = {};
1240
+ } else if (err instanceof SyntaxError) throw new Error(`Refusing to overwrite malformed JSON at ${target.path}: ${err.message}. Fix the file or remove it, then re-run \`amba init --sandbox\`.`);
1241
+ else throw err;
1242
+ }
1243
+ const config = existing ?? {};
1244
+ const serversRaw = config["mcpServers"];
1245
+ const servers = typeof serversRaw === "object" && serversRaw !== null && !Array.isArray(serversRaw) ? serversRaw : {};
1246
+ let backedUpTo = null;
1247
+ if ("amba" in servers && rawExisting !== null) {
1248
+ backedUpTo = `${target.path}.bak-${Date.now()}`;
1249
+ await writeFile(backedUpTo, rawExisting, "utf-8");
1250
+ }
1251
+ servers["amba"] = buildAmbaMcpEntry(pat);
1252
+ config["mcpServers"] = servers;
1253
+ await mkdir(dirname(target.path), { recursive: true });
1254
+ await writeFile(target.path, JSON.stringify(config, null, 2) + "\n", "utf-8");
1255
+ return {
1256
+ path: target.path,
1257
+ backedUpTo
1258
+ };
1259
+ }
1260
+ /**
1261
+ * Probe every known MCP client location and merge our entry into the
1262
+ * ones that exist (or that are flagged scaffoldIfMissing). Returns one
1263
+ * record per touched file (skipped files are omitted).
1264
+ *
1265
+ * `warn` is invoked (instead of `console.warn`) for per-target errors
1266
+ * so callers using `--json` can route those notices to stderr and keep
1267
+ * stdout machine-parseable.
1268
+ */
1269
+ async function writeAllMcpConfigs(cwd, pat, options = {}) {
1270
+ const warn = options.warn ?? ((msg) => console.warn(msg));
1271
+ const written = [];
1272
+ const targets = mcpClientTargets(cwd, options);
1273
+ for (const target of targets) {
1274
+ let result = {
1275
+ path: null,
1276
+ backedUpTo: null
1277
+ };
1278
+ try {
1279
+ if (!target.scaffoldIfMissing) {
1280
+ if (!await fileExists$1(target.path)) continue;
1281
+ }
1282
+ result = await mergeMcpConfigFile(target, pat);
1283
+ } catch (err) {
1284
+ const message = err instanceof Error ? err.message : String(err);
1285
+ warn(` ! Skipped ${target.label}: ${message}`);
1286
+ continue;
1287
+ }
1288
+ if (result.path) written.push({
1289
+ path: result.path,
1290
+ backedUpTo: result.backedUpTo
1291
+ });
1292
+ }
1293
+ return written;
1294
+ }
1295
+ async function fileExists$1(path) {
1296
+ try {
1297
+ await access(path);
1298
+ return true;
1299
+ } catch {
1300
+ return false;
1301
+ }
1302
+ }
1303
+ function isEnoent(err) {
1304
+ return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
1305
+ }
1306
+ /**
1307
+ * Render the canonical Amba MCP snippet for clients we can't auto-wire
1308
+ * (anything outside the {Claude Code, Cursor, Windsurf} set). Printed by
1309
+ * the CLI when no client configs are detected so the developer at least
1310
+ * has a paste-ready JSON blob.
1311
+ */
1312
+ function formatManualMcpSnippet(pat) {
1313
+ return JSON.stringify({ mcpServers: { amba: buildAmbaMcpEntry(pat) } }, null, 2);
1314
+ }
1315
+ //#endregion
1316
+ //#region ../mcp/dist/expo-build-prompt.js
1317
+ /**
1318
+ * Canonical Amba Expo build prompt — markdown body (no MDX frontmatter).
1319
+ *
1320
+ * Source of truth for three customer-facing surfaces:
1321
+ *
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`).
1331
+ *
1332
+ * Drift between this constant and the MDX file is caught by
1333
+ * `expo-build-prompt.test.ts` — that test reads the MDX from disk,
1334
+ * strips the YAML frontmatter, and asserts it equals `EXPO_BUILD_PROMPT_MD`.
1335
+ *
1336
+ * **Update protocol:** edit the MDX (it's the human-facing surface;
1337
+ * it renders on docs.amba.dev). Re-run the drift test. The test will
1338
+ * fail with a diff. Apply the same diff here. The two are kept in
1339
+ * sync by hand because the MDX must be statically parseable for
1340
+ * fumadocs + we can't import `.md` files as raw strings without a
1341
+ * build-step that pulls in extra config.
1342
+ *
1343
+ * The body itself is plain CommonMark — no MDX components, no JSX —
1344
+ * so it renders identically as `.md` (the MCP / skill consumers) and
1345
+ * as `.mdx` (the docs site).
1346
+ */
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).
1349
+ > If you're reading an inlined snapshot from your
1350
+ > \`.claude/skills/amba-build/SKILL.md\`, check the URL above for updates.
1351
+
1352
+ This is the prompt an AI coding agent runs to build a full Expo app
1353
+ where Amba is the only backend. It's structured as a single \`/goal\`
1354
+ directive — paste it, replace \`<DESIGN_HASH>\` with whatever describes
1355
+ your design (a URL, a description, a Figma link), and let the agent
1356
+ execute.
1357
+
1358
+ ## Quick setup
1359
+
1360
+ The CLI handles signup, project provisioning, env-file writes, and MCP
1361
+ client config wiring in one command:
1362
+
1363
+ \`\`\`bash
1364
+ npx @layers/amba init --sandbox
1365
+ \`\`\`
1366
+
1367
+ That's the entire setup. The CLI:
1368
+
1369
+ 1. Signs up an agent-mode developer account (no browser, no email
1370
+ verification needed for sandbox).
1371
+ 2. Creates an Amba project and mints a client key + admin PAT.
1372
+ 3. Writes \`.env.local\` (\`AMBA_PROJECT_ID\`, \`AMBA_CLIENT_KEY\`,
1373
+ \`AMBA_API_URL\`).
1374
+ 4. Writes \`AMBA.md\` (project-scoped context for the agent).
1375
+ 5. Auto-wires \`mcpServers.amba\` into every MCP client config it
1376
+ detects on disk — Claude Code, Cursor, Windsurf.
1377
+ 6. Prints a per-client restart instruction.
1378
+
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.
1382
+
1383
+ If you have the \`/amba-build\` skill installed (via
1384
+ \`npx @layers/amba init --sandbox\`), invoke it directly:
1385
+
1386
+ \`\`\`
1387
+ /amba-build <DESIGN_HASH>
1388
+ \`\`\`
1389
+
1390
+ Otherwise paste the prompt below.
1391
+
1392
+ ## Current known gotchas
1393
+
1394
+ Three remaining wrinkles you may hit. Everything else from the 2026-05
1395
+ DX cascade is fixed.
1396
+
1397
+ - **Web CORS** — the public API does not currently send
1398
+ \`Access-Control-Allow-Origin\` for browser-origin requests. Use the
1399
+ agent's circuit-break-on-second-failure rule for web targets; for
1400
+ Expo (iOS + Android) you'll never see this.
1401
+ - **React Native bundle size** — the React Native SDK adds ~4 MB to
1402
+ the JS bundle today. Functional, just heavier than the long-term
1403
+ goal. Tracked separately.
1404
+ - **Sandbox MAU cap (50)** — the agent-mode sandbox tier caps at 50
1405
+ monthly active users. If you blow through it during testing, call
1406
+ \`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.
1409
+
1410
+ ## How to read the design
1411
+
1412
+ If \`<DESIGN_HASH>\` is a URL to a packaged design (e.g. a download
1413
+ link from your design tool of choice), unpack it before you start:
1414
+
1415
+ \`\`\`bash
1416
+ mkdir -p design && cd design
1417
+ curl -L "<DESIGN_HASH>" -o design.tar.gz
1418
+ gunzip -c design.tar.gz | tar -x
1419
+ ls
1420
+ # Expected: README, chats/, project/ (or equivalent)
1421
+ \`\`\`
1422
+
1423
+ Read the README first — it should tell you what each subdirectory
1424
+ holds. The \`chats/\` directory typically contains conversation logs
1425
+ that capture the design intent in dialog form; treat them as the
1426
+ authoritative source for tone and feature priorities. The \`project/\`
1427
+ directory holds the structured asset graph (screens, components,
1428
+ styles).
1429
+
1430
+ If \`<DESIGN_HASH>\` is a freeform description (not a URL), skip the
1431
+ unpacking and treat the description text as the design brief.
1432
+
1433
+ ## Use Amba for everything
1434
+
1435
+ The rule: any feature that touches data, identity, scheduling,
1436
+ notifications, content, or social — use Amba. Don't reach for
1437
+ AsyncStorage-as-database, don't bring in Firebase / Supabase / your
1438
+ own server, don't roll a custom auth layer. The point of this build
1439
+ is that Amba covers it all.
1440
+
1441
+ Specifically: every feature in the design that needs a backend maps
1442
+ to an Amba primitive. If you can't find a fit, the rule is **escalate
1443
+ in the gaps log** (see the verification gate), not "ship without
1444
+ Amba." Skipping a primitive needs a written justification — the
1445
+ verification gate enforces this.
1446
+
1447
+ ## Feature → Amba primitive map
1448
+
1449
+ | App feature | Amba primitive | MCP tools |
1450
+ | -------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1451
+ | User accounts (anonymous + Apple + Google + email) | Auth | \`amba_developer_signup\` (one-time bootstrap), \`Amba.signIn()\` SDK calls |
1452
+ | Profile data (name, avatar, prefs) | App users | \`amba_users_list\`, \`amba_users_get\`, \`amba_users_bulk_update\` |
1453
+ | Daily content (tips, lessons, quotes) | Content libraries + schedules | \`amba_content_libraries_create\`, \`amba_content_items_add\`, \`amba_content_schedules_create\`, \`amba_content_list_libraries\`, \`amba_content_list_items\`, \`amba_content_list_schedules\` |
1454
+ | Push notifications | Push campaigns | \`amba_push_campaigns_create\`, \`amba_push_campaigns_send\`, \`amba_push_send_test\`, \`amba_push_list_campaigns\` |
1455
+ | User segments (e.g. inactive 7d, premium) | Segments | \`amba_segments_create\`, \`amba_segments_list\`, \`amba_segments_evaluate\` |
1456
+ | Daily streaks | Streaks | \`amba_streaks_create\`, \`amba_streaks_list\` (call \`streaks.qualify()\` from the SDK to record activity) |
1457
+ | XP and levels | XP rules | \`amba_xp_rules_create\`, \`amba_xp_rules_list\`, \`amba_users_get_xp\` |
1458
+ | Achievements / badges | Achievements | \`amba_achievements_create\`, \`amba_achievements_list\`, \`amba_achievements_get\` |
1459
+ | Challenges (time-limited goals) | Challenges | \`amba_challenges_create\`, \`amba_challenges_list\`, \`amba_challenges_list_participants\` |
1460
+ | Leaderboards | Leaderboards | \`amba_leaderboards_create\`, \`amba_leaderboards_list\`, \`amba_leaderboards_get\` |
1461
+ | In-app currency / virtual goods | Economy (currencies + catalog + stores) | \`amba_currencies_create\`, \`amba_catalog_items_create\`, \`amba_stores_create\`, \`amba_currencies_grant\`, \`amba_users_get_inventory\` |
1462
+ | Social (friends, groups, feed, DMs) | Social primitives | \`amba_create_group\`, \`amba_groups_list\`, \`amba_groups_update_member\`, \`amba_friendships_list\` (feeds + messaging via SDK: \`Amba.feeds.*\`, \`Amba.messaging.*\`) |
1463
+ | Remote feature flags / config | Configs | \`amba_configs_create\`, \`amba_configs_list\`, \`amba_configs_update\` |
1464
+ | Entitlements (premium / paywall) | RevenueCat / Superwall integration | \`amba_integrations_configure\`, \`amba_integrations_test\` |
1465
+ | Custom data (anything not above) | Collections | \`amba_collections_create\`, \`amba_collections_alter\`, \`amba_collections_list\`, \`amba_admin_insert_row\`, \`amba_admin_list_rows\`, plus client-side \`Amba.collections.*\` |
1466
+ | Analytics / event tracking | Events | \`Amba.events.track(...)\` from the SDK; query with \`amba_analytics_get\`, \`amba_users_list_events\` |
1467
+
1468
+ Every primitive above has list / read MCP tools you can use to verify
1469
+ seed data after creation — the verification gate uses these to catch
1470
+ "fake implementation" failure modes (where the app code thinks a thing
1471
+ was created but nothing actually landed in the backend).
1472
+
1473
+ ## Seed data
1474
+
1475
+ Before writing app code, seed the backend with enough data that every
1476
+ screen in the design has something realistic to render. Order:
1477
+
1478
+ 1. **Configs** — feature flags + tunable constants the app reads at
1479
+ boot (\`amba_configs_create\`).
1480
+ 2. **Segments** — at least one (e.g. "new_user", first 7 days) so
1481
+ targeting works downstream.
1482
+ 3. **Content libraries + schedules** — daily content for any
1483
+ tips/quotes/lessons screen. Seed ≥30 items so the carousel /
1484
+ day-stepper doesn't loop visibly.
1485
+ 4. **Streaks** — define the streak shape (daily / weekly, grace
1486
+ window, freeze policy).
1487
+ 5. **XP rules** — events → XP-award rules so XP accrues from real
1488
+ gameplay.
1489
+ 6. **Achievements** — unlock criteria for badges.
1490
+ 7. **Challenges** — at least one active challenge with rewards.
1491
+ 8. **Leaderboards** — XP, streaks, or any custom metric.
1492
+ 9. **Currencies + catalog + stores** — virtual currency, catalog
1493
+ items, store listings (only if the design has an economy screen).
1494
+ 10. **Collections** — schemas + sample rows for any custom data the
1495
+ app needs (e.g. user-generated content, journal entries, custom
1496
+ list items).
1497
+ 11. **Push campaigns** — at least one welcome push + one re-engagement
1498
+ push targeting your "new_user" segment.
1499
+
1500
+ After seeding, the verification gate (below) confirms each primitive
1501
+ exists by calling the matching \`amba_*_list\` MCP tool. Empty list →
1502
+ failure.
1503
+
1504
+ ## Engineering rules
1505
+
1506
+ These are non-negotiable. Violating any one of them fails the build
1507
+ gate.
1508
+
1509
+ - **Expo Router with typed routes.** Use \`expo-router\` and enable
1510
+ \`experiments.typedRoutes\` in \`app.json\`. Every screen is a
1511
+ filesystem route; no manual navigation stacks.
1512
+ - **TypeScript strict mode.** \`strict: true\` in \`tsconfig.json\`. Zero
1513
+ \`any\`. Zero \`@ts-ignore\`. \`tsc --noEmit\` must pass.
1514
+ - **React Native primitives only.** \`View\`, \`Text\`, \`Pressable\`,
1515
+ \`ScrollView\`, \`FlatList\`, \`Image\`. No \`div\`, no \`span\`, no DOM-only
1516
+ libs. The build target is iOS + Android + Web — every screen has to
1517
+ render on all three.
1518
+ - **Fonts via expo-font.** Don't ship system-font-only screens; load
1519
+ the design's typography via \`useFonts\` and gate the splash screen
1520
+ on load.
1521
+ - **Persistence via AsyncStorage.** Anything you cache client-side
1522
+ (theme choice, last-viewed-item, dismissed banners) goes in
1523
+ AsyncStorage. Never sprinkle direct file I/O.
1524
+ - **Theme system.** A single \`theme.ts\` exports light + dark token
1525
+ maps; consume via a \`useTheme()\` hook. The verification gate
1526
+ toggles light ↔ dark and screenshots; if any screen has hardcoded
1527
+ colors that don't flip, the gate fails.
1528
+ - **Circuit-break on second failure.** If two consecutive Amba API
1529
+ calls fail with the same error, stop retrying and surface a clean
1530
+ empty-state to the user. Don't loop forever; don't crash. The web
1531
+ CORS issue (above) is the most likely trigger.
1532
+ - **Deterministic offline fallback.** When \`fetch\` fails (airplane
1533
+ mode, network drop), the app renders **deterministic** placeholder
1534
+ content — same content per \`userId + day\` — never random. Real data
1535
+ swaps in when the network returns.
1536
+ - **Three-platform bundle gate.** \`expo export --platform web\`,
1537
+ \`expo export --platform ios\`, and \`expo export --platform android\`
1538
+ must all succeed. If any one fails, the build fails. No
1539
+ "shipped iOS-only, web is broken" — the rule is parity.
1540
+
1541
+ ## Verification gate
1542
+
1543
+ Before declaring the build done, run every check in this list. Any
1544
+ failure means the build is not done — fix and re-run.
1545
+
1546
+ \`\`\`bash
1547
+ # Type-check
1548
+ pnpm tsc --noEmit
1549
+
1550
+ # Three-platform export
1551
+ pnpm expo export --platform web
1552
+ pnpm expo export --platform ios
1553
+ pnpm expo export --platform android
1554
+ \`\`\`
1555
+
1556
+ Then, from inside the agent (use the Amba MCP tools):
1557
+
1558
+ - \`amba_analytics_get\` → at least one event tracked end-to-end
1559
+ through \`Amba.events.track()\` from the app.
1560
+ - \`amba_users_list\` → at least one user exists (the agent's own
1561
+ anonymous signin counts).
1562
+ - For every primitive the seed step created, call the matching
1563
+ \`amba_*_list\` and assert non-empty:
1564
+ - \`amba_configs_list\`
1565
+ - \`amba_segments_list\`
1566
+ - \`amba_content_list_libraries\`, \`amba_content_list_items\`,
1567
+ \`amba_content_list_schedules\`
1568
+ - \`amba_streaks_list\`
1569
+ - \`amba_xp_rules_list\`
1570
+ - \`amba_achievements_list\`
1571
+ - \`amba_challenges_list\`
1572
+ - \`amba_leaderboards_list\`
1573
+ - \`amba_currencies_list\` (if economy seeded)
1574
+ - \`amba_catalog_list\` (if catalog seeded)
1575
+ - \`amba_collections_list\` + \`amba_admin_list_rows\` per collection
1576
+ - \`amba_push_list_campaigns\`
1577
+ - Empty list for any seeded primitive → the implementation is fake
1578
+ (UI exists but never wrote to the backend). Failure.
1579
+ - Manually walk every route in the browser (\`expo start --web\`),
1580
+ screenshot each, and confirm:
1581
+ - Light theme renders cleanly.
1582
+ - Dark theme renders cleanly (toggle and re-screenshot every
1583
+ route).
1584
+ - Empty states render when collections are empty (fresh-install
1585
+ simulation: wipe AsyncStorage, reload).
1586
+ - \`amba_users_reset_sandbox\` to confirm you can recover from the MAU
1587
+ cap if you blew past 50 during testing.
1588
+
1589
+ Skipping any primitive's seed step requires a one-line written
1590
+ justification in the gaps log (next section). "We don't need
1591
+ streaks" is fine; silence is not.
1592
+
1593
+ ## Final output
1594
+
1595
+ When done, write a final report to \`BUILD_REPORT.md\` in the project
1596
+ root. Required sections:
1597
+
1598
+ - **Start timestamp** (when the agent started).
1599
+ - **End timestamp** (when the verification gate last passed).
1600
+ - **MCP call inventory** — every \`amba_*\` tool you invoked, with a
1601
+ count. Lets a human reviewer audit "did this agent actually use
1602
+ Amba for X" at a glance.
1603
+ - **Primitive coverage table** — one row per primitive from the
1604
+ Feature → Amba primitive map. Mark each ✅ (used), ⚠️ (used with
1605
+ caveats — explain), or ⏭ (skipped — justify in one line).
1606
+ - **Gaps log** — every primitive you skipped, every feature you
1607
+ couldn't fit cleanly to an Amba primitive, every workaround. One
1608
+ line per gap, no marketing language.
1609
+ - **\`seed-report.json\`** — machine-readable seed summary:
1610
+ \`{ "primitive": "<name>", "created": <count>, "listed": <count> }\`
1611
+ for every primitive. The \`listed\` count comes from the
1612
+ \`amba_*_list\` call in the verification gate. \`created ===
1613
+ listed\` for every row is the success condition.
1614
+
1615
+ If \`BUILD_REPORT.md\` is missing any required section, or
1616
+ \`seed-report.json\` is missing, the build is not done.
1617
+ `;
1618
+ //#endregion
1619
+ //#region src/skills.ts
1620
+ /**
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.
1648
+ */
1649
+ /**
1650
+ * Build the contents of `.claude/skills/amba-build/SKILL.md`.
1651
+ *
1652
+ * Exported as a pure function so the unit tests can assert structural
1653
+ * properties (frontmatter, fetcher block, inlined snapshot fence)
1654
+ * 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
+ */
1667
+ function buildAmbaBuildSkillContent() {
1668
+ return `---
1669
+ description: Canonical /goal prompt for building a full Expo app with Amba as the only backend. Use as a starter when integrating Amba — Claude Code, Cursor, Windsurf.
1670
+ ---
1671
+
1672
+ # /amba-build
1673
+
1674
+ When invoked, fetch the canonical prompt from
1675
+ \`https://docs.amba.dev/docs/prompts/expo-build.md\` and use it as the
1676
+ \`/goal\` directive for building a full Expo app with Amba as the only
1677
+ backend. The user supplies a design hash (URL or description) as the
1678
+ argument; substitute it for every \`<DESIGN_HASH>\` placeholder in the
1679
+ prompt before executing.
1680
+
1681
+ ## Usage
1682
+
1683
+ \`\`\`
1684
+ /amba-build <DESIGN_HASH>
1685
+ \`\`\`
1686
+
1687
+ Replace \`<DESIGN_HASH>\` with:
1688
+
1689
+ - A URL to a packaged design tarball, OR
1690
+ - A freeform description of the design intent.
1691
+
1692
+ ## Fetcher
1693
+
1694
+ \`\`\`bash
1695
+ curl -sf https://docs.amba.dev/docs/prompts/expo-build.md
1696
+ \`\`\`
1697
+
1698
+ If \`curl\` fails (404, 5xx, network error, no internet), fall back to
1699
+ the **inlined snapshot** below — it was captured at CLI install time
1700
+ and is a complete, self-contained version of the prompt.
1701
+
1702
+ When the fetcher succeeds, prefer the live version: it's the source of
1703
+ truth and may have been updated since this skill was installed.
1704
+
1705
+ ## Inlined snapshot
1706
+
1707
+ The block between the AMBA-BUILD-PROMPT-START / -END markers is the
1708
+ prompt content captured at CLI install time. Re-run
1709
+ \`npx @layers/amba init --sandbox\` (or a future \`amba update-skills\`
1710
+ command) to refresh.
1711
+
1712
+ <!-- AMBA-BUILD-PROMPT-START -->
1713
+ ${EXPO_BUILD_PROMPT_MD}<!-- AMBA-BUILD-PROMPT-END -->
1714
+ `;
1715
+ }
1716
+ /**
1717
+ * 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.
1729
+ */
1730
+ async function writeAmbaBuildSkill(options = {}) {
1731
+ const skillDir = join(options.baseDir ?? process.cwd(), ".claude", "skills", "amba-build");
1732
+ await mkdir(skillDir, { recursive: true });
1733
+ const skillPath = join(skillDir, "SKILL.md");
1734
+ await writeFile(skillPath, buildAmbaBuildSkillContent(), "utf-8");
1735
+ return { path: skillPath };
1736
+ }
1737
+ //#endregion
814
1738
  //#region src/commands/init.ts
815
1739
  function prompt(question) {
816
1740
  const rl = createInterface({
@@ -923,6 +1847,18 @@ Docs: https://docs.amba.dev
923
1847
  }
924
1848
  async function initCommand(options = {}) {
925
1849
  const cwd = process.cwd();
1850
+ if (options.sandbox) {
1851
+ const result = await runSandboxInit(cwd, {
1852
+ sandboxEmail: options.sandboxEmail,
1853
+ noMcpConfig: options.noMcpConfig,
1854
+ noSkills: options.noSkills,
1855
+ json: options.json,
1856
+ homeDir: options.homeDir
1857
+ });
1858
+ if (options.json) process.stdout.write(JSON.stringify(sandboxResultToJson(result), null, 2) + "\n");
1859
+ else printSandboxNextSteps(result);
1860
+ return;
1861
+ }
926
1862
  const environment = options.env ?? "development";
927
1863
  console.log();
928
1864
  console.log(pc.bold(" amba init"));
@@ -1092,6 +2028,159 @@ async function initCommand(options = {}) {
1092
2028
  console.log(` Docs: ${pc.underline("https://docs.amba.dev")}`);
1093
2029
  console.log();
1094
2030
  }
2031
+ async function runSandboxInit(cwd, options) {
2032
+ if (!options.json) {
2033
+ console.log();
2034
+ console.log(pc.bold(" amba init --sandbox"));
2035
+ console.log(pc.dim(" ─────────────────────────────────"));
2036
+ console.log();
2037
+ }
2038
+ const signup = await performSandboxSignup({
2039
+ email: options.sandboxEmail?.trim() || generateSandboxEmail(),
2040
+ password: generateSandboxPassword()
2041
+ });
2042
+ const envLocalPath = await writeSandboxEnvLocal(cwd, signup.project_id, signup.client_key, signup.api_url);
2043
+ const framework = await detectFramework(cwd);
2044
+ const sdkPackage = getSdkPackage(framework);
2045
+ const ambaMdPath = await writeSandboxAmbaMd(cwd, {
2046
+ projectId: signup.project_id,
2047
+ email: signup.email,
2048
+ verifyUrl: signup.verify_url ?? null,
2049
+ sdkPackage,
2050
+ framework,
2051
+ apiUrl: signup.api_url
2052
+ });
2053
+ const warn = options.json ? (msg) => process.stderr.write(msg + "\n") : (msg) => console.warn(msg);
2054
+ let mcpConfigsWritten = [];
2055
+ if (!options.noMcpConfig) mcpConfigsWritten = await writeAllMcpConfigs(cwd, signup.pat, {
2056
+ homeDir: options.homeDir,
2057
+ warn
2058
+ });
2059
+ const credentialsResult = await writeSandboxCredentials(signup.pat, { homeDir: options.homeDir });
2060
+ 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)}`);
2065
+ }
2066
+ 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,
2075
+ envLocalPath,
2076
+ ambaMdPath,
2077
+ mcpConfigsWritten,
2078
+ sdkPackage,
2079
+ framework,
2080
+ verifyUrl: signup.verify_url ?? null,
2081
+ provisioningStatus: signup.provisioning_status ?? "unknown",
2082
+ skillPath
2083
+ };
2084
+ }
2085
+ function sandboxResultToJson(r) {
2086
+ return {
2087
+ ok: true,
2088
+ mode: "sandbox",
2089
+ email: r.email,
2090
+ project_id: r.projectId,
2091
+ pat_preview: r.patPreview,
2092
+ client_key: r.clientKey,
2093
+ api_url: r.apiUrl,
2094
+ framework: r.framework,
2095
+ sdk_package: r.sdkPackage,
2096
+ credentials_path: r.credentialsPath,
2097
+ credentials_backed_up_to: r.credentialsBackedUpTo,
2098
+ env_local_path: r.envLocalPath,
2099
+ amba_md_path: r.ambaMdPath,
2100
+ mcp_configs_written: r.mcpConfigsWritten.map((m) => ({
2101
+ path: m.path,
2102
+ backed_up_to: m.backedUpTo
2103
+ })),
2104
+ skill_path: r.skillPath,
2105
+ verify_url: r.verifyUrl,
2106
+ provisioning_status: r.provisioningStatus,
2107
+ next_steps: ["restart your MCP client"]
2108
+ };
2109
+ }
2110
+ /**
2111
+ * 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.
2131
+ */
2132
+ function buildSandboxNextStepsLines(r) {
2133
+ 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:`);
2148
+ lines.push("");
2149
+ for (const snippetLine of formatManualMcpSnippet(r.pat).split("\n")) lines.push(` ${snippetLine}`);
2150
+ lines.push("");
2151
+ }
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
+ return lines;
2167
+ }
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
+ 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);
2182
+ console.log();
2183
+ }
1095
2184
  //#endregion
1096
2185
  //#region src/commands/login.ts
1097
2186
  async function loginCommand() {
@@ -2262,6 +3351,61 @@ async function schemaExportCommand(opts) {
2262
3351
  process.stdout.write(output);
2263
3352
  }
2264
3353
  //#endregion
3354
+ //#region src/project-config.ts
3355
+ /**
3356
+ * Local project config loader.
3357
+ *
3358
+ * `amba init` writes `.env.local` with `AMBA_PROJECT_ID` + `AMBA_API_KEY`.
3359
+ * Subsequent commands resolve the active project by reading `.env.local`
3360
+ * (or the OS env if exported); fail with a clear "run amba init first"
3361
+ * error if neither is set.
3362
+ *
3363
+ * Kept tiny on purpose — the CLI's full config story (per-environment
3364
+ * dev/prod selection) is a v2 follow-up; v1 just needs project id.
3365
+ */
3366
+ const ENV_LOCAL_FILES = [".env.local", ".env"];
3367
+ async function loadProjectConfig(cwd = process.cwd()) {
3368
+ let projectId = process.env["AMBA_PROJECT_ID"];
3369
+ let apiUrl = process.env["AMBA_API_URL"];
3370
+ if (!projectId || !apiUrl) for (const filename of ENV_LOCAL_FILES) {
3371
+ const content = await readFile(join(cwd, filename), "utf-8").catch(() => null);
3372
+ if (!content) continue;
3373
+ const parsed = parseEnv(content);
3374
+ if (!projectId) projectId = parsed["AMBA_PROJECT_ID"];
3375
+ if (!apiUrl) apiUrl = parsed["AMBA_API_URL"];
3376
+ if (projectId && apiUrl) break;
3377
+ }
3378
+ if (!projectId) throw new Error(`AMBA_PROJECT_ID not found. Run ${pc.cyan("amba init")} or set it in .env.local.`);
3379
+ return {
3380
+ projectId,
3381
+ apiUrl: apiUrl ?? "https://api.amba.dev"
3382
+ };
3383
+ }
3384
+ /**
3385
+ * Parse a `.env`-style file body into a flat string map.
3386
+ *
3387
+ * Lines are trimmed; blank lines and `#`-comments are skipped; lines
3388
+ * without an `=` are skipped. Values may be wrapped in matching single
3389
+ * or double quotes which are stripped on read. Bug fixes should land
3390
+ * here once — both `project-config.ts` (resolves `AMBA_PROJECT_ID`)
3391
+ * and `commands/functions.ts` (loads `.env.local` for the local dev
3392
+ * server) call this.
3393
+ */
3394
+ function parseEnv(content) {
3395
+ const out = {};
3396
+ for (const rawLine of content.split("\n")) {
3397
+ const line = rawLine.trim();
3398
+ if (!line || line.startsWith("#")) continue;
3399
+ const eq = line.indexOf("=");
3400
+ if (eq === -1) continue;
3401
+ const key = line.slice(0, eq).trim();
3402
+ let value = line.slice(eq + 1).trim();
3403
+ if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
3404
+ out[key] = value;
3405
+ }
3406
+ return out;
3407
+ }
3408
+ //#endregion
2265
3409
  //#region src/bundle.ts
2266
3410
  /**
2267
3411
  * Customer-function bundling for `amba functions deploy`.
@@ -2370,51 +3514,6 @@ function formatBytes$1(n) {
2370
3514
  return `${(n / 1024 / 1024).toFixed(2)}MB`;
2371
3515
  }
2372
3516
  //#endregion
2373
- //#region src/project-config.ts
2374
- /**
2375
- * Local project config loader.
2376
- *
2377
- * `amba init` writes `.env.local` with `AMBA_PROJECT_ID` + `AMBA_API_KEY`.
2378
- * Subsequent commands resolve the active project by reading `.env.local`
2379
- * (or the OS env if exported); fail with a clear "run amba init first"
2380
- * error if neither is set.
2381
- *
2382
- * Kept tiny on purpose — the CLI's full config story (per-environment
2383
- * dev/prod selection) is a v2 follow-up; v1 just needs project id.
2384
- */
2385
- const ENV_LOCAL_FILES = [".env.local", ".env"];
2386
- async function loadProjectConfig(cwd = process.cwd()) {
2387
- let projectId = process.env["AMBA_PROJECT_ID"];
2388
- let apiUrl = process.env["AMBA_API_URL"];
2389
- if (!projectId || !apiUrl) for (const filename of ENV_LOCAL_FILES) {
2390
- const content = await readFile(join(cwd, filename), "utf-8").catch(() => null);
2391
- if (!content) continue;
2392
- const parsed = parseEnv(content);
2393
- if (!projectId) projectId = parsed["AMBA_PROJECT_ID"];
2394
- if (!apiUrl) apiUrl = parsed["AMBA_API_URL"];
2395
- if (projectId && apiUrl) break;
2396
- }
2397
- if (!projectId) throw new Error(`AMBA_PROJECT_ID not found. Run ${pc.cyan("amba init")} or set it in .env.local.`);
2398
- return {
2399
- projectId,
2400
- apiUrl: apiUrl ?? "https://api.amba.dev"
2401
- };
2402
- }
2403
- function parseEnv(content) {
2404
- const out = {};
2405
- for (const rawLine of content.split("\n")) {
2406
- const line = rawLine.trim();
2407
- if (!line || line.startsWith("#")) continue;
2408
- const eq = line.indexOf("=");
2409
- if (eq === -1) continue;
2410
- const key = line.slice(0, eq).trim();
2411
- let value = line.slice(eq + 1).trim();
2412
- if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
2413
- out[key] = value;
2414
- }
2415
- return out;
2416
- }
2417
- //#endregion
2418
3517
  //#region src/commands/functions.ts
2419
3518
  /**
2420
3519
  * `amba functions ...` commands.
@@ -2447,7 +3546,7 @@ async function functionsDeployCommand(entryPoint, options = {}) {
2447
3546
  bundleCode: bundle.code,
2448
3547
  rate_limit: rateLimit
2449
3548
  });
2450
- console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version} (${result.data.cf_script_name})`)}`);
3549
+ console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version}`)}`);
2451
3550
  console.log(pc.green(" ✓") + ` URL: ${pc.underline(result.fn_url)}`);
2452
3551
  if (rateLimit) console.log(pc.dim(` Rate limit: ${rateLimit.max} per ${rateLimit.window} (key=${rateLimit.key}) — enforced pre-dispatch`));
2453
3552
  console.log();
@@ -2465,10 +3564,10 @@ async function functionsListCommand() {
2465
3564
  }
2466
3565
  async function functionsDeleteCommand(name, options = {}) {
2467
3566
  validateFunctionName(name);
2468
- if (!options.confirm || options.confirm !== name) throw new Error(`Delete is destructive. Pass --confirm ${name} to proceed. Customer Workers calling this function will start 404'ing immediately.`);
3567
+ if (!options.confirm || options.confirm !== name) throw new Error(`Delete is destructive. Pass --confirm ${name} to proceed. Clients calling this function will start 404'ing immediately.`);
2469
3568
  const cascade = (await deleteFunctionViaApi((await loadProjectConfig()).projectId, name, { confirm: name })).data.cascade;
2470
3569
  console.log(pc.green(" ✓") + ` Deleted ${pc.bold(name)}.`);
2471
- console.log(pc.dim(` Cascade: cf_dispatch_script_deleted=${cascade.cf_dispatch_script_deleted ?? false}, function_deployments_marked_disabled=${cascade.function_deployments_marked_disabled ?? 0}`));
3570
+ console.log(pc.dim(` Cascade: runtime_script_removed=${cascade.runtime_script_removed ?? false}, function_deployments_marked_disabled=${cascade.function_deployments_marked_disabled ?? 0}`));
2472
3571
  }
2473
3572
  async function functionsScheduleCommand(name, cron, options = {}) {
2474
3573
  const projectConfig = await loadProjectConfig();
@@ -2485,15 +3584,364 @@ async function functionsScheduleCommand(name, cron, options = {}) {
2485
3584
  console.log();
2486
3585
  }
2487
3586
  /**
2488
- * `amba functions dev`not configured in this release. Use
2489
- * `amba functions deploy <file>` to deploy via the platform API.
3587
+ * `amba functions dev <file>` run the function locally with file-change
3588
+ * hot reload.
3589
+ *
3590
+ * Architecture: the CLI process owns the file watcher + bundler; the
3591
+ * actual HTTP server lives in a child Node process that imports the
3592
+ * bundle and binds to the user's port. On file change, the parent kills
3593
+ * the child, writes a fresh bundle, and respawns. This bounds memory
3594
+ * (each rebuild = fresh V8 heap) and isolates customer-handler crashes
3595
+ * from the CLI. Brief downtime per rebuild (~100ms while the port is
3596
+ * released and re-bound); incoming connections during the swap queue
3597
+ * at the TCP listen backlog and complete once the new child is up.
3598
+ *
3599
+ * The bundler is the same esbuild pipeline `amba functions deploy` uses
3600
+ * — externalization rules, size cap, and source-map handling are
3601
+ * identical so dev → prod parity is automatic.
3602
+ *
3603
+ * Handler shape: `export default async function (req: Request): Promise<Response>`.
3604
+ * `.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).
2490
3608
  */
2491
- async function functionsDevCommand(_entryPoint) {
2492
- console.error(pc.red(" Error: `amba functions dev` is not available in this release."));
2493
- console.error(pc.dim(" Use `amba functions deploy <file>` to deploy via the platform API."));
2494
- process.exit(1);
3609
+ async function functionsDevCommand(entryPoint, options = {}) {
3610
+ const port = validatePort(options.port);
3611
+ const resolvedEntry = resolve(entryPoint);
3612
+ const childEnv = {
3613
+ ...await loadEnvLocal(),
3614
+ ...process.env
3615
+ };
3616
+ const tmpRoot = await mkdtemp(join(tmpdir(), "amba-fn-dev-"));
3617
+ const bundlePath = join(tmpRoot, "fn.mjs");
3618
+ const runnerPath = join(tmpRoot, "runner.mjs");
3619
+ await writeFile(runnerPath, DEV_RUNNER_SOURCE, "utf8");
3620
+ let child = null;
3621
+ let buildSeq = 0;
3622
+ let shuttingDown = false;
3623
+ let inFlightRebuild = null;
3624
+ let rebuildQueue = Promise.resolve(true);
3625
+ const scheduleRebuild = () => {
3626
+ rebuildQueue = rebuildQueue.then(() => rebuildLocked());
3627
+ inFlightRebuild = rebuildQueue;
3628
+ return rebuildQueue;
3629
+ };
3630
+ /**
3631
+ * Returns true when the child was spawned and serving on the port,
3632
+ * false on any failure path. The caller decides whether to surface a
3633
+ * "server is listening" banner based on that — a green checkmark
3634
+ * after a red error would mislead users.
3635
+ */
3636
+ async function rebuildLocked() {
3637
+ buildSeq++;
3638
+ const seq = buildSeq;
3639
+ let bundleResult;
3640
+ try {
3641
+ bundleResult = await bundleFunction({
3642
+ entryPoint: resolvedEntry,
3643
+ sourcemap: "inline"
3644
+ });
3645
+ } catch (err) {
3646
+ console.error(pc.red(" Bundle failed:"), err instanceof Error ? err.message : String(err));
3647
+ return false;
3648
+ }
3649
+ if (seq !== buildSeq || shuttingDown) return false;
3650
+ try {
3651
+ await writeFile(bundlePath, bundleResult.code, "utf8");
3652
+ } catch (err) {
3653
+ console.error(pc.red(" Write failed:"), err instanceof Error ? err.message : String(err));
3654
+ return false;
3655
+ }
3656
+ if (seq !== buildSeq || shuttingDown) return false;
3657
+ if (child) {
3658
+ const prev = child;
3659
+ prev.kill("SIGTERM");
3660
+ await waitForChildExit(prev, 2e3);
3661
+ if (child === prev) child = null;
3662
+ }
3663
+ if (seq !== buildSeq || shuttingDown) return false;
3664
+ const next = spawn(process.execPath, [
3665
+ runnerPath,
3666
+ bundlePath,
3667
+ String(port)
3668
+ ], {
3669
+ env: childEnv,
3670
+ stdio: [
3671
+ "ignore",
3672
+ "pipe",
3673
+ "inherit"
3674
+ ]
3675
+ });
3676
+ let spawnError = null;
3677
+ next.on("error", (err) => {
3678
+ spawnError = err;
3679
+ });
3680
+ try {
3681
+ await waitForChildReady(next, 5e3);
3682
+ } catch (err) {
3683
+ const cause = spawnError ?? (err instanceof Error ? err : new Error(String(err)));
3684
+ console.error(pc.red(" Child failed to start:"), cause.message);
3685
+ next.kill("SIGKILL");
3686
+ return false;
3687
+ }
3688
+ if (spawnError) {
3689
+ console.error(pc.red(" Child failed to start:"), spawnError.message);
3690
+ next.kill("SIGKILL");
3691
+ return false;
3692
+ }
3693
+ if (seq !== buildSeq || shuttingDown) {
3694
+ next.kill("SIGTERM");
3695
+ return false;
3696
+ }
3697
+ child = next;
3698
+ next.stdout?.on("data", (chunk) => process.stdout.write(chunk));
3699
+ next.on("exit", (code, signal) => {
3700
+ if (child === next && !shuttingDown && signal !== "SIGTERM") {
3701
+ console.error(pc.red(` Child exited unexpectedly (code=${code}, signal=${signal ?? "none"})`));
3702
+ child = null;
3703
+ }
3704
+ });
3705
+ console.log(pc.green(" ✓") + pc.dim(` bundle ready — ${bundleResult.uncompressedSize} bytes (${bundleResult.sha256.slice(0, 12)}…)`));
3706
+ return true;
3707
+ }
3708
+ const initialOk = await scheduleRebuild();
3709
+ let watcherCloser = null;
3710
+ let pendingRebuildTimer = null;
3711
+ if (!options.noWatch) {
3712
+ const watcher = watch(resolvedEntry, () => {
3713
+ if (shuttingDown) return;
3714
+ if (pendingRebuildTimer) clearTimeout(pendingRebuildTimer);
3715
+ pendingRebuildTimer = setTimeout(() => {
3716
+ pendingRebuildTimer = null;
3717
+ if (shuttingDown) return;
3718
+ console.log(pc.dim("\n ↻ file changed — rebuilding"));
3719
+ scheduleRebuild();
3720
+ }, 100);
3721
+ });
3722
+ watcherCloser = () => watcher.close();
3723
+ }
3724
+ console.log();
3725
+ if (initialOk) console.log(pc.green(" ✓") + ` Local dev server: ${pc.underline(`http://localhost:${port}`)}`);
3726
+ else console.log(pc.yellow(" !") + " Dev server is NOT listening — fix the bundle / handler errors above and save the file.");
3727
+ console.log(pc.dim(` Entry: ${entryPoint}`));
3728
+ if (!options.noWatch) console.log(pc.dim(" Watching for changes. Ctrl+C to stop."));
3729
+ console.log();
3730
+ const shutdown = async (exitCode = 0) => {
3731
+ if (shuttingDown) return;
3732
+ shuttingDown = true;
3733
+ if (pendingRebuildTimer) clearTimeout(pendingRebuildTimer);
3734
+ watcherCloser?.();
3735
+ if (inFlightRebuild) try {
3736
+ await inFlightRebuild;
3737
+ } catch {}
3738
+ if (child) {
3739
+ const c = child;
3740
+ child = null;
3741
+ c.kill("SIGTERM");
3742
+ await waitForChildExit(c, 2e3);
3743
+ }
3744
+ await rm(tmpRoot, {
3745
+ recursive: true,
3746
+ force: true
3747
+ }).catch(() => {});
3748
+ process.exit(exitCode);
3749
+ };
3750
+ process.on("SIGINT", () => void shutdown(0));
3751
+ process.on("SIGTERM", () => void shutdown(0));
3752
+ process.on("SIGHUP", () => void shutdown(0));
3753
+ process.on("uncaughtException", (err) => {
3754
+ console.error(pc.red(" Uncaught exception in CLI:"), err);
3755
+ shutdown(1);
3756
+ });
3757
+ process.on("unhandledRejection", (err) => {
3758
+ console.error(pc.red(" Unhandled rejection in CLI:"), err);
3759
+ shutdown(1);
3760
+ });
3761
+ }
3762
+ /**
3763
+ * Validate `--port` value. Defaults to 8787 when unset. Rejects
3764
+ * non-integers, ports below 1024 (which need root on POSIX), and
3765
+ * anything above 65535. Bailing here means the spawned child never
3766
+ * gets a NaN port that quietly picks a random ephemeral port — that
3767
+ * was confusing for customers ("the banner says :NaN").
3768
+ */
3769
+ function validatePort(raw) {
3770
+ if (raw === void 0) return 8787;
3771
+ if (!Number.isInteger(raw) || raw < 1 || raw > 65535) throw new Error(`Invalid --port ${raw}; must be an integer between 1 and 65535`);
3772
+ if (raw < 1024) throw new Error(`--port ${raw} is in the privileged range (<1024) and would need root on POSIX. Pick a port ≥ 1024.`);
3773
+ return raw;
3774
+ }
3775
+ /**
3776
+ * Reads `.env.local` from the current working directory (if present)
3777
+ * and returns the parsed KEY → VALUE map. Uses the shared `parseEnv`
3778
+ * from `project-config.ts` so any fix to the parser propagates here
3779
+ * automatically.
3780
+ */
3781
+ async function loadEnvLocal() {
3782
+ try {
3783
+ return parseEnv(await readFile(".env.local", "utf8"));
3784
+ } catch {
3785
+ return {};
3786
+ }
3787
+ }
3788
+ /**
3789
+ * Wait for a child process to print `AMBA_DEV_READY` on stdout (the
3790
+ * marker emitted by `DEV_RUNNER_SOURCE` once its HTTP server has bound
3791
+ * to the port). Rejects if the child exits before the marker arrives
3792
+ * or the timeout elapses.
3793
+ */
3794
+ function waitForChildReady(child, timeoutMs) {
3795
+ return new Promise((resolveReady, rejectReady) => {
3796
+ let buffered = "";
3797
+ let settled = false;
3798
+ const settle = (fn) => {
3799
+ if (settled) return;
3800
+ settled = true;
3801
+ child.stdout?.off("data", onData);
3802
+ child.off("exit", onExit);
3803
+ clearTimeout(timer);
3804
+ fn();
3805
+ };
3806
+ const onData = (chunk) => {
3807
+ buffered += typeof chunk === "string" ? chunk : chunk.toString("utf8");
3808
+ const idx = buffered.indexOf("AMBA_DEV_READY\n");
3809
+ if (idx === -1) return;
3810
+ const before = buffered.slice(0, idx);
3811
+ const after = buffered.slice(idx + 15);
3812
+ if (before.length > 0) process.stdout.write(before);
3813
+ if (after.length > 0) process.stdout.write(after);
3814
+ settle(() => resolveReady());
3815
+ };
3816
+ const onExit = (code, signal) => {
3817
+ settle(() => rejectReady(/* @__PURE__ */ new Error(`child exited before ready (code=${code}, signal=${signal ?? "-"})`)));
3818
+ };
3819
+ const timer = setTimeout(() => {
3820
+ settle(() => rejectReady(/* @__PURE__ */ new Error(`timed out after ${timeoutMs}ms waiting for child ready`)));
3821
+ }, timeoutMs);
3822
+ child.stdout?.on("data", onData);
3823
+ child.on("exit", onExit);
3824
+ });
3825
+ }
3826
+ /**
3827
+ * Wait for a child to exit. If `timeoutMs` elapses first, SIGKILL it
3828
+ * and resolve anyway — the caller doesn't care which path closed the
3829
+ * port, only that it's closed.
3830
+ */
3831
+ function waitForChildExit(child, timeoutMs) {
3832
+ return new Promise((resolveExit) => {
3833
+ if (child.exitCode !== null || child.signalCode !== null) return resolveExit();
3834
+ const timer = setTimeout(() => {
3835
+ child.kill("SIGKILL");
3836
+ }, timeoutMs);
3837
+ child.once("exit", () => {
3838
+ clearTimeout(timer);
3839
+ resolveExit();
3840
+ });
3841
+ });
2495
3842
  }
2496
3843
  /**
3844
+ * The script the child Node process runs. Bound as a string template
3845
+ * so the parent CLI ships with no separate file to package. The runner
3846
+ * imports the bundle once at startup (so the V8 heap holds exactly one
3847
+ * version of the customer code) and serves it on the user's port.
3848
+ *
3849
+ * READY handshake: the runner writes `AMBA_DEV_READY\n` to stdout once
3850
+ * `server.listen()` calls back — the parent uses that to gate killing
3851
+ * the previous child.
3852
+ */
3853
+ const DEV_RUNNER_SOURCE = `
3854
+ import { createServer } from 'node:http';
3855
+ import { pathToFileURL } from 'node:url';
3856
+
3857
+ const bundlePath = process.argv[2];
3858
+ const port = Number(process.argv[3]);
3859
+ const parentPid = process.ppid;
3860
+
3861
+ // Parent-pid watchdog. If the CLI gets SIGKILL'd or its terminal
3862
+ // closes without delivering SIGHUP, the parent's signal handlers
3863
+ // don't run and this child would orphan — keep holding the port,
3864
+ // keep serving stale code. Polling ppid every 2s catches the orphan
3865
+ // case: when the parent dies, the OS reparents us (ppid changes,
3866
+ // becomes 1 on POSIX). On a clean SIGTERM from the parent, the
3867
+ // signal handler at the bottom exits us first so this never fires.
3868
+ setInterval(() => {
3869
+ if (process.ppid !== parentPid) {
3870
+ process.stderr.write(' Parent CLI is gone; exiting child to free port.\\n');
3871
+ process.exit(0);
3872
+ }
3873
+ }, 2000).unref();
3874
+
3875
+ let handler;
3876
+ try {
3877
+ // Node's ESM \`import()\` requires a URL on Windows — a raw absolute
3878
+ // path like C:\\\\foo\\\\bar.mjs throws ERR_UNSUPPORTED_ESM_URL_SCHEME.
3879
+ // \`pathToFileURL\` is a no-op equivalent on POSIX (returns file:///...).
3880
+ const bundleUrl = pathToFileURL(bundlePath).href;
3881
+ 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');
3884
+ process.exit(2);
3885
+ }
3886
+ handler = mod.default;
3887
+ } catch (err) {
3888
+ process.stderr.write(' Bundle import failed: ' + (err && err.stack ? err.stack : err) + '\\n');
3889
+ process.exit(2);
3890
+ }
3891
+
3892
+ const server = createServer(async (req, res) => {
3893
+ try {
3894
+ const proto = req.socket && req.socket.encrypted ? 'https' : 'http';
3895
+ const url = proto + '://' + (req.headers.host || 'localhost') + (req.url || '/');
3896
+ const headers = new Headers();
3897
+ for (const [k, v] of Object.entries(req.headers)) {
3898
+ if (typeof v === 'string') headers.set(k, v);
3899
+ else if (Array.isArray(v)) for (const vv of v) headers.append(k, vv);
3900
+ }
3901
+ let body = null;
3902
+ if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
3903
+ const chunks = [];
3904
+ for await (const chunk of req) chunks.push(chunk);
3905
+ body = Buffer.concat(chunks);
3906
+ }
3907
+ const request = new Request(url, { method: req.method, headers, body });
3908
+ const response = await handler(request);
3909
+ res.statusCode = response.status;
3910
+ response.headers.forEach((value, key) => res.setHeader(key, value));
3911
+ res.end(Buffer.from(await response.arrayBuffer()));
3912
+ } catch (err) {
3913
+ process.stderr.write(' Handler error: ' + (err && err.stack ? err.stack : err) + '\\n');
3914
+ if (!res.headersSent) {
3915
+ res.statusCode = 500;
3916
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
3917
+ }
3918
+ res.end('Internal Server Error');
3919
+ }
3920
+ });
3921
+
3922
+ server.on('error', (err) => {
3923
+ process.stderr.write(' Server error: ' + (err && err.message ? err.message : err) + '\\n');
3924
+ process.exit(3);
3925
+ });
3926
+
3927
+ // Bind to 127.0.0.1 explicitly so the dev server is loopback-only.
3928
+ // Node's default \`server.listen(port)\` binds 0.0.0.0 (or ::), which
3929
+ // exposes the local handler — potentially reading secrets from
3930
+ // .env.local — to every device on the same Wi-Fi. The banner says
3931
+ // "localhost" so the customer's mental model expects local-only;
3932
+ // match that. Aligns with Vite / wrangler / next dev defaults.
3933
+ server.listen(port, '127.0.0.1', () => {
3934
+ process.stdout.write('AMBA_DEV_READY\\n');
3935
+ });
3936
+
3937
+ const shutdown = (signal) => {
3938
+ server.close(() => process.exit(0));
3939
+ setTimeout(() => process.exit(0), 1000).unref();
3940
+ };
3941
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
3942
+ process.on('SIGINT', () => shutdown('SIGINT'));
3943
+ `;
3944
+ /**
2497
3945
  * `amba functions consume <queue> <function>` — bind a function as the
2498
3946
  * consumer for a queue. Customers send to a queue with `ctx.queue.send`;
2499
3947
  * the genericQueueJobWorkflow looks up the binding and invokes the
@@ -3238,10 +4686,9 @@ function validateHostname(host) {
3238
4686
  if (!HOSTNAME_RE.test(host)) throw new Error(`Invalid hostname '${host}'. Must be a DNS-shaped name (e.g. site.example.com).`);
3239
4687
  }
3240
4688
  /**
3241
- * Per-deploy size cap. CF Pages enforces 25 MiB per file + 25k files; we
3242
- * pre-flight at 100 MiB total so the developer sees an actionable error
3243
- * before we spend their time on a multi-second upload. Above this, point
3244
- * them at a Pages-only deployment outside amba.
4689
+ * Per-deploy size cap. The hosting provider enforces 25 MiB per file +
4690
+ * 25k files; we pre-flight at 100 MiB total so the developer sees an
4691
+ * actionable error before we spend their time on a multi-second upload.
3245
4692
  */
3246
4693
  const MAX_DEPLOYMENT_BYTES = 100 * 1024 * 1024;
3247
4694
  const MAX_DEPLOYMENT_FILES = 2e4;
@@ -3264,23 +4711,23 @@ async function sitesDeployCommand(inputDir, options = {}) {
3264
4711
  if (totalBytes > MAX_DEPLOYMENT_BYTES) throw new Error(`Deployment too large (${formatBytes(totalBytes)} > ${formatBytes(MAX_DEPLOYMENT_BYTES)}). Trim assets or split into multiple sites.`);
3265
4712
  console.log(pc.dim(` ${files.length} files, ${formatBytes(totalBytes)}`));
3266
4713
  if (options.dryRun) {
3267
- console.log(pc.yellow(" ! Dry run — skipping CF Pages upload + control-plane write."));
4714
+ console.log(pc.yellow(" ! Dry run — skipping upload + control-plane write."));
3268
4715
  console.log();
3269
4716
  return;
3270
4717
  }
3271
- let cfPagesProjectName;
4718
+ let slug;
3272
4719
  try {
3273
- cfPagesProjectName = (await createSite(projectId, { name: siteName })).data.cf_pages_project_name;
3274
- console.log(pc.green(" ✓") + ` Registered site (cf_pages_project=${cfPagesProjectName})`);
4720
+ slug = (await createSite(projectId, { name: siteName })).data.slug;
4721
+ console.log(pc.green(" ✓") + ` Registered site (slug=${slug})`);
3275
4722
  } catch (err) {
3276
- cfPagesProjectName = (await describeSite(projectId, siteName)).data.cf_pages_project_name;
3277
- console.log(pc.dim(` Site already registered (cf_pages_project=${cfPagesProjectName})`));
4723
+ slug = (await describeSite(projectId, siteName)).data.slug;
4724
+ console.log(pc.dim(` Site already registered (slug=${slug})`));
3278
4725
  }
3279
4726
  console.log(pc.dim(" Uploading…"));
3280
4727
  const dep = (await deploySiteViaApi(projectId, siteName, await buildPagesDeploymentForm(files))).data;
3281
4728
  console.log(pc.green(" ✓") + ` Deployed ${dep.deployment_id.slice(0, 12)} ${pc.dim(`(branch=${dep.branch}, status=${dep.status})`)}`);
3282
4729
  console.log(pc.green(" ✓") + ` URL: ${pc.underline(dep.url)}`);
3283
- if (dep.preview_url && dep.preview_url !== dep.url) console.log(pc.dim(` preview (CF): ${dep.preview_url}`));
4730
+ if (dep.preview_url && dep.preview_url !== dep.url) console.log(pc.dim(` preview: ${dep.preview_url}`));
3284
4731
  const domains = await listSiteDomains(projectId, siteName);
3285
4732
  if (domains.data.length > 0) {
3286
4733
  console.log();
@@ -3299,7 +4746,7 @@ async function sitesListCommand() {
3299
4746
  }
3300
4747
  for (const s of res.data) {
3301
4748
  const status = s.status === "active" ? pc.green("active") : pc.yellow(s.status);
3302
- console.log(` ${pc.bold(s.name)} ${status} ${pc.dim(`pages=${s.cf_pages_project_name} ${s.created_at}`)}`);
4749
+ console.log(` ${pc.bold(s.name)} ${status} ${pc.dim(`slug=${s.slug} ${s.created_at}`)}`);
3303
4750
  }
3304
4751
  console.log();
3305
4752
  }
@@ -3311,7 +4758,7 @@ async function sitesListCommand() {
3311
4758
  async function sitesLogsCommand(name) {
3312
4759
  validateSiteName(name);
3313
4760
  console.log();
3314
- console.log(pc.yellow(" ! `amba sites logs` is not available in this release."));
4761
+ console.log(pc.yellow(" ! `amba sites logs` is not available via the CLI."));
3315
4762
  console.log();
3316
4763
  console.log(pc.dim(" Alternatives:"));
3317
4764
  console.log(pc.dim(" amba sites describe <name> (current state + domains/certs)"));
@@ -3334,7 +4781,7 @@ async function sitesDomainAddCommand(hostname, options) {
3334
4781
  console.log(pc.dim(` → site ${pc.cyan(options.site)}`));
3335
4782
  console.log();
3336
4783
  const res = await addSiteDomainViaApi(projectId, options.site, hostname);
3337
- console.log(pc.green(" ✓") + ` Custom Hostname registered (cf_id=${res.data.cf_hostname_id})`);
4784
+ console.log(pc.green(" ✓") + ` Custom hostname registered`);
3338
4785
  console.log();
3339
4786
  console.log(pc.dim(" Point your DNS at:"));
3340
4787
  console.log(` ${pc.bold("CNAME")} ${hostname} → ${pc.cyan(res.data.dns_target)}`);
@@ -3397,7 +4844,7 @@ async function sitesArchiveCommand(name, options = {}) {
3397
4844
  if (!options.confirm || options.confirm !== name) throw new Error(`Archive is destructive. Pass --confirm ${name} to proceed. The site project will be removed and traffic will 404.`);
3398
4845
  const cascade = (await deleteSiteViaApi((await loadProjectConfig()).projectId, name, { confirm: name })).data.cascade;
3399
4846
  console.log(pc.green(" ✓") + ` Archived ${name}.`);
3400
- console.log(pc.dim(` Cascade: domains_removed=${cascade.domains_removed ?? 0}, cf_pages_project_deleted=${cascade.cf_pages_project_deleted ?? false}`));
4847
+ console.log(pc.dim(` Cascade: domains_removed=${cascade.domains_removed ?? 0}, site_runtime_removed=${cascade.site_runtime_removed ?? false}`));
3401
4848
  }
3402
4849
  /**
3403
4850
  * Sites are static-only. Dynamic logic belongs in `amba functions deploy`;
@@ -3501,7 +4948,7 @@ function runAction(fn) {
3501
4948
  process.exit(1);
3502
4949
  });
3503
4950
  }
3504
- program.command("init").description("Initialize Amba in the current project (mints a personal dev project by default)").option("--with-example", "Scaffold a sample app.tsx + README snippet into the current directory").option("--env <env>", "'development' (default) or 'production'").action(async (opts) => {
4951
+ program.command("init").description("Initialize Amba in the current project (mints a personal dev project by default)").option("--with-example", "Scaffold a sample app.tsx + README snippet into the current directory").option("--env <env>", "'development' (default) or 'production'").option("--sandbox", "Headless agentic mode: auto-signup, write .env.local + AMBA.md, auto-wire MCP client configs. No prompts.").option("--email <email>", "Override the auto-generated sandbox email (requires --sandbox)").option("--no-mcp-config", "Skip writing MCP client config files (rare; mostly for testing)").option("--no-skills", "Skip installing the project-local /amba-build Claude Code skill (default: install during --sandbox)").option("--json", "Emit a machine-readable JSON summary on stdout instead of human-readable lines").action(async (opts) => {
3505
4952
  let env;
3506
4953
  if (opts.env === "development" || opts.env === "dev") env = "development";
3507
4954
  else if (opts.env === "production" || opts.env === "prod") env = "production";
@@ -3509,9 +4956,30 @@ program.command("init").description("Initialize Amba in the current project (min
3509
4956
  console.error(`Error: --env must be 'development' or 'production' (got '${opts.env}').`);
3510
4957
  process.exit(1);
3511
4958
  }
4959
+ if (opts.email && !opts.sandbox) {
4960
+ console.error("Error: --email is only valid with --sandbox.");
4961
+ process.exit(1);
4962
+ }
4963
+ if (opts.json && !opts.sandbox) {
4964
+ console.error("Error: --json is only valid with --sandbox.");
4965
+ process.exit(1);
4966
+ }
4967
+ if (opts.mcpConfig === false && !opts.sandbox) {
4968
+ console.error("Error: --no-mcp-config is only valid with --sandbox.");
4969
+ process.exit(1);
4970
+ }
4971
+ if (opts.skills === false && !opts.sandbox) {
4972
+ console.error("Error: --no-skills is only valid with --sandbox.");
4973
+ process.exit(1);
4974
+ }
3512
4975
  await runAction(() => initCommand({
3513
4976
  withExample: opts.withExample,
3514
- env
4977
+ env,
4978
+ sandbox: opts.sandbox,
4979
+ sandboxEmail: opts.email,
4980
+ noMcpConfig: opts.mcpConfig === false,
4981
+ noSkills: opts.skills === false,
4982
+ json: opts.json
3515
4983
  }));
3516
4984
  });
3517
4985
  program.command("login").description("Authenticate with Amba").action(async () => {
@@ -3542,7 +5010,7 @@ const projects = program.command("projects").description("Project management com
3542
5010
  projects.command("list").description("List all projects in the authenticated developer account").action(async () => {
3543
5011
  await runAction(projectsListCommand);
3544
5012
  });
3545
- projects.command("create").description("Create a new project").requiredOption("--name <name>", "Project name").option("--env <env>", "Environment hint (informational; projects start in development)").option("--bundle-id <id>", "Bundle identifier (iOS/Android)").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").action(async (opts) => {
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) => {
3546
5014
  await runAction(() => projectsCreateCommand({
3547
5015
  name: opts.name,
3548
5016
  env: opts.env,
@@ -3593,7 +5061,7 @@ program.command("schema").description("Schema export commands").command("export"
3593
5061
  format: opts.format ?? "json"
3594
5062
  }));
3595
5063
  });
3596
- const functions = program.command("functions").description("Customer Worker functions (Cloudflare Workers for Platforms)");
5064
+ const functions = program.command("functions").description("Customer serverless functions deployed to the edge");
3597
5065
  functions.command("deploy <file>").description("Bundle a function file and deploy to the dispatch namespace").option("--name <name>", "Function name (default: filename without extension)").option("--dry-run", "Bundle and report size without uploading").option("--rate-limit-window <duration>", "Rate-limit window: 60s | 5m | 1h").option("--rate-limit-max <int>", "Rate-limit max requests per window", (v) => Number.parseInt(v, 10)).option("--rate-limit-key <kind>", "Rate-limit bucket key: user_id | ip").action(async (file, opts) => {
3598
5066
  await runAction(() => functionsDeployCommand(file, opts));
3599
5067
  });
@@ -3606,8 +5074,11 @@ functions.command("delete <name>").description("Disable + remove a function from
3606
5074
  functions.command("schedule <name> <cron>").description("Register a cron schedule that invokes a deployed function").option("--tz <iana>", "IANA timezone for the schedule (default: UTC)").action(async (name, cron, opts) => {
3607
5075
  await runAction(() => functionsScheduleCommand(name, cron, opts));
3608
5076
  });
3609
- functions.command("dev <file>").description("Run wrangler dev --remote against your dev project").action(async (file) => {
3610
- await runAction(() => functionsDevCommand(file));
5077
+ functions.command("dev <file>").description("Run a local dev server for your function with hot reload on file changes").option("--port <n>", "Port to listen on (default 8787)", (v) => parseInt(v, 10)).option("--no-watch", "Disable file-change hot reload").action(async (file, opts) => {
5078
+ await runAction(() => functionsDevCommand(file, {
5079
+ port: opts.port,
5080
+ noWatch: opts.watch === false
5081
+ }));
3611
5082
  });
3612
5083
  functions.command("logs <name>").description("Stream log events for a deployed function").option("--since <iso>", "Start of the time range (default: 1 hour ago)").option("--until <iso>", "End of the time range (default: now). Ignored on --tail.").option("--limit <n>", "Max events per fetch (default 100, max 1000)", (v) => parseInt(v, 10)).option("--tail", "Follow new events; polls every 3s. Ctrl+C to stop.").option("--follow", "Alias for --tail (kept for backwards compatibility with v1 log commands).").option("--json", "NDJSON output to stdout (one event per line)").action(async (name, opts) => {
3613
5084
  await runAction(() => functionsLogsCommand(name, {
@@ -3642,7 +5113,7 @@ secrets.command("list").description("List secret sync status for the current pro
3642
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) => {
3643
5114
  await runAction(() => secretsUnsetCommand(name, opts));
3644
5115
  });
3645
- const collections = program.command("collections").description("Customer collections (schema-first Postgres in tenant Neon)");
5116
+ const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
3646
5117
  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) => {
3647
5118
  await runAction(() => collectionsCreateCommand(name, opts));
3648
5119
  });
@@ -3681,7 +5152,7 @@ sites.command("archive <name>").description("Archive a site (DESTRUCTIVE — del
3681
5152
  await runAction(() => sitesArchiveCommand(name, opts));
3682
5153
  });
3683
5154
  const sitesDomain = sites.command("domain").description("Manage custom hostnames per site");
3684
- sitesDomain.command("add <hostname>").description("Attach a custom hostname (CF for SaaS — DV cert, polls until active)").requiredOption("--site <name>", "Site name to attach the hostname to").option("--zone-id <id>", "CF zone id (default: env CLOUDFLARE_AMBA_HOST_ZONE_ID)").option("--no-wait", "Skip the cert-status poll loop; return as soon as the row is recorded").option("--timeout <seconds>", "Cert poll timeout (default 600)", (v) => parseInt(v, 10)).action(async (hostname, opts) => {
5155
+ sitesDomain.command("add <hostname>").description("Attach a custom hostname (DV cert, polls until active)").requiredOption("--site <name>", "Site name to attach the hostname to").option("--zone-id <id>", "DNS zone id (default: env AMBA_DNS_ZONE_ID)").option("--no-wait", "Skip the cert-status poll loop; return as soon as the row is recorded").option("--timeout <seconds>", "Cert poll timeout (default 600)", (v) => parseInt(v, 10)).action(async (hostname, opts) => {
3685
5156
  await runAction(() => sitesDomainAddCommand(hostname, {
3686
5157
  site: opts.site,
3687
5158
  zoneId: opts.zoneId,
@@ -3692,7 +5163,7 @@ sitesDomain.command("add <hostname>").description("Attach a custom hostname (CF
3692
5163
  sitesDomain.command("list <site>").description("List custom hostnames attached to a site").action(async (site) => {
3693
5164
  await runAction(() => sitesDomainListCommand(site));
3694
5165
  });
3695
- sitesDomain.command("remove <hostname>").description("Detach a custom hostname (best-effort CF detach + control-plane row delete)").requiredOption("--site <name>", "Site name the hostname is attached to").option("--zone-id <id>", "CF zone id (default: env CLOUDFLARE_AMBA_HOST_ZONE_ID)").action(async (hostname, opts) => {
5166
+ sitesDomain.command("remove <hostname>").description("Detach a custom hostname (best-effort edge detach + control-plane row delete)").requiredOption("--site <name>", "Site name the hostname is attached to").option("--zone-id <id>", "DNS zone id (default: env AMBA_DNS_ZONE_ID)").action(async (hostname, opts) => {
3696
5167
  await runAction(() => sitesDomainRemoveCommand(hostname, {
3697
5168
  site: opts.site,
3698
5169
  zoneId: opts.zoneId