@layers/amba 1.0.0 → 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/README.md +45 -22
- package/dist/api-client.d.ts +16 -23
- package/dist/commands/functions.d.ts +28 -3
- package/dist/commands/init.d.ts +69 -0
- package/dist/index.js +1563 -92
- package/dist/project-config.d.ts +11 -0
- package/dist/sandbox.d.ts +311 -0
- package/dist/skills.d.ts +75 -0
- package/package.json +9 -7
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 {
|
|
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 `
|
|
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
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
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}
|
|
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.
|
|
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:
|
|
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
|
|
2489
|
-
*
|
|
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(
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
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.
|
|
3242
|
-
* pre-flight at 100 MiB total so the developer sees an
|
|
3243
|
-
* before we spend their time on a multi-second upload.
|
|
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
|
|
4714
|
+
console.log(pc.yellow(" ! Dry run — skipping upload + control-plane write."));
|
|
3268
4715
|
console.log();
|
|
3269
4716
|
return;
|
|
3270
4717
|
}
|
|
3271
|
-
let
|
|
4718
|
+
let slug;
|
|
3272
4719
|
try {
|
|
3273
|
-
|
|
3274
|
-
console.log(pc.green(" ✓") + ` Registered site (
|
|
4720
|
+
slug = (await createSite(projectId, { name: siteName })).data.slug;
|
|
4721
|
+
console.log(pc.green(" ✓") + ` Registered site (slug=${slug})`);
|
|
3275
4722
|
} catch (err) {
|
|
3276
|
-
|
|
3277
|
-
console.log(pc.dim(` Site already registered (
|
|
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
|
|
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(`
|
|
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
|
|
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
|
|
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},
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|