@layers/amba 1.0.1 → 4.0.2
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 +49 -29
- package/dist/auth.d.ts +8 -0
- package/dist/bundle.d.ts +23 -9
- package/dist/commands/billing.d.ts +34 -0
- package/dist/commands/claim.d.ts +41 -0
- package/dist/commands/functions.d.ts +28 -3
- package/dist/commands/init.d.ts +69 -0
- package/dist/commands/projects.d.ts +8 -0
- package/dist/credentials.d.ts +259 -0
- package/dist/index.js +3657 -425
- package/dist/project-config.d.ts +11 -0
- package/dist/sandbox.d.ts +309 -0
- package/dist/skill-installer.d.ts +95 -0
- package/dist/skills/presets.d.ts +146 -0
- package/dist/skills.d.ts +266 -0
- package/package.json +7 -2
- package/skill-bundle/SKILL.md +324 -0
- package/skill-bundle/references/economy.md +331 -0
- package/skill-bundle/references/engagement.md +400 -0
- package/skill-bundle/references/gamification.md +316 -0
- package/skill-bundle/references/identity.md +395 -0
- package/skill-bundle/references/infrastructure.md +348 -0
- package/skill-bundle/references/social.md +366 -0
package/dist/index.js
CHANGED
|
@@ -1,15 +1,55 @@
|
|
|
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, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
6
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
|
-
import { createInterface } from "node:readline";
|
|
7
7
|
import { createServer } from "node:http";
|
|
8
|
-
import { homedir } from "node:os";
|
|
9
8
|
import open from "open";
|
|
10
|
-
import {
|
|
9
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
import { createInterface } from "node:readline";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { createWriteStream, watch } from "node:fs";
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
11
14
|
import { build } from "esbuild";
|
|
12
|
-
|
|
15
|
+
//#region ../shared/dist/index.js
|
|
16
|
+
/**
|
|
17
|
+
* Shared email-shape validation.
|
|
18
|
+
*
|
|
19
|
+
* Lives in `@layers/amba-shared` so the API (validates inbound emails
|
|
20
|
+
* server-side before minting tokens / writing DB rows) and the CLI
|
|
21
|
+
* (validates locally before round-tripping `amba claim <email>`) share
|
|
22
|
+
* one canonical implementation. Previous drift between the two
|
|
23
|
+
* implementations caused a real BugBot finding: a >320-char email
|
|
24
|
+
* passed the CLI's loose regex but bounced server-side with
|
|
25
|
+
* `INVALID_INPUT` — confusing UX.
|
|
26
|
+
*
|
|
27
|
+
* Intentionally permissive: validates the structural shape ("no
|
|
28
|
+
* whitespace, has an `@`, has a dot in the domain part, ≤320 chars")
|
|
29
|
+
* rather than running the full RFC-5321 grammar. Most callers
|
|
30
|
+
* (hosted dashboard, Expo app form, CLI prompts) already validate
|
|
31
|
+
* client-side; anything weirder than this check will bounce at the
|
|
32
|
+
* upstream email provider anyway.
|
|
33
|
+
*
|
|
34
|
+
* The check exists so a 400 INVALID_INPUT surfaces before we mint a
|
|
35
|
+
* token / write a DB row / fire an outbound provider request — that's
|
|
36
|
+
* the load-bearing property, not "exactly RFC-compliant."
|
|
37
|
+
*
|
|
38
|
+
* The 320-char cap matches RFC 5321 §4.5.3.1.3 (path length, which
|
|
39
|
+
* includes the email + envelope wrappers). It's the conventional
|
|
40
|
+
* upper bound most validators converge on.
|
|
41
|
+
*/
|
|
42
|
+
function isPlausibleEmail(value) {
|
|
43
|
+
if (typeof value !== "string") return false;
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
if (trimmed.length === 0 || trimmed.length > 320) return false;
|
|
46
|
+
const at = trimmed.indexOf("@");
|
|
47
|
+
if (at <= 0 || at === trimmed.length - 1) return false;
|
|
48
|
+
if (/\s/.test(trimmed)) return false;
|
|
49
|
+
if (!trimmed.slice(at + 1).includes(".")) return false;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
13
53
|
//#region src/_internal/shared.ts
|
|
14
54
|
const DEFAULT_API_URL = "https://api.amba.dev";
|
|
15
55
|
const CONSOLE_URL = "https://app.amba.dev";
|
|
@@ -73,11 +113,7 @@ function getReservationReason(name) {
|
|
|
73
113
|
return null;
|
|
74
114
|
}
|
|
75
115
|
const RESERVED_BINDING_PREFIXES = ["AMBA_", "EDGE_"];
|
|
76
|
-
const RESERVED_BINDING_EXACT_NAMES = [
|
|
77
|
-
"STORAGE",
|
|
78
|
-
"HYPERDRIVE",
|
|
79
|
-
"EDGE_DB_PROXY"
|
|
80
|
-
];
|
|
116
|
+
const RESERVED_BINDING_EXACT_NAMES = ["STORAGE", "EDGE_DB_PROXY"];
|
|
81
117
|
const VALID_BINDING_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
82
118
|
const MAX_BINDING_NAME_LENGTH = 64;
|
|
83
119
|
/** Return why a binding name is reserved/invalid, or `null` if acceptable. */
|
|
@@ -249,6 +285,16 @@ function setBearerOverride(token) {
|
|
|
249
285
|
bearerOverride = token === null || token.length === 0 ? null : token;
|
|
250
286
|
}
|
|
251
287
|
/**
|
|
288
|
+
* Read the current bearer override without consuming it. Returns
|
|
289
|
+
* `null` when no override is set. Used by commands that need to know
|
|
290
|
+
* "did the operator supply a PAT for this invocation?" — e.g. `init`
|
|
291
|
+
* branches on whether to bypass stored-creds and use the supplied
|
|
292
|
+
* token verbatim.
|
|
293
|
+
*/
|
|
294
|
+
function getBearerOverride() {
|
|
295
|
+
return bearerOverride;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
252
298
|
* Resolve the bearer token to send on the next admin API call.
|
|
253
299
|
*
|
|
254
300
|
* Returns the override (PAT or JWT supplied via flag/env) when set,
|
|
@@ -355,6 +401,14 @@ async function listProjects() {
|
|
|
355
401
|
async function createProject(input) {
|
|
356
402
|
return request("POST", "/projects", input);
|
|
357
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* PATCH /admin/projects/:projectId — update mutable fields. Server
|
|
406
|
+
* silently ignores unknown keys; we filter to the documented allow-list
|
|
407
|
+
* before sending so a typo at the CLI doesn't pass the wire silently.
|
|
408
|
+
*/
|
|
409
|
+
async function updateProject(projectId, patch) {
|
|
410
|
+
return request("PATCH", `/projects/${projectId}`, patch);
|
|
411
|
+
}
|
|
358
412
|
async function getProject(projectId) {
|
|
359
413
|
return request("GET", `/projects/${projectId}`);
|
|
360
414
|
}
|
|
@@ -559,7 +613,7 @@ async function updateSite(projectId, name, patch) {
|
|
|
559
613
|
}
|
|
560
614
|
/**
|
|
561
615
|
* Add a custom domain to a site. The server-side proxy registers the
|
|
562
|
-
* custom hostname, persists the resulting `
|
|
616
|
+
* custom hostname, persists the resulting `provider_hostname_id`, and returns
|
|
563
617
|
* the CNAME target the customer should point their DNS at.
|
|
564
618
|
*/
|
|
565
619
|
async function addSiteDomainViaApi(projectId, siteName, hostname) {
|
|
@@ -574,10 +628,10 @@ async function removeSiteDomainViaApi(projectId, siteName, hostname) {
|
|
|
574
628
|
return request("DELETE", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/domains/${encodeURIComponent(hostname)}`);
|
|
575
629
|
}
|
|
576
630
|
/**
|
|
577
|
-
* Roll a live
|
|
578
|
-
*
|
|
579
|
-
*
|
|
580
|
-
*
|
|
631
|
+
* Roll a live deployment back to a prior `deployment_id`. Rollback creates
|
|
632
|
+
* a NEW deployment that serves the prior bundle (git-revert semantics,
|
|
633
|
+
* not git-reset), so the response shape mirrors `DeploySiteResult` and
|
|
634
|
+
* the new `deployment_id` is what's now live.
|
|
581
635
|
*/
|
|
582
636
|
async function rollbackSiteViaApi(projectId, siteName, deploymentId) {
|
|
583
637
|
return request("POST", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/rollback`, { deployment_id: deploymentId });
|
|
@@ -624,191 +678,2425 @@ async function validateApiKey(apiKey) {
|
|
|
624
678
|
};
|
|
625
679
|
}
|
|
626
680
|
//#endregion
|
|
627
|
-
//#region src/
|
|
681
|
+
//#region src/sandbox.ts
|
|
628
682
|
/**
|
|
629
|
-
*
|
|
683
|
+
* Headless agentic sandbox bootstrap.
|
|
684
|
+
*
|
|
685
|
+
* Implements `amba init --sandbox`: the zero-question, no-browser path
|
|
686
|
+
* an AI coding agent runs when a developer pastes the homepage prompt:
|
|
687
|
+
*
|
|
688
|
+
* Run `npx @layers/amba init --sandbox` and follow the
|
|
689
|
+
* instructions it prints.
|
|
690
|
+
*
|
|
691
|
+
* The CLI does everything: synthesize an anonymous email + password,
|
|
692
|
+
* sign the developer up via the public `/v1/auth/developer/signup`
|
|
693
|
+
* endpoint (no Bearer needed), pluck the returned PAT + project
|
|
694
|
+
* credentials, and write them into:
|
|
695
|
+
*
|
|
696
|
+
* - `~/.amba/credentials.json` (chmod 0600)
|
|
697
|
+
* - `<cwd>/.env.local` (.gitignored — SDK reads it)
|
|
698
|
+
* - `<cwd>/AMBA.md` (markdown context for the agent)
|
|
699
|
+
* - every detected MCP client config (`~/.claude.json`,
|
|
700
|
+
* `~/.cursor/mcp.json`, `~/.codeium/windsurf/mcp_config.json`,
|
|
701
|
+
* plus their project-local equivalents WHEN already present —
|
|
702
|
+
* never created from scratch, to avoid cluttering repos)
|
|
703
|
+
*
|
|
704
|
+
* The MCP-config merge is non-destructive: an existing `mcpServers.amba`
|
|
705
|
+
* entry is replaced with the new PAT, but the prior file is copied
|
|
706
|
+
* aside to `<path>.bak-<unix-ms>` first so a developer who had a real
|
|
707
|
+
* production PAT wired in can recover. Every other server entry is
|
|
708
|
+
* preserved. JSON files that already exist but lack an `mcpServers`
|
|
709
|
+
* key gain one; missing files for the global locations get scaffolded
|
|
710
|
+
* with a minimal `{ "mcpServers": { "amba": … }}`.
|
|
711
|
+
*
|
|
712
|
+
* Similarly, a pre-existing `~/.amba/credentials.json` whose `source`
|
|
713
|
+
* is not `'sandbox-init'` is backed up to `credentials.json.bak-<ms>`
|
|
714
|
+
* before the sandbox PAT replaces it. Idempotent re-runs from our own
|
|
715
|
+
* sandbox session do NOT trigger a backup.
|
|
716
|
+
*
|
|
717
|
+
* Provisioning polling: deliberately skipped. The server returns the
|
|
718
|
+
* project row with `provisioning_status: 'provisioning'` immediately and
|
|
719
|
+
* the workflow flips it to `'active'` within ~5s. The agent's next SDK
|
|
720
|
+
* call may briefly retry — fine. Blocking the CLI here would just hide
|
|
721
|
+
* the same wait behind a different progress indicator.
|
|
630
722
|
*/
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
return <Slot />;
|
|
723
|
+
/**
|
|
724
|
+
* Generate a deterministic-looking but globally-unique sandbox email.
|
|
725
|
+
*
|
|
726
|
+
* Pattern: `sandbox-<epoch>-<6char>@layers.com`.
|
|
727
|
+
*
|
|
728
|
+
* The control DB's `developers` table has a UNIQUE(email) constraint and
|
|
729
|
+
* a 5-per-minute / 50-per-day per-IP rate limit on signup. Embedding the
|
|
730
|
+
* epoch + a 6-char nonce keeps the collision probability negligible even
|
|
731
|
+
* across a herd of CI agents all running `amba init --sandbox` from the
|
|
732
|
+
* same VPC.
|
|
733
|
+
*
|
|
734
|
+
* We use `@layers.com` (not the customer's own domain) because the
|
|
735
|
+
* sandbox tier is pre-verification — the developer never receives or
|
|
736
|
+
* actions a verification email for this address. When they want to
|
|
737
|
+
* upgrade, the CLI prints the verify URL the API returned so they can
|
|
738
|
+
* claim a real email in the console.
|
|
739
|
+
*/
|
|
740
|
+
function generateSandboxEmail() {
|
|
741
|
+
return `sandbox-${Math.floor(Date.now() / 1e3)}-${randomBytes(4).toString("base64url").slice(0, 6).toLowerCase()}@layers.com`;
|
|
651
742
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
743
|
+
/**
|
|
744
|
+
* Random URL-safe password. 24 raw bytes → 32 base64url chars; well over
|
|
745
|
+
* the 8-char minimum the API enforces, with ~192 bits of entropy.
|
|
746
|
+
*
|
|
747
|
+
* The password is never shown to the developer or written anywhere — the
|
|
748
|
+
* PAT is what gets stored. We generate it solely because `POST /signup`
|
|
749
|
+
* requires it (and demands a non-empty value); a future API change could
|
|
750
|
+
* accept "agent signup" with no password and we'd drop this entirely.
|
|
751
|
+
*/
|
|
752
|
+
function generateSandboxPassword() {
|
|
753
|
+
return randomBytes(24).toString("base64url");
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* POST the synthesized credentials at the public signup endpoint.
|
|
757
|
+
*
|
|
758
|
+
* No Bearer auth — this is the bootstrap call that mints one. We use
|
|
759
|
+
* `fetch` directly (not the api-client wrapper) because that wrapper
|
|
760
|
+
* always resolves a bearer token first, which is exactly what we don't
|
|
761
|
+
* have yet.
|
|
762
|
+
*
|
|
763
|
+
* Returns the unwrapped, flattened shape consumed by the rest of the
|
|
764
|
+
* sandbox flow. Throws with a human-readable message on any non-2xx so
|
|
765
|
+
* the CLI's `runAction` wrapper can surface it without crashing on a
|
|
766
|
+
* generic 'fetch failed'.
|
|
767
|
+
*/
|
|
768
|
+
async function performSandboxSignup(req, options = {}) {
|
|
769
|
+
const apiUrl = options.apiUrl ?? process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
|
|
770
|
+
const res = await (options.fetchImpl ?? fetch)(`${apiUrl}/v1/auth/developer/signup`, {
|
|
771
|
+
method: "POST",
|
|
772
|
+
headers: {
|
|
773
|
+
"Content-Type": "application/json",
|
|
774
|
+
"User-Agent": "amba-cli/sandbox"
|
|
775
|
+
},
|
|
776
|
+
body: JSON.stringify({
|
|
777
|
+
email: req.email,
|
|
778
|
+
password: req.password,
|
|
779
|
+
name: req.name ?? "amba-sandbox-cli"
|
|
780
|
+
})
|
|
781
|
+
});
|
|
782
|
+
if (!res.ok) {
|
|
783
|
+
let detail = `${res.status} ${res.statusText}`;
|
|
784
|
+
try {
|
|
785
|
+
const body = await res.json();
|
|
786
|
+
if (body.error?.message) detail = body.error.message;
|
|
787
|
+
} catch {}
|
|
788
|
+
throw new Error(`Sandbox signup failed: ${detail}`);
|
|
789
|
+
}
|
|
790
|
+
let raw;
|
|
791
|
+
try {
|
|
792
|
+
raw = await res.json();
|
|
793
|
+
} catch (parseErr) {
|
|
794
|
+
const reason = parseErr instanceof Error ? parseErr.message : String(parseErr);
|
|
795
|
+
throw new Error(`Sandbox signup returned ${res.status} but the response body was not valid JSON: ${reason}`);
|
|
796
|
+
}
|
|
797
|
+
const data = raw.data;
|
|
798
|
+
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)");
|
|
799
|
+
return {
|
|
800
|
+
pat: data.pat,
|
|
801
|
+
project_id: data.project.project_id,
|
|
802
|
+
client_key: data.project.client_key,
|
|
803
|
+
server_key: data.project.server_key,
|
|
804
|
+
api_url: apiUrl,
|
|
805
|
+
provisioning_status: data.project.provisioning_status,
|
|
806
|
+
verify_url: data.project.verify_url,
|
|
807
|
+
email: req.email,
|
|
808
|
+
developer_id: data.developer?.id ?? "",
|
|
809
|
+
developer_name: data.developer?.name
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Write or update `<cwd>/.env.local` with the sandbox project's keys.
|
|
814
|
+
*
|
|
815
|
+
* Mirrors the `init` interactive flow exactly so the existing env-read
|
|
816
|
+
* conventions in the SDKs and CLI commands keep working. The merge
|
|
817
|
+
* logic: if the file already exists and contains an `AMBA_PROJECT_ID`
|
|
818
|
+
* line we replace the Amba lines in place; otherwise we append a
|
|
819
|
+
* fresh stanza.
|
|
820
|
+
*
|
|
821
|
+
* `serverKey` is optional — pass it on the new two-scope credential
|
|
822
|
+
* model where init mints both client+server. Pre-existing AMBA_SERVER_KEY
|
|
823
|
+
* lines are refreshed when a new value is provided and removed when
|
|
824
|
+
* serverKey is null AND no prior line existed (no-op on second case).
|
|
825
|
+
*/
|
|
826
|
+
async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl, serverKey) {
|
|
827
|
+
const envPath = join(cwd, ".env.local");
|
|
828
|
+
const stanzaLines = [
|
|
829
|
+
"# Amba SDK configuration (sandbox tier)",
|
|
830
|
+
`AMBA_PROJECT_ID=${projectId}`,
|
|
831
|
+
`AMBA_CLIENT_KEY=${clientKey}`
|
|
832
|
+
];
|
|
833
|
+
if (serverKey) stanzaLines.push(`AMBA_SERVER_KEY=${serverKey}`);
|
|
834
|
+
stanzaLines.push(`AMBA_API_URL=${apiUrl}`);
|
|
835
|
+
stanzaLines.push("");
|
|
836
|
+
const stanza = stanzaLines.join("\n");
|
|
837
|
+
let existing = "";
|
|
838
|
+
try {
|
|
839
|
+
existing = await readFile(envPath, "utf-8");
|
|
840
|
+
} catch (err) {
|
|
841
|
+
if (!isEnoent$1(err)) throw err;
|
|
842
|
+
}
|
|
843
|
+
if (existing.length === 0) {
|
|
844
|
+
await writeFile(envPath, stanza, "utf-8");
|
|
845
|
+
return envPath;
|
|
846
|
+
}
|
|
847
|
+
if (/^AMBA_PROJECT_ID=/m.test(existing)) {
|
|
848
|
+
let updated = existing;
|
|
849
|
+
updated = updated.replace(/^AMBA_PROJECT_ID=.*/m, () => `AMBA_PROJECT_ID=${projectId}`);
|
|
850
|
+
updated = updated.replace(/^AMBA_API_URL=.*/m, () => `AMBA_API_URL=${apiUrl}`);
|
|
851
|
+
const hadClientKey = /^AMBA_CLIENT_KEY=/m.test(updated);
|
|
852
|
+
const hadApiKey = /^AMBA_API_KEY=/m.test(updated);
|
|
853
|
+
if (hadClientKey && hadApiKey) {
|
|
854
|
+
updated = updated.replace(/^AMBA_CLIENT_KEY=.*\n?/m, "");
|
|
855
|
+
updated = updated.replace(/^AMBA_API_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
|
|
856
|
+
} else if (hadApiKey) updated = updated.replace(/^AMBA_API_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
|
|
857
|
+
else if (hadClientKey) updated = updated.replace(/^AMBA_CLIENT_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
|
|
858
|
+
else updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_CLIENT_KEY=${clientKey}\n`;
|
|
859
|
+
if (serverKey) if (/^AMBA_SERVER_KEY=/m.test(updated)) updated = updated.replace(/^AMBA_SERVER_KEY=.*/m, () => `AMBA_SERVER_KEY=${serverKey}`);
|
|
860
|
+
else {
|
|
861
|
+
const clientKeyMatch = updated.match(/^AMBA_CLIENT_KEY=.*\n?/m);
|
|
862
|
+
if (clientKeyMatch) {
|
|
863
|
+
const insertAt = (clientKeyMatch.index ?? 0) + clientKeyMatch[0].length;
|
|
864
|
+
updated = updated.slice(0, insertAt) + `AMBA_SERVER_KEY=${serverKey}\n` + updated.slice(insertAt);
|
|
865
|
+
} else updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_SERVER_KEY=${serverKey}\n`;
|
|
866
|
+
}
|
|
867
|
+
if (!/^AMBA_API_URL=/m.test(updated)) updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_API_URL=${apiUrl}\n`;
|
|
868
|
+
await writeFile(envPath, updated, "utf-8");
|
|
869
|
+
return envPath;
|
|
870
|
+
}
|
|
871
|
+
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
872
|
+
await writeFile(envPath, existing + separator + stanza, "utf-8");
|
|
873
|
+
return envPath;
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Write the AMBA.md sandbox-tier guide to `<cwd>/AMBA.md`.
|
|
877
|
+
*
|
|
878
|
+
* Always overwrites — the file is meant to be regenerated, and the
|
|
879
|
+
* interactive `init` flow's longer AMBA.md template is replaced here
|
|
880
|
+
* with a sandbox-specific shorter one (with upgrade instructions).
|
|
881
|
+
*/
|
|
882
|
+
async function writeSandboxAmbaMd(cwd, ctx) {
|
|
883
|
+
const ambaMdPath = join(cwd, "AMBA.md");
|
|
884
|
+
await writeFile(ambaMdPath, sandboxAmbaMdContent(ctx), "utf-8");
|
|
885
|
+
return ambaMdPath;
|
|
886
|
+
}
|
|
887
|
+
function sandboxAmbaMdContent(ctx) {
|
|
888
|
+
return `# Amba (Sandbox tier)
|
|
658
889
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
await Amba.events.track('lesson_completed', { lesson_id: '123' });
|
|
890
|
+
This project was provisioned by \`amba init --sandbox\`. The sandbox is
|
|
891
|
+
real (real database, real API, real MCP) but capped so you can try Amba
|
|
892
|
+
without a credit card or email verification.
|
|
663
893
|
|
|
664
|
-
|
|
665
|
-
await Amba.signInWithApple();
|
|
894
|
+
## What was provisioned
|
|
666
895
|
|
|
667
|
-
|
|
668
|
-
|
|
896
|
+
| Field | Value |
|
|
897
|
+
| --- | --- |
|
|
898
|
+
| Project ID | \`${ctx.projectId}\` |
|
|
899
|
+
| Email | \`${ctx.email}\` |
|
|
900
|
+
| API URL | \`${ctx.apiUrl}\` |
|
|
901
|
+
| SDK | \`${ctx.sdkPackage}\` |
|
|
902
|
+
| Framework | ${ctx.framework} |
|
|
669
903
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
904
|
+
The credentials live in **\`.env.local\`** (\`AMBA_PROJECT_ID\`,
|
|
905
|
+
\`AMBA_CLIENT_KEY\`, \`AMBA_API_URL\`) — gitignored by convention. Your
|
|
906
|
+
Personal Access Token is stored in \`~/.amba/credentials.json\`.
|
|
673
907
|
|
|
674
|
-
|
|
675
|
-
}
|
|
676
|
-
\`\`\`` : `
|
|
677
|
-
### Client Setup
|
|
908
|
+
## SDK quickstart
|
|
678
909
|
|
|
679
|
-
\`\`\`
|
|
680
|
-
import { Amba } from '${sdkPackage}';
|
|
910
|
+
\`\`\`ts
|
|
911
|
+
import { Amba } from '${ctx.sdkPackage}';
|
|
681
912
|
|
|
682
|
-
|
|
913
|
+
Amba.configure({
|
|
683
914
|
projectId: process.env.AMBA_PROJECT_ID!,
|
|
684
|
-
|
|
915
|
+
clientKey: process.env.AMBA_CLIENT_KEY!,
|
|
685
916
|
});
|
|
686
917
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
// Read remote config
|
|
691
|
-
const config = await Amba.config.fetch();
|
|
692
|
-
|
|
693
|
-
// Email sign-in
|
|
694
|
-
await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
|
|
695
|
-
\`\`\``;
|
|
696
|
-
return `# Amba Project Context
|
|
697
|
-
|
|
698
|
-
> This file provides context about the Amba integration for AI coding agents.
|
|
699
|
-
|
|
700
|
-
## Project Info
|
|
701
|
-
|
|
702
|
-
| Key | Value |
|
|
703
|
-
|-----|-------|
|
|
704
|
-
| Project ID | \`${opts.projectId}\` |
|
|
705
|
-
| Project Name | ${opts.projectName} |
|
|
706
|
-
| Framework | ${opts.framework} |
|
|
707
|
-
| SDK | \`${sdkPackage}\` |
|
|
708
|
-
|
|
709
|
-
## Environment Variables
|
|
710
|
-
|
|
711
|
-
These are configured in \`.env.local\`:
|
|
712
|
-
|
|
713
|
-
- \`AMBA_PROJECT_ID\` — Your project identifier
|
|
714
|
-
- \`AMBA_API_KEY\` — Client API key (safe for client-side use)
|
|
715
|
-
- \`AMBA_API_URL\` — API endpoint (defaults to https://api.amba.dev)
|
|
716
|
-
|
|
717
|
-
## SDK Usage
|
|
718
|
-
${providerExample}
|
|
918
|
+
await Amba.events.track('app_opened');
|
|
919
|
+
\`\`\`
|
|
719
920
|
|
|
720
|
-
##
|
|
921
|
+
## Sandbox limits
|
|
721
922
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
- **Entitlements** — Subscription status via RevenueCat integration
|
|
728
|
-
- **Analytics** — DAU, MAU, retention, and custom event tracking
|
|
923
|
+
| Limit | Sandbox | Free (after verification) |
|
|
924
|
+
| --- | --- | --- |
|
|
925
|
+
| Monthly active users | 100 | 1,000 |
|
|
926
|
+
| Database size | 10 MB | 500 MB |
|
|
927
|
+
| Push notifications / mo | 1,000 | 10,000 |
|
|
729
928
|
|
|
730
|
-
##
|
|
929
|
+
## Upgrade past sandbox
|
|
731
930
|
|
|
732
|
-
-
|
|
733
|
-
|
|
734
|
-
-
|
|
931
|
+
This account uses an auto-generated email (\`${ctx.email}\`). Run
|
|
932
|
+
\`amba claim me@example.com\` to bind it to a real address — you'll get
|
|
933
|
+
a one-click link in your inbox that promotes the project to the Free
|
|
934
|
+
tier (1,000 MAU, 500 MB DB) and lets you sign in from a browser if you
|
|
935
|
+
ever need to.
|
|
735
936
|
|
|
736
|
-
##
|
|
937
|
+
## Useful commands
|
|
737
938
|
|
|
738
939
|
\`\`\`bash
|
|
739
|
-
amba status
|
|
740
|
-
amba
|
|
741
|
-
amba
|
|
742
|
-
amba config set <key> <value> # Set a config value
|
|
940
|
+
amba status # health check
|
|
941
|
+
amba projects list # list your sandbox project
|
|
942
|
+
amba seed --preset=starter # populate sample data
|
|
743
943
|
\`\`\`
|
|
944
|
+
|
|
945
|
+
Docs: https://docs.amba.dev
|
|
744
946
|
`;
|
|
745
947
|
}
|
|
746
948
|
/**
|
|
747
|
-
*
|
|
949
|
+
* The canonical Amba MCP entry. Used as the value of
|
|
950
|
+
* `mcpServers.amba` in every detected client config.
|
|
951
|
+
*
|
|
952
|
+
* Shape note: Claude Code, Cursor, and Windsurf all read the same
|
|
953
|
+
* `type` + `url` + `headers` triple. (Windsurf historically also
|
|
954
|
+
* accepted `serverUrl` — we emit `url` to match the modern shape it
|
|
955
|
+
* also accepts, and skip emitting the legacy alias to keep the JSON
|
|
956
|
+
* minimal.)
|
|
748
957
|
*/
|
|
749
|
-
function
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
958
|
+
function buildAmbaMcpEntry(pat) {
|
|
959
|
+
return {
|
|
960
|
+
type: "http",
|
|
961
|
+
url: "https://mcp.amba.dev/mcp",
|
|
962
|
+
headers: { Authorization: `Bearer ${pat}` }
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* The list of MCP client config files we probe. Order matters only for
|
|
967
|
+
* the printed report.
|
|
968
|
+
*
|
|
969
|
+
* Project-local entries are listed but only get touched when the file
|
|
970
|
+
* already exists in CWD — we don't want to scatter `.mcp.json` /
|
|
971
|
+
* `.cursor/mcp.json` files into random user repos that have never been
|
|
972
|
+
* MCP-configured.
|
|
973
|
+
*/
|
|
974
|
+
function mcpClientTargets(cwd, options = {}) {
|
|
975
|
+
const home = options.homeDir ?? homedir();
|
|
976
|
+
return [
|
|
977
|
+
{
|
|
978
|
+
label: "Claude Code (global)",
|
|
979
|
+
path: join(home, ".claude.json"),
|
|
980
|
+
scaffoldIfMissing: true
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
label: "Claude Code (project)",
|
|
984
|
+
path: join(cwd, ".mcp.json"),
|
|
985
|
+
scaffoldIfMissing: false
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
label: "Cursor (global)",
|
|
989
|
+
path: join(home, ".cursor", "mcp.json"),
|
|
990
|
+
scaffoldIfMissing: true
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
label: "Cursor (project)",
|
|
994
|
+
path: join(cwd, ".cursor", "mcp.json"),
|
|
995
|
+
scaffoldIfMissing: false
|
|
996
|
+
},
|
|
997
|
+
{
|
|
998
|
+
label: "Windsurf",
|
|
999
|
+
path: join(home, ".codeium", "windsurf", "mcp_config.json"),
|
|
1000
|
+
scaffoldIfMissing: true
|
|
1001
|
+
}
|
|
1002
|
+
];
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Merge the `amba` entry into a single client config file.
|
|
1006
|
+
*
|
|
1007
|
+
* Strategy:
|
|
1008
|
+
* - If the file exists, load + JSON-parse. If parse fails, throw with
|
|
1009
|
+
* a clear "we won't clobber malformed JSON" error.
|
|
1010
|
+
* - If the file doesn't exist and scaffolding is allowed, create the
|
|
1011
|
+
* parent dir and write `{ "mcpServers": { "amba": ... } }`.
|
|
1012
|
+
* - In all cases, `mcpServers.amba` ends up set; every other
|
|
1013
|
+
* `mcpServers.*` entry is preserved.
|
|
1014
|
+
*
|
|
1015
|
+
* Real-credential safety: if the existing file ALREADY has a
|
|
1016
|
+
* `mcpServers.amba` entry, we copy the whole file aside to
|
|
1017
|
+
* `<path>.bak-<unix-ms>` BEFORE merging. This protects a developer who
|
|
1018
|
+
* had a real production PAT wired in and then ran `amba init --sandbox`
|
|
1019
|
+
* to "try" the flow. Idempotent re-runs from the same sandbox session
|
|
1020
|
+
* still trigger a backup — cheap, and the developer can `rm *.bak-*`
|
|
1021
|
+
* any time.
|
|
1022
|
+
*/
|
|
1023
|
+
async function mergeMcpConfigFile(target, pat) {
|
|
1024
|
+
let existing = null;
|
|
1025
|
+
let rawExisting = null;
|
|
1026
|
+
try {
|
|
1027
|
+
rawExisting = await readFile(target.path, "utf-8");
|
|
1028
|
+
const parsed = JSON.parse(rawExisting);
|
|
1029
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) existing = parsed;
|
|
1030
|
+
else throw new Error(`Existing config at ${target.path} is not a JSON object`);
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
if (isEnoent$1(err)) {
|
|
1033
|
+
if (!target.scaffoldIfMissing) return {
|
|
1034
|
+
path: null,
|
|
1035
|
+
backedUpTo: null
|
|
1036
|
+
};
|
|
1037
|
+
existing = {};
|
|
1038
|
+
} 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\`.`);
|
|
1039
|
+
else throw err;
|
|
1040
|
+
}
|
|
1041
|
+
const config = existing ?? {};
|
|
1042
|
+
const serversRaw = config["mcpServers"];
|
|
1043
|
+
const servers = typeof serversRaw === "object" && serversRaw !== null && !Array.isArray(serversRaw) ? serversRaw : {};
|
|
1044
|
+
let backedUpTo = null;
|
|
1045
|
+
if ("amba" in servers && rawExisting !== null) {
|
|
1046
|
+
backedUpTo = `${target.path}.bak-${Date.now()}`;
|
|
1047
|
+
await writeFile(backedUpTo, rawExisting, "utf-8");
|
|
1048
|
+
}
|
|
1049
|
+
servers["amba"] = buildAmbaMcpEntry(pat);
|
|
1050
|
+
config["mcpServers"] = servers;
|
|
1051
|
+
await mkdir(dirname(target.path), { recursive: true });
|
|
1052
|
+
await writeFile(target.path, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
1053
|
+
return {
|
|
1054
|
+
path: target.path,
|
|
1055
|
+
backedUpTo
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Probe every known MCP client location and merge our entry into the
|
|
1060
|
+
* ones that exist (or that are flagged scaffoldIfMissing). Returns one
|
|
1061
|
+
* record per touched file (skipped files are omitted).
|
|
1062
|
+
*
|
|
1063
|
+
* `warn` is invoked (instead of `console.warn`) for per-target errors
|
|
1064
|
+
* so callers using `--json` can route those notices to stderr and keep
|
|
1065
|
+
* stdout machine-parseable.
|
|
1066
|
+
*/
|
|
1067
|
+
async function writeAllMcpConfigs(cwd, pat, options = {}) {
|
|
1068
|
+
const warn = options.warn ?? ((msg) => console.warn(msg));
|
|
1069
|
+
const written = [];
|
|
1070
|
+
const targets = mcpClientTargets(cwd, options);
|
|
1071
|
+
for (const target of targets) {
|
|
1072
|
+
let result = {
|
|
1073
|
+
path: null,
|
|
1074
|
+
backedUpTo: null
|
|
1075
|
+
};
|
|
1076
|
+
try {
|
|
1077
|
+
if (!target.scaffoldIfMissing) {
|
|
1078
|
+
if (!await fileExists$1(target.path)) continue;
|
|
1079
|
+
}
|
|
1080
|
+
result = await mergeMcpConfigFile(target, pat);
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1083
|
+
warn(` ! Skipped ${target.label}: ${message}`);
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
if (result.path) written.push({
|
|
1087
|
+
path: result.path,
|
|
1088
|
+
backedUpTo: result.backedUpTo
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
return written;
|
|
1092
|
+
}
|
|
1093
|
+
async function fileExists$1(path) {
|
|
1094
|
+
try {
|
|
1095
|
+
await access(path);
|
|
1096
|
+
return true;
|
|
1097
|
+
} catch {
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
function isEnoent$1(err) {
|
|
1102
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Render the canonical Amba MCP snippet for clients we can't auto-wire
|
|
1106
|
+
* (anything outside the {Claude Code, Cursor, Windsurf} set). Printed by
|
|
1107
|
+
* the CLI when no client configs are detected so the developer at least
|
|
1108
|
+
* has a paste-ready JSON blob.
|
|
1109
|
+
*/
|
|
1110
|
+
function formatManualMcpSnippet(pat) {
|
|
1111
|
+
return JSON.stringify({ mcpServers: { amba: buildAmbaMcpEntry(pat) } }, null, 2);
|
|
1112
|
+
}
|
|
1113
|
+
//#endregion
|
|
1114
|
+
//#region src/credentials.ts
|
|
1115
|
+
/**
|
|
1116
|
+
* Two-scope credential model for `amba init`.
|
|
1117
|
+
*
|
|
1118
|
+
* Identity is **developer-scoped** (one machine identity, persisted in
|
|
1119
|
+
* `~/.amba/credentials.json`). State is **project-scoped** (one per
|
|
1120
|
+
* project directory, persisted in `<cwd>/.amba/project.json`).
|
|
1121
|
+
*
|
|
1122
|
+
* One Amba account can own N projects. Running `amba init` in five
|
|
1123
|
+
* different folders under one identity yields one developer row + five
|
|
1124
|
+
* project rows — exactly the model `apps/console` and the API enforce.
|
|
1125
|
+
*
|
|
1126
|
+
* Backward compatibility
|
|
1127
|
+
* ----------------------
|
|
1128
|
+
* The legacy `~/.amba/credentials.json` (browser-OAuth era) carried
|
|
1129
|
+
* `{ access_token, refresh_token, expires_at }`. We read both shapes —
|
|
1130
|
+
* a missing `version` key signals legacy and triggers a one-shot
|
|
1131
|
+
* in-place upgrade after the first successful `developer_me` verify.
|
|
1132
|
+
*
|
|
1133
|
+
* Idempotency
|
|
1134
|
+
* -----------
|
|
1135
|
+
* `ensureDeveloperIdentity` + `ensureProjectForCwd` are the two entry
|
|
1136
|
+
* points. Both are safe to call on every `amba init` run:
|
|
1137
|
+
* - identity: load → verify → upgrade-or-keep; only signs up if no
|
|
1138
|
+
* verified PAT exists anywhere.
|
|
1139
|
+
* - project: load `<cwd>/.amba/project.json` → verify the
|
|
1140
|
+
* `project_id` still belongs to the current developer; if missing
|
|
1141
|
+
* or stale, mint a new project under the dev's identity.
|
|
1142
|
+
*/
|
|
1143
|
+
function developerCredentialsPath(homeDir) {
|
|
1144
|
+
return join(homeDir ?? homedir(), ".amba", "credentials.json");
|
|
1145
|
+
}
|
|
1146
|
+
function projectCredentialsPath(cwd) {
|
|
1147
|
+
return join(cwd, ".amba", "project.json");
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Read `~/.amba/credentials.json`. Returns null when the file is
|
|
1151
|
+
* missing, malformed, or empty. Handles both new (versioned) and
|
|
1152
|
+
* legacy shapes — legacy returns `version: 1` after migration but
|
|
1153
|
+
* with `source: 'legacy'` so callers can tell.
|
|
1154
|
+
*
|
|
1155
|
+
* Does NOT verify the PAT against the API. Caller must follow up
|
|
1156
|
+
* with `verifyPat` before trusting the identity.
|
|
1157
|
+
*/
|
|
1158
|
+
async function loadDeveloperCredentials(options = {}) {
|
|
1159
|
+
const path = developerCredentialsPath(options.homeDir);
|
|
1160
|
+
let raw;
|
|
1161
|
+
try {
|
|
1162
|
+
raw = await readFile(path, "utf-8");
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
if (isEnoent(err)) return null;
|
|
1165
|
+
throw err;
|
|
1166
|
+
}
|
|
1167
|
+
if (raw.trim().length === 0) return null;
|
|
1168
|
+
let parsed;
|
|
1169
|
+
try {
|
|
1170
|
+
parsed = JSON.parse(raw);
|
|
1171
|
+
} catch {
|
|
1172
|
+
return null;
|
|
1173
|
+
}
|
|
1174
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
1175
|
+
const obj = parsed;
|
|
1176
|
+
if (obj["version"] === 1) {
|
|
1177
|
+
const v = obj;
|
|
1178
|
+
if (typeof v["pat"] !== "string" || v["pat"].length === 0) return null;
|
|
1179
|
+
return {
|
|
1180
|
+
version: 1,
|
|
1181
|
+
developer_id: typeof v["developer_id"] === "string" ? v["developer_id"] : null,
|
|
1182
|
+
email: typeof v["email"] === "string" ? v["email"] : "unknown",
|
|
1183
|
+
pat: v["pat"],
|
|
1184
|
+
api_url: typeof v["api_url"] === "string" ? v["api_url"] : DEFAULT_API_URL,
|
|
1185
|
+
source: normalizeSource(v["source"]),
|
|
1186
|
+
created_at: typeof v["created_at"] === "string" ? v["created_at"] : (/* @__PURE__ */ new Date()).toISOString(),
|
|
1187
|
+
access_token: v["pat"],
|
|
1188
|
+
refresh_token: "",
|
|
1189
|
+
expires_at: typeof v["expires_at"] === "string" ? v["expires_at"] : (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString()
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
const accessToken = obj["access_token"];
|
|
1193
|
+
if (typeof accessToken !== "string" || accessToken.length === 0) return null;
|
|
1194
|
+
const onDiskSource = normalizeSource(obj["source"]);
|
|
1195
|
+
return {
|
|
1196
|
+
version: 1,
|
|
1197
|
+
developer_id: null,
|
|
1198
|
+
email: "unknown",
|
|
1199
|
+
pat: accessToken,
|
|
1200
|
+
api_url: DEFAULT_API_URL,
|
|
1201
|
+
source: typeof obj["source"] === "string" ? onDiskSource : "legacy",
|
|
1202
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1203
|
+
access_token: accessToken,
|
|
1204
|
+
refresh_token: "",
|
|
1205
|
+
expires_at: typeof obj["expires_at"] === "string" ? obj["expires_at"] : (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString()
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
function normalizeSource(value) {
|
|
1209
|
+
if (value === "sandbox-init" || value === "browser-auth" || value === "manual" || value === "legacy") return value;
|
|
1210
|
+
return "manual";
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Atomically write developer credentials to `~/.amba/credentials.json`
|
|
1214
|
+
* with mode 0600. Writes to a sibling `.tmp` first and renames into
|
|
1215
|
+
* place so a crash mid-write doesn't leave the file empty.
|
|
1216
|
+
*
|
|
1217
|
+
* Backs up an existing file when its `source` is not one of the
|
|
1218
|
+
* managed sources OR when the existing PAT differs from the one being
|
|
1219
|
+
* written. The backup goes to `credentials.json.bak-<unix-ms>`.
|
|
1220
|
+
*/
|
|
1221
|
+
async function writeDeveloperCredentials(creds, options = {}) {
|
|
1222
|
+
const path = developerCredentialsPath(options.homeDir);
|
|
1223
|
+
await mkdir(join(options.homeDir ?? homedir(), ".amba"), { recursive: true });
|
|
1224
|
+
let backedUpTo = null;
|
|
1225
|
+
try {
|
|
1226
|
+
const existingRaw = await readFile(path, "utf-8");
|
|
1227
|
+
const existing = JSON.parse(existingRaw);
|
|
1228
|
+
const existingToken = typeof existing.pat === "string" && existing.pat.length > 0 ? existing.pat : typeof existing.access_token === "string" ? existing.access_token : "";
|
|
1229
|
+
if (existingToken.length > 0 && existingToken !== creds.pat) {
|
|
1230
|
+
backedUpTo = `${path}.bak-${Date.now()}`;
|
|
1231
|
+
await writeFile(backedUpTo, existingRaw, "utf-8");
|
|
1232
|
+
try {
|
|
1233
|
+
await chmod(backedUpTo, 384);
|
|
1234
|
+
} catch {}
|
|
1235
|
+
}
|
|
1236
|
+
} catch {}
|
|
1237
|
+
const tmpPath = `${path}.tmp-${Date.now()}`;
|
|
1238
|
+
await writeFile(tmpPath, JSON.stringify(creds, null, 2), "utf-8");
|
|
1239
|
+
try {
|
|
1240
|
+
await chmod(tmpPath, 384);
|
|
1241
|
+
} catch {}
|
|
1242
|
+
await rename(tmpPath, path);
|
|
1243
|
+
return {
|
|
1244
|
+
path,
|
|
1245
|
+
backedUpTo
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
async function loadProjectCredentials(cwd) {
|
|
1249
|
+
const path = projectCredentialsPath(cwd);
|
|
1250
|
+
let raw;
|
|
1251
|
+
try {
|
|
1252
|
+
raw = await readFile(path, "utf-8");
|
|
1253
|
+
} catch (err) {
|
|
1254
|
+
if (isEnoent(err)) return null;
|
|
1255
|
+
throw err;
|
|
1256
|
+
}
|
|
1257
|
+
if (raw.trim().length === 0) return null;
|
|
1258
|
+
let parsed;
|
|
1259
|
+
try {
|
|
1260
|
+
parsed = JSON.parse(raw);
|
|
1261
|
+
} catch {
|
|
1262
|
+
return null;
|
|
1263
|
+
}
|
|
1264
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
1265
|
+
const obj = parsed;
|
|
1266
|
+
if (obj["version"] !== 1) return null;
|
|
1267
|
+
if (typeof obj["project_id"] !== "string" || obj["project_id"].length === 0) return null;
|
|
1268
|
+
if (typeof obj["client_key"] !== "string" || obj["client_key"].length === 0) return null;
|
|
1269
|
+
return {
|
|
1270
|
+
version: 1,
|
|
1271
|
+
project_id: obj["project_id"],
|
|
1272
|
+
project_name: typeof obj["project_name"] === "string" ? obj["project_name"] : "unknown",
|
|
1273
|
+
environment: obj["environment"] === "production" ? "production" : "development",
|
|
1274
|
+
client_key: obj["client_key"],
|
|
1275
|
+
server_key: typeof obj["server_key"] === "string" && obj["server_key"].length > 0 ? obj["server_key"] : null,
|
|
1276
|
+
api_url: typeof obj["api_url"] === "string" ? obj["api_url"] : DEFAULT_API_URL,
|
|
1277
|
+
wired_surfaces: Array.isArray(obj["wired_surfaces"]) ? obj["wired_surfaces"].filter((s) => typeof s === "string") : [],
|
|
1278
|
+
created_at: typeof obj["created_at"] === "string" ? obj["created_at"] : (/* @__PURE__ */ new Date()).toISOString(),
|
|
1279
|
+
updated_at: typeof obj["updated_at"] === "string" ? obj["updated_at"] : (/* @__PURE__ */ new Date()).toISOString()
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
async function writeProjectCredentials(cwd, creds) {
|
|
1283
|
+
const path = projectCredentialsPath(cwd);
|
|
1284
|
+
await mkdir(join(cwd, ".amba"), { recursive: true });
|
|
1285
|
+
const tmpPath = `${path}.tmp-${Date.now()}`;
|
|
1286
|
+
await writeFile(tmpPath, JSON.stringify(creds, null, 2), "utf-8");
|
|
1287
|
+
try {
|
|
1288
|
+
await chmod(tmpPath, 384);
|
|
1289
|
+
} catch {}
|
|
1290
|
+
await rename(tmpPath, path);
|
|
1291
|
+
return path;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Verify a PAT by calling `GET /v1/auth/developer/me`. Returns the
|
|
1295
|
+
* developer row on success, `null` on 401/403/404 (PAT invalid or
|
|
1296
|
+
* developer not found), or throws on network / 5xx errors.
|
|
1297
|
+
*
|
|
1298
|
+
* This is the single source of truth for "do we have a working
|
|
1299
|
+
* identity." Used at the top of every init run.
|
|
1300
|
+
*/
|
|
1301
|
+
async function verifyPat(pat, options = {}) {
|
|
1302
|
+
const apiUrl = options.apiUrl ?? process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
|
|
1303
|
+
const res = await (options.fetchImpl ?? fetch)(`${apiUrl}/v1/auth/developer/me`, {
|
|
1304
|
+
method: "GET",
|
|
1305
|
+
headers: {
|
|
1306
|
+
Authorization: `Bearer ${pat}`,
|
|
1307
|
+
"User-Agent": "amba-cli/credentials"
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) return null;
|
|
1311
|
+
if (!res.ok) throw new Error(`developer/me verify returned ${res.status} ${res.statusText}`);
|
|
1312
|
+
let raw;
|
|
1313
|
+
try {
|
|
1314
|
+
raw = await res.json();
|
|
1315
|
+
} catch (err) {
|
|
1316
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1317
|
+
throw new Error(`developer/me returned 2xx but body was not JSON: ${reason}`);
|
|
1318
|
+
}
|
|
1319
|
+
if (!raw.data?.id) return null;
|
|
1320
|
+
return {
|
|
1321
|
+
id: raw.data.id,
|
|
1322
|
+
email: raw.data.email ?? "unknown",
|
|
1323
|
+
name: raw.data.name
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Ensure the machine has a verified Amba developer identity.
|
|
1328
|
+
*
|
|
1329
|
+
* Decision tree:
|
|
1330
|
+
* 1. Load existing `~/.amba/credentials.json`.
|
|
1331
|
+
* 2. If found, verify the PAT via `developer/me`.
|
|
1332
|
+
* - Valid → migrate shape if legacy, return.
|
|
1333
|
+
* - Invalid → fall through to signup (unless `signupOnMissing: false`).
|
|
1334
|
+
* 3. No creds (or invalid) + `signupOnMissing !== false` → call
|
|
1335
|
+
* `performSandboxSignup` with generated email/password, write the
|
|
1336
|
+
* result, return.
|
|
1337
|
+
* 4. No creds + `signupOnMissing === false` → throw.
|
|
1338
|
+
*/
|
|
1339
|
+
async function ensureDeveloperIdentity(options = {}) {
|
|
1340
|
+
const apiUrl = options.apiUrl ?? process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
|
|
1341
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1342
|
+
const existing = await loadDeveloperCredentials({ homeDir: options.homeDir });
|
|
1343
|
+
if (existing) {
|
|
1344
|
+
let verified = null;
|
|
1345
|
+
try {
|
|
1346
|
+
verified = await verifyPat(existing.pat, {
|
|
1347
|
+
apiUrl,
|
|
1348
|
+
fetchImpl
|
|
1349
|
+
});
|
|
1350
|
+
} catch {
|
|
1351
|
+
throw new Error(`Could not verify existing Amba credentials at ${developerCredentialsPath(options.homeDir)} — check your network and try again.`);
|
|
1352
|
+
}
|
|
1353
|
+
if (verified) {
|
|
1354
|
+
if (existing.source === "legacy" || existing.developer_id !== verified.id || existing.email !== verified.email) {
|
|
1355
|
+
const upgraded = {
|
|
1356
|
+
...existing,
|
|
1357
|
+
version: 1,
|
|
1358
|
+
developer_id: verified.id,
|
|
1359
|
+
email: verified.email,
|
|
1360
|
+
api_url: apiUrl,
|
|
1361
|
+
source: existing.source === "legacy" ? "manual" : existing.source,
|
|
1362
|
+
created_at: existing.created_at
|
|
1363
|
+
};
|
|
1364
|
+
const write = await writeDeveloperCredentials(upgraded, { homeDir: options.homeDir });
|
|
1365
|
+
return {
|
|
1366
|
+
credentials: upgraded,
|
|
1367
|
+
newlySignedUp: false,
|
|
1368
|
+
developer: verified,
|
|
1369
|
+
firstProject: null,
|
|
1370
|
+
credentialsBackedUpTo: write.backedUpTo,
|
|
1371
|
+
credentialsPath: write.path
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
return {
|
|
1375
|
+
credentials: existing,
|
|
1376
|
+
newlySignedUp: false,
|
|
1377
|
+
developer: verified,
|
|
1378
|
+
firstProject: null,
|
|
1379
|
+
credentialsBackedUpTo: null,
|
|
1380
|
+
credentialsPath: developerCredentialsPath(options.homeDir)
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
if (options.signupOnMissing === false) throw new Error(`No verified Amba identity at ${developerCredentialsPath(options.homeDir)} and signup-on-missing is disabled. Run \`amba login\` to authenticate.`);
|
|
1385
|
+
const signup = await performSandboxSignup({
|
|
1386
|
+
email: options.sandboxEmail?.trim() || generateSandboxEmail(),
|
|
1387
|
+
password: generateSandboxPassword()
|
|
1388
|
+
}, {
|
|
1389
|
+
apiUrl,
|
|
1390
|
+
fetchImpl
|
|
1391
|
+
});
|
|
1392
|
+
const developerId = signup.developer_id.length > 0 ? signup.developer_id : null;
|
|
1393
|
+
const developer = {
|
|
1394
|
+
id: developerId ?? "pending",
|
|
1395
|
+
email: signup.email,
|
|
1396
|
+
...signup.developer_name ? { name: signup.developer_name } : {}
|
|
1397
|
+
};
|
|
1398
|
+
const newCreds = {
|
|
1399
|
+
version: 1,
|
|
1400
|
+
developer_id: developerId,
|
|
1401
|
+
email: signup.email,
|
|
1402
|
+
pat: signup.pat,
|
|
1403
|
+
api_url: signup.api_url,
|
|
1404
|
+
source: "sandbox-init",
|
|
1405
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1406
|
+
access_token: signup.pat,
|
|
1407
|
+
refresh_token: "",
|
|
1408
|
+
expires_at: (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString()
|
|
1409
|
+
};
|
|
1410
|
+
const write = await writeDeveloperCredentials(newCreds, { homeDir: options.homeDir });
|
|
1411
|
+
return {
|
|
1412
|
+
credentials: newCreds,
|
|
1413
|
+
newlySignedUp: true,
|
|
1414
|
+
developer,
|
|
1415
|
+
firstProject: {
|
|
1416
|
+
project_id: signup.project_id,
|
|
1417
|
+
client_key: signup.client_key,
|
|
1418
|
+
server_key: signup.server_key ?? null,
|
|
1419
|
+
provisioning_status: signup.provisioning_status,
|
|
1420
|
+
verify_url: signup.verify_url
|
|
1421
|
+
},
|
|
1422
|
+
credentialsBackedUpTo: write.backedUpTo,
|
|
1423
|
+
credentialsPath: write.path
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Ensure the current working directory is attached to an Amba project.
|
|
1428
|
+
*
|
|
1429
|
+
* Decision tree:
|
|
1430
|
+
* 1. Load existing `<cwd>/.amba/project.json`.
|
|
1431
|
+
* - Present → return (no API call; we trust the file's metadata
|
|
1432
|
+
* until something downstream fails, at which point the caller
|
|
1433
|
+
* re-keys).
|
|
1434
|
+
* 2. Missing + `signupFirstProject` provided → use those keys, write
|
|
1435
|
+
* `<cwd>/.amba/project.json`, return (newlyCreated=true).
|
|
1436
|
+
* 3. Missing + no signup payload + `attachToProjectId` provided →
|
|
1437
|
+
* mint a new client+server key under that project, write the
|
|
1438
|
+
* file, return.
|
|
1439
|
+
* 4. Missing + no signup payload + no attach → call
|
|
1440
|
+
* `createProject({ name, environment })` under the dev's PAT,
|
|
1441
|
+
* mint both keys, write the file, return.
|
|
1442
|
+
*/
|
|
1443
|
+
async function ensureProjectForCwd(cwd, options) {
|
|
1444
|
+
const existing = await loadProjectCredentials(cwd);
|
|
1445
|
+
if (existing) return {
|
|
1446
|
+
credentials: existing,
|
|
1447
|
+
newlyCreated: false
|
|
1448
|
+
};
|
|
1449
|
+
const environment = options.environment ?? "development";
|
|
1450
|
+
const apiUrl = process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
|
|
1451
|
+
setBearerOverride(options.pat);
|
|
1452
|
+
if (options.signupFirstProject) {
|
|
1453
|
+
const creds = {
|
|
1454
|
+
version: 1,
|
|
1455
|
+
project_id: options.signupFirstProject.project_id,
|
|
1456
|
+
project_name: options.defaultName ?? (basename(cwd) || "amba-sandbox"),
|
|
1457
|
+
environment,
|
|
1458
|
+
client_key: options.signupFirstProject.client_key,
|
|
1459
|
+
server_key: options.signupFirstProject.server_key,
|
|
1460
|
+
api_url: apiUrl,
|
|
1461
|
+
wired_surfaces: [],
|
|
1462
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1463
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1464
|
+
};
|
|
1465
|
+
await writeProjectCredentials(cwd, creds);
|
|
1466
|
+
return {
|
|
1467
|
+
credentials: creds,
|
|
1468
|
+
newlyCreated: true
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
if (options.attachToProjectId) {
|
|
1472
|
+
const { clientKey, serverKey } = await mintProjectKeyPair(options.attachToProjectId, environment);
|
|
1473
|
+
const creds = {
|
|
1474
|
+
version: 1,
|
|
1475
|
+
project_id: options.attachToProjectId,
|
|
1476
|
+
project_name: options.defaultName ?? (basename(cwd) || "amba-project"),
|
|
1477
|
+
environment,
|
|
1478
|
+
client_key: clientKey,
|
|
1479
|
+
server_key: serverKey,
|
|
1480
|
+
api_url: apiUrl,
|
|
1481
|
+
wired_surfaces: [],
|
|
1482
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1483
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1484
|
+
};
|
|
1485
|
+
await writeProjectCredentials(cwd, creds);
|
|
1486
|
+
return {
|
|
1487
|
+
credentials: creds,
|
|
1488
|
+
newlyCreated: true
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
const uniqueName = await uniqueProjectName(sanitizeProjectName(options.defaultName ?? (basename(cwd) || "amba-project")));
|
|
1492
|
+
const project = await createProject({
|
|
1493
|
+
name: uniqueName,
|
|
1494
|
+
environment
|
|
1495
|
+
});
|
|
1496
|
+
const { clientKey, serverKey } = await mintProjectKeyPair(project.data.id, environment);
|
|
1497
|
+
const creds = {
|
|
1498
|
+
version: 1,
|
|
1499
|
+
project_id: project.data.id,
|
|
1500
|
+
project_name: uniqueName,
|
|
1501
|
+
environment,
|
|
1502
|
+
client_key: clientKey,
|
|
1503
|
+
server_key: serverKey,
|
|
1504
|
+
api_url: apiUrl,
|
|
1505
|
+
wired_surfaces: [],
|
|
1506
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1507
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1508
|
+
};
|
|
1509
|
+
await writeProjectCredentials(cwd, creds);
|
|
1510
|
+
return {
|
|
1511
|
+
credentials: creds,
|
|
1512
|
+
newlyCreated: true
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
async function mintProjectKeyPair(projectId, environment) {
|
|
1516
|
+
const clientRes = await createApiKey(projectId, "client", environment);
|
|
1517
|
+
const serverRes = await createApiKey(projectId, "server", environment);
|
|
1518
|
+
return {
|
|
1519
|
+
clientKey: clientRes.data.key,
|
|
1520
|
+
serverKey: serverRes.data.key
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* Pick a project name unique against the developer's current set.
|
|
1525
|
+
*
|
|
1526
|
+
* Multi-folder reality: a developer running `amba init` from
|
|
1527
|
+
* `~/code/fitness-app` then `~/code/fitness-app-v2` will get names
|
|
1528
|
+
* derived from different basenames already; the disambiguation is
|
|
1529
|
+
* only for the rare case where two folders end up with the same
|
|
1530
|
+
* basename (e.g. `~/work/fitness` and `~/personal/fitness`).
|
|
1531
|
+
*/
|
|
1532
|
+
async function uniqueProjectName(base) {
|
|
1533
|
+
let existing;
|
|
1534
|
+
try {
|
|
1535
|
+
existing = (await listProjects()).data.map((p) => p.name);
|
|
1536
|
+
} catch {
|
|
1537
|
+
return base;
|
|
1538
|
+
}
|
|
1539
|
+
if (!existing.includes(base)) return base;
|
|
1540
|
+
for (let i = 2; i < 100; i += 1) {
|
|
1541
|
+
const candidate = `${base}-${i}`;
|
|
1542
|
+
if (!existing.includes(candidate)) return candidate;
|
|
1543
|
+
}
|
|
1544
|
+
return `${base}-${Date.now().toString(36)}`;
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Sanitize a candidate project name. The control-plane enforces
|
|
1548
|
+
* `^[a-zA-Z0-9-_]{1,64}$` (see `apps/api/src/routes/projects.ts`); the
|
|
1549
|
+
* basename of a project folder often contains spaces or dots. We
|
|
1550
|
+
* collapse runs of non-allowed chars to `-`, trim outer dashes, and
|
|
1551
|
+
* truncate to 64.
|
|
1552
|
+
*/
|
|
1553
|
+
function sanitizeProjectName(input) {
|
|
1554
|
+
const collapsed = input.normalize("NFKD").replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
1555
|
+
return collapsed.length > 0 ? collapsed : "amba-project";
|
|
1556
|
+
}
|
|
1557
|
+
function isEnoent(err) {
|
|
1558
|
+
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
1559
|
+
}
|
|
1560
|
+
//#endregion
|
|
1561
|
+
//#region src/commands/claim.ts
|
|
1562
|
+
/**
|
|
1563
|
+
* `amba claim <email>` — bind a sandbox account to a real email address
|
|
1564
|
+
* via a one-click magic link.
|
|
1565
|
+
*
|
|
1566
|
+
* Sandbox accounts are minted with an auto-generated address
|
|
1567
|
+
* (`sandbox-<epoch>-<nonce>@layers.com`) and remain capped at 100 MAU /
|
|
1568
|
+
* 10 MB DB until the developer claims a real email. This command POSTs
|
|
1569
|
+
* the target email to `/v1/auth/developer/claim` under the developer's
|
|
1570
|
+
* stored PAT; the backend emails a single-use magic link that — when
|
|
1571
|
+
* clicked — updates the developer row and flips the project tier from
|
|
1572
|
+
* `sandbox` to `verified_free` (1,000 MAU, 500 MB DB).
|
|
1573
|
+
*
|
|
1574
|
+
* Wire shape:
|
|
1575
|
+
*
|
|
1576
|
+
* POST {AMBA_API_URL}/v1/auth/developer/claim
|
|
1577
|
+
* Authorization: Bearer {pat}
|
|
1578
|
+
* Content-Type: application/json
|
|
1579
|
+
* Body: { "email": "<target-email>" }
|
|
1580
|
+
*
|
|
1581
|
+
* Success: HTTP 200 `{ "ok": true }`
|
|
1582
|
+
* Errors: HTTP 400 INVALID_INPUT
|
|
1583
|
+
* HTTP 409 EMAIL_TAKEN — that address already owns another account
|
|
1584
|
+
* HTTP 409 ALREADY_CLAIMED — this account is already verified
|
|
1585
|
+
* HTTP 429 — rate-limited
|
|
1586
|
+
* HTTP 5xx — surface verbatim with code + message
|
|
1587
|
+
*
|
|
1588
|
+
* UX contract: a single ✓ line + a hint that the link expires in 15
|
|
1589
|
+
* minutes. No copy-paste tokens, no follow-up commands. The click in
|
|
1590
|
+
* the email is the whole flow.
|
|
1591
|
+
*/
|
|
1592
|
+
async function claimCommand(email, options = {}) {
|
|
1593
|
+
console.log();
|
|
1594
|
+
console.log(pc.bold(" amba claim"));
|
|
1595
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1596
|
+
console.log();
|
|
1597
|
+
const trimmed = email.trim();
|
|
1598
|
+
if (!isPlausibleEmail(trimmed)) {
|
|
1599
|
+
console.log(pc.red(" ✗") + " Invalid email format.");
|
|
1600
|
+
console.log();
|
|
1601
|
+
process.exit(1);
|
|
1602
|
+
}
|
|
1603
|
+
let pat = options.pat ?? null;
|
|
1604
|
+
if (!pat) try {
|
|
1605
|
+
const dev = await loadDeveloperCredentials({ homeDir: options.homeDir });
|
|
1606
|
+
if (dev?.pat) pat = dev.pat;
|
|
1607
|
+
} catch {}
|
|
1608
|
+
if (!pat) {
|
|
1609
|
+
console.log(pc.red(" ✗") + " No Amba credentials found. Run " + pc.bold("amba init") + " first.");
|
|
1610
|
+
console.log();
|
|
1611
|
+
process.exit(1);
|
|
1612
|
+
}
|
|
1613
|
+
const url = `${options.apiUrl?.trim() || process.env["AMBA_API_URL"]?.trim() || "https://api.amba.dev"}/v1/auth/developer/claim`;
|
|
1614
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1615
|
+
let res;
|
|
1616
|
+
try {
|
|
1617
|
+
res = await fetchImpl(url, {
|
|
1618
|
+
method: "POST",
|
|
1619
|
+
headers: {
|
|
1620
|
+
Authorization: `Bearer ${pat}`,
|
|
1621
|
+
"Content-Type": "application/json",
|
|
1622
|
+
"User-Agent": "amba-cli/claim"
|
|
1623
|
+
},
|
|
1624
|
+
body: JSON.stringify({ email: trimmed })
|
|
1625
|
+
});
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1628
|
+
console.log(pc.red(" ✗") + ` Could not reach Amba: ${reason}`);
|
|
1629
|
+
console.log();
|
|
1630
|
+
process.exit(1);
|
|
1631
|
+
}
|
|
1632
|
+
if (res.status === 200) {
|
|
1633
|
+
try {
|
|
1634
|
+
await res.text();
|
|
1635
|
+
} catch {}
|
|
1636
|
+
console.log(pc.green(" ✓") + ` Check ${pc.bold(trimmed)} for a one-click link.`);
|
|
1637
|
+
console.log(pc.dim(" (Link expires in 15 minutes.)"));
|
|
1638
|
+
console.log();
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
let errCode = "";
|
|
1642
|
+
let errMessage = "";
|
|
1643
|
+
try {
|
|
1644
|
+
const body = await res.json();
|
|
1645
|
+
errCode = body.error?.code ?? "";
|
|
1646
|
+
errMessage = body.error?.message ?? "";
|
|
1647
|
+
} catch {}
|
|
1648
|
+
if (res.status === 400 && errCode === "INVALID_INPUT") {
|
|
1649
|
+
console.log(pc.red(" ✗") + " Invalid email format.");
|
|
1650
|
+
console.log();
|
|
1651
|
+
process.exit(1);
|
|
1652
|
+
}
|
|
1653
|
+
if (res.status === 409 && errCode === "EMAIL_TAKEN") {
|
|
1654
|
+
console.log(pc.red(" ✗") + ` That email is already on another Amba account. If it's yours, sign in via ` + pc.bold("amba login") + " or reach out to support@layers.com.");
|
|
1655
|
+
console.log();
|
|
1656
|
+
process.exit(1);
|
|
1657
|
+
}
|
|
1658
|
+
if (res.status === 409 && errCode === "ALREADY_CLAIMED") {
|
|
1659
|
+
console.log(pc.red(" ✗") + " This account is already verified.");
|
|
1660
|
+
console.log();
|
|
1661
|
+
process.exit(1);
|
|
1662
|
+
}
|
|
1663
|
+
if (res.status === 429) {
|
|
1664
|
+
console.log(pc.red(" ✗") + " Too many claim attempts. Try again in a minute.");
|
|
1665
|
+
console.log();
|
|
1666
|
+
process.exit(1);
|
|
1667
|
+
}
|
|
1668
|
+
const codeLabel = errCode || `HTTP_${res.status}`;
|
|
1669
|
+
const messageLabel = errMessage || res.statusText || "Request failed";
|
|
1670
|
+
console.log(pc.red(" ✗") + ` ${codeLabel}: ${messageLabel}`);
|
|
1671
|
+
console.log();
|
|
1672
|
+
process.exit(1);
|
|
1673
|
+
}
|
|
1674
|
+
//#endregion
|
|
1675
|
+
//#region src/context-files.ts
|
|
1676
|
+
/**
|
|
1677
|
+
* Generate AMBA.md project context file for AI agents.
|
|
1678
|
+
*/
|
|
1679
|
+
function generateAmbaMarkdown(opts) {
|
|
1680
|
+
const sdkPackage = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
|
|
1681
|
+
const providerExample = opts.framework === "expo" ? `
|
|
1682
|
+
### Client Setup
|
|
1683
|
+
|
|
1684
|
+
\`\`\`tsx
|
|
1685
|
+
// app/_layout.tsx
|
|
1686
|
+
import { useEffect } from 'react';
|
|
1687
|
+
import { Slot } from 'expo-router';
|
|
1688
|
+
import { Amba } from '@layers/amba-expo';
|
|
1689
|
+
|
|
1690
|
+
export default function RootLayout() {
|
|
1691
|
+
useEffect(() => {
|
|
1692
|
+
Amba.configure({
|
|
1693
|
+
projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
|
|
1694
|
+
apiKey: process.env.EXPO_PUBLIC_AMBA_API_KEY!,
|
|
1695
|
+
});
|
|
1696
|
+
}, []);
|
|
1697
|
+
|
|
1698
|
+
return <Slot />;
|
|
1699
|
+
}
|
|
1700
|
+
\`\`\`
|
|
1701
|
+
|
|
1702
|
+
### Using the Client
|
|
1703
|
+
|
|
1704
|
+
\`\`\`tsx
|
|
1705
|
+
import { Amba } from '@layers/amba-expo';
|
|
1706
|
+
|
|
1707
|
+
export default function MyComponent() {
|
|
1708
|
+
const onPress = async () => {
|
|
1709
|
+
// Track an event
|
|
1710
|
+
await Amba.events.track('lesson_completed', { lesson_id: '123' });
|
|
1711
|
+
|
|
1712
|
+
// Sign in with Apple (requires expo-apple-authentication)
|
|
1713
|
+
await Amba.signInWithApple();
|
|
1714
|
+
|
|
1715
|
+
// Read remote config
|
|
1716
|
+
const showBanner = await Amba.config.fetch();
|
|
1717
|
+
|
|
1718
|
+
// Email sign-in
|
|
1719
|
+
await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
|
|
1720
|
+
};
|
|
1721
|
+
|
|
1722
|
+
// ...
|
|
1723
|
+
}
|
|
1724
|
+
\`\`\`` : `
|
|
1725
|
+
### Client Setup
|
|
1726
|
+
|
|
1727
|
+
\`\`\`typescript
|
|
1728
|
+
import { Amba } from '${sdkPackage}';
|
|
1729
|
+
|
|
1730
|
+
await Amba.configure({
|
|
1731
|
+
projectId: process.env.AMBA_PROJECT_ID!,
|
|
1732
|
+
apiKey: process.env.AMBA_API_KEY!,
|
|
1733
|
+
});
|
|
1734
|
+
|
|
1735
|
+
// Track an event
|
|
1736
|
+
await Amba.events.track('page_viewed', { page: '/pricing' });
|
|
1737
|
+
|
|
1738
|
+
// Read remote config
|
|
1739
|
+
const config = await Amba.config.fetch();
|
|
1740
|
+
|
|
1741
|
+
// Email sign-in
|
|
1742
|
+
await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
|
|
1743
|
+
\`\`\``;
|
|
1744
|
+
return `# Amba Project Context
|
|
1745
|
+
|
|
1746
|
+
> This file provides context about the Amba integration for AI coding agents.
|
|
1747
|
+
|
|
1748
|
+
## Project Info
|
|
1749
|
+
|
|
1750
|
+
| Key | Value |
|
|
1751
|
+
|-----|-------|
|
|
1752
|
+
| Project ID | \`${opts.projectId}\` |
|
|
1753
|
+
| Project Name | ${opts.projectName} |
|
|
1754
|
+
| Framework | ${opts.framework} |
|
|
1755
|
+
| SDK | \`${sdkPackage}\` |
|
|
1756
|
+
|
|
1757
|
+
## Environment Variables
|
|
1758
|
+
|
|
1759
|
+
These are configured in \`.env.local\`:
|
|
1760
|
+
|
|
1761
|
+
- \`AMBA_PROJECT_ID\` — Your project identifier
|
|
1762
|
+
- \`AMBA_API_KEY\` — Client API key (safe for client-side use)
|
|
1763
|
+
- \`AMBA_API_URL\` — API endpoint (defaults to https://api.amba.dev)
|
|
1764
|
+
|
|
1765
|
+
## SDK Usage
|
|
1766
|
+
${providerExample}
|
|
1767
|
+
|
|
1768
|
+
## Available Features
|
|
1769
|
+
|
|
1770
|
+
- **Push Notifications** — Send targeted push notifications to user segments
|
|
1771
|
+
- **Remote Config** — Key-value configuration that updates without app releases
|
|
1772
|
+
- **Segments** — Group users by behavior, properties, or entitlements
|
|
1773
|
+
- **Streaks** — Track user engagement streaks (daily, weekly)
|
|
1774
|
+
- **Content Libraries** — Scheduled content delivery (daily tips, weekly challenges)
|
|
1775
|
+
- **Entitlements** — Subscription status via RevenueCat integration
|
|
1776
|
+
- **Analytics** — DAU, MAU, retention, and custom event tracking
|
|
1777
|
+
|
|
1778
|
+
## API Reference
|
|
1779
|
+
|
|
1780
|
+
- Admin API: \`https://api.amba.dev/v1/admin\`
|
|
1781
|
+
- Client API: \`https://api.amba.dev/v1/client\`
|
|
1782
|
+
- Docs: \`https://docs.amba.dev\`
|
|
1783
|
+
|
|
1784
|
+
## CLI Commands
|
|
1785
|
+
|
|
1786
|
+
\`\`\`bash
|
|
1787
|
+
amba status # Check project health
|
|
1788
|
+
amba push test # Send a test push notification
|
|
1789
|
+
amba config list # List remote config values
|
|
1790
|
+
amba config set <key> <value> # Set a config value
|
|
1791
|
+
\`\`\`
|
|
1792
|
+
`;
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Generate .cursor/rules/amba.mdc Cursor rules file.
|
|
1796
|
+
*/
|
|
1797
|
+
function generateCursorRules(opts) {
|
|
1798
|
+
const sdk = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
|
|
1799
|
+
return `---
|
|
1800
|
+
description: Rules for working with the Amba SDK in this project
|
|
1801
|
+
globs: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
|
|
1802
|
+
---
|
|
1803
|
+
|
|
1804
|
+
# Amba SDK Rules
|
|
1805
|
+
|
|
1806
|
+
## Project Setup
|
|
1807
|
+
- Project ID: \`${opts.projectId}\`
|
|
1808
|
+
- SDK: \`${sdk}\`
|
|
1809
|
+
- API URL: \`https://api.amba.dev\`
|
|
1810
|
+
|
|
1811
|
+
## Environment Variables
|
|
1812
|
+
- Always read Amba config from environment variables, never hardcode
|
|
1813
|
+
- Use \`process.env.AMBA_PROJECT_ID\` and \`process.env.AMBA_API_KEY\`
|
|
1814
|
+
- The .env.local file contains the project credentials
|
|
1815
|
+
|
|
1816
|
+
## SDK Patterns
|
|
1817
|
+
${opts.framework === "expo" ? `- Import the \`Amba\` singleton from \`@layers/amba-expo\`
|
|
1818
|
+
- Call \`Amba.init({ projectId, apiKey })\` once in the root layout (inside a \`useEffect\`)
|
|
1819
|
+
- The Expo wrapper auto-wires AsyncStorage, push tokens, and Apple/Google sign-in
|
|
1820
|
+
- Use \`Amba.signInWithApple()\` / \`Amba.signInWithGoogle()\` for social auth one-liners
|
|
1821
|
+
- Call \`Amba.track()\` for engagement events, don't build custom analytics` : `- Initialize the Amba client once and export it as a singleton
|
|
1822
|
+
- Use \`Amba.client.track()\` for all engagement events
|
|
1823
|
+
- Use \`Amba.client.config.get()\` for remote configuration
|
|
1824
|
+
- Use \`Amba.client.auth\` for sign-up / sign-in flows`}
|
|
1825
|
+
|
|
1826
|
+
## Push Notifications
|
|
1827
|
+
- Register push tokens via the SDK \`registerPushToken()\` method
|
|
1828
|
+
- Handle notification payloads using the SDK's notification listener
|
|
1829
|
+
- Don't implement custom push token management
|
|
1830
|
+
|
|
1831
|
+
## Remote Config
|
|
1832
|
+
- Use remote config for feature flags and dynamic values
|
|
1833
|
+
- Always provide sensible defaults when reading config values
|
|
1834
|
+
- Config values are cached — don't fetch on every render
|
|
1835
|
+
|
|
1836
|
+
## Streaks
|
|
1837
|
+
- Streaks are server-managed; the SDK provides read-only access
|
|
1838
|
+
- Use \`track()\` to record qualifying events — the server evaluates streaks
|
|
1839
|
+
- Show streak state from \`streak.current()\`, don't calculate manually
|
|
1840
|
+
|
|
1841
|
+
## Best Practices
|
|
1842
|
+
- Don't store Amba API keys in source code or commit them to git
|
|
1843
|
+
- Use \`.env.local\` for local development credentials
|
|
1844
|
+
- The client API key (prefixed \`amb_dev_ck_\` or \`amb_live_ck_\`) is safe for client-side use
|
|
1845
|
+
- Server keys (prefixed \`amb_dev_sk_\` or \`amb_live_sk_\`) must stay server-side only
|
|
1846
|
+
`;
|
|
1847
|
+
}
|
|
1848
|
+
/**
|
|
1849
|
+
* Write both context files to the project directory.
|
|
1850
|
+
*/
|
|
1851
|
+
async function generateContextFiles(opts) {
|
|
1852
|
+
const files = [];
|
|
1853
|
+
await writeFile(join(opts.cwd, "AMBA.md"), generateAmbaMarkdown(opts), "utf-8");
|
|
1854
|
+
files.push("AMBA.md");
|
|
1855
|
+
const cursorDir = join(opts.cwd, ".cursor", "rules");
|
|
1856
|
+
await mkdir(cursorDir, { recursive: true });
|
|
1857
|
+
await writeFile(join(cursorDir, "amba.mdc"), generateCursorRules(opts), "utf-8");
|
|
1858
|
+
files.push(".cursor/rules/amba.mdc");
|
|
1859
|
+
return files;
|
|
1860
|
+
}
|
|
1861
|
+
//#endregion
|
|
1862
|
+
//#region src/skill-installer.ts
|
|
1863
|
+
/**
|
|
1864
|
+
* Amba skill bundle installer.
|
|
1865
|
+
*
|
|
1866
|
+
* The bundled `skill-bundle/` directory contains `SKILL.md` plus a
|
|
1867
|
+
* `references/` folder with one file per Amba surface area. The skill
|
|
1868
|
+
* teaches the agent the classify → confirm → wire-up playbook for
|
|
1869
|
+
* adding Amba primitives to a developer's codebase. See
|
|
1870
|
+
* `packages/cli/skill-bundle/SKILL.md` for the source.
|
|
1871
|
+
*
|
|
1872
|
+
* Why ship a bundled skill (instead of `npx skills add layers/amba`):
|
|
1873
|
+
* the CLI run is the same install step. Bundling avoids a second
|
|
1874
|
+
* fetch, keeps the skill version locked to the CLI version, and means
|
|
1875
|
+
* `amba init` produces a fully-wired agent on offline networks too.
|
|
1876
|
+
*
|
|
1877
|
+
* Cross-agent install — we drop the same body into every detected
|
|
1878
|
+
* coding agent's skill directory. Agents read their own location:
|
|
1879
|
+
*
|
|
1880
|
+
* - Claude Code: `.claude/skills/amba/`
|
|
1881
|
+
* - Cursor: `.cursor/skills/amba/`
|
|
1882
|
+
* - Codex CLI: `.codex/skills/amba/`
|
|
1883
|
+
* - Windsurf: `.windsurf/skills/amba/`
|
|
1884
|
+
*
|
|
1885
|
+
* We also write a project-root copy at `.agents/skills/amba/` which
|
|
1886
|
+
* the `npx skills add ...` distribution tool reads from (and which any
|
|
1887
|
+
* agent that pre-registers an `.agents/skills/` lookup picks up). Five
|
|
1888
|
+
* locations, one body — same fan-out pattern `writeAllSetupTargets`
|
|
1889
|
+
* already uses for the legacy setup guide.
|
|
1890
|
+
*
|
|
1891
|
+
* Idempotency: re-running `amba init` overwrites the bundled
|
|
1892
|
+
* `SKILL.md` and `references/*.md` so every developer ends up on the
|
|
1893
|
+
* latest playbook. We back up a pre-existing `SKILL.md` to a sibling
|
|
1894
|
+
* `.bak-<unix-ms>` ONLY when its first frontmatter key (`name:`) is
|
|
1895
|
+
* not `amba` — that's the signal it was hand-authored / unrelated and
|
|
1896
|
+
* shouldn't be silently clobbered. Bundled Amba files are refreshed
|
|
1897
|
+
* without backup.
|
|
1898
|
+
*/
|
|
1899
|
+
/**
|
|
1900
|
+
* Resolve the bundled skill source directory.
|
|
1901
|
+
*
|
|
1902
|
+
* The bundle lives at `<package-root>/skill-bundle/` in both the
|
|
1903
|
+
* source tree and the published tarball (via `files[]` in
|
|
1904
|
+
* `package.json`). From a built `dist/commands/init.js` the path is
|
|
1905
|
+
* `../../skill-bundle/`. From the source tree
|
|
1906
|
+
* (`src/skill-installer.ts`) the path is `../skill-bundle/`. We try
|
|
1907
|
+
* both relative to `import.meta.url` and pick the one that exists.
|
|
1908
|
+
*/
|
|
1909
|
+
async function resolveBundleDir() {
|
|
1910
|
+
const here = fileURLToPath(import.meta.url);
|
|
1911
|
+
const candidates = [
|
|
1912
|
+
join(dirname(here), "..", "skill-bundle"),
|
|
1913
|
+
join(dirname(here), "..", "..", "skill-bundle"),
|
|
1914
|
+
join(dirname(here), "..", "..", "..", "skill-bundle")
|
|
1915
|
+
];
|
|
1916
|
+
for (const candidate of candidates) try {
|
|
1917
|
+
await access(join(candidate, "SKILL.md"));
|
|
1918
|
+
return candidate;
|
|
1919
|
+
} catch {}
|
|
1920
|
+
throw new Error(`Amba skill bundle not found. Looked at: ${candidates.join(", ")}. This is a CLI packaging bug — please file an issue at https://github.com/layers/amba/issues.`);
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* List the five install targets the CLI fans out to. Project-local
|
|
1924
|
+
* directories (`<cwd>/.claude/skills/amba/`, etc.) — the agent reads
|
|
1925
|
+
* project-local skills with priority over global ones, so this is the
|
|
1926
|
+
* canonical install location for a tool meant to wire up THIS project.
|
|
1927
|
+
*/
|
|
1928
|
+
function skillInstallTargets(cwd) {
|
|
1929
|
+
return [
|
|
1930
|
+
{
|
|
1931
|
+
kind: "claude-code",
|
|
1932
|
+
path: join(cwd, ".claude", "skills", "amba")
|
|
1933
|
+
},
|
|
1934
|
+
{
|
|
1935
|
+
kind: "cursor",
|
|
1936
|
+
path: join(cwd, ".cursor", "skills", "amba")
|
|
1937
|
+
},
|
|
1938
|
+
{
|
|
1939
|
+
kind: "codex",
|
|
1940
|
+
path: join(cwd, ".codex", "skills", "amba")
|
|
1941
|
+
},
|
|
1942
|
+
{
|
|
1943
|
+
kind: "windsurf",
|
|
1944
|
+
path: join(cwd, ".windsurf", "skills", "amba")
|
|
1945
|
+
},
|
|
1946
|
+
{
|
|
1947
|
+
kind: "generic-agents",
|
|
1948
|
+
path: join(cwd, ".agents", "skills", "amba")
|
|
1949
|
+
}
|
|
1950
|
+
];
|
|
1951
|
+
}
|
|
1952
|
+
/**
|
|
1953
|
+
* Copy SKILL.md + every file under references/ into the target
|
|
1954
|
+
* directory. Creates the directory tree if missing. Returns the list
|
|
1955
|
+
* of files touched and any backup paths.
|
|
1956
|
+
*
|
|
1957
|
+
* Backup rule: a pre-existing `SKILL.md` is backed up to
|
|
1958
|
+
* `SKILL.md.bak-<unix-ms>` ONLY when its first `name:` frontmatter
|
|
1959
|
+
* line is NOT `name: amba`. That's the signal it was authored by the
|
|
1960
|
+
* user for an unrelated purpose and shouldn't be silently overwritten.
|
|
1961
|
+
* Amba-owned files get refreshed without backup so developers
|
|
1962
|
+
* tracking the latest playbook don't accumulate junk.
|
|
1963
|
+
*/
|
|
1964
|
+
async function installSkillBundle(cwd, options = {}) {
|
|
1965
|
+
const bundleDir = options.bundleDir ?? await resolveBundleDir();
|
|
1966
|
+
const targets = skillInstallTargets(cwd);
|
|
1967
|
+
const results = [];
|
|
1968
|
+
const skillBody = await readFile(join(bundleDir, "SKILL.md"), "utf-8");
|
|
1969
|
+
const referencesDir = join(bundleDir, "references");
|
|
1970
|
+
let referenceEntries = [];
|
|
1971
|
+
try {
|
|
1972
|
+
referenceEntries = await readdir(referencesDir);
|
|
1973
|
+
} catch {
|
|
1974
|
+
referenceEntries = [];
|
|
1975
|
+
}
|
|
1976
|
+
const referenceBodies = /* @__PURE__ */ new Map();
|
|
1977
|
+
for (const entry of referenceEntries) {
|
|
1978
|
+
if (!entry.endsWith(".md")) continue;
|
|
1979
|
+
const body = await readFile(join(referencesDir, entry), "utf-8");
|
|
1980
|
+
referenceBodies.set(entry, body);
|
|
1981
|
+
}
|
|
1982
|
+
for (const target of targets) {
|
|
1983
|
+
await mkdir(join(target.path, "references"), { recursive: true });
|
|
1984
|
+
const files = [];
|
|
1985
|
+
const skillPath = join(target.path, "SKILL.md");
|
|
1986
|
+
const skillBackup = await backupIfForeignSkill(skillPath);
|
|
1987
|
+
await writeFile(skillPath, skillBody, "utf-8");
|
|
1988
|
+
files.push({
|
|
1989
|
+
path: skillPath,
|
|
1990
|
+
backedUpTo: skillBackup
|
|
1991
|
+
});
|
|
1992
|
+
for (const [name, body] of referenceBodies) {
|
|
1993
|
+
const refPath = join(target.path, "references", name);
|
|
1994
|
+
await writeFile(refPath, body, "utf-8");
|
|
1995
|
+
files.push({
|
|
1996
|
+
path: refPath,
|
|
1997
|
+
backedUpTo: null
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
results.push({
|
|
2001
|
+
target,
|
|
2002
|
+
files
|
|
2003
|
+
});
|
|
2004
|
+
}
|
|
2005
|
+
return results;
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* If a pre-existing `SKILL.md` at `path` has a different `name:`
|
|
2009
|
+
* frontmatter value than `amba`, copy it to a timestamped backup and
|
|
2010
|
+
* return the backup path. Otherwise return null (no backup needed).
|
|
2011
|
+
*
|
|
2012
|
+
* Frontmatter parsing is intentionally cheap — just the first
|
|
2013
|
+
* occurrence of `^name:\s*<value>` within the leading `---` block. A
|
|
2014
|
+
* malformed file falls through to "back up" (safe default).
|
|
2015
|
+
*/
|
|
2016
|
+
async function backupIfForeignSkill(path) {
|
|
2017
|
+
let raw;
|
|
2018
|
+
try {
|
|
2019
|
+
raw = await readFile(path, "utf-8");
|
|
2020
|
+
} catch {
|
|
2021
|
+
return null;
|
|
2022
|
+
}
|
|
2023
|
+
const nameMatch = raw.slice(0, 512).match(/^name:\s*([A-Za-z0-9_-]+)/m);
|
|
2024
|
+
if (nameMatch && nameMatch[1] === "amba") return null;
|
|
2025
|
+
const backupPath = `${path}.bak-${Date.now()}`;
|
|
2026
|
+
await writeFile(backupPath, raw, "utf-8");
|
|
2027
|
+
return backupPath;
|
|
2028
|
+
}
|
|
2029
|
+
/**
|
|
2030
|
+
* Convenience: returns the count of skill files written and the list
|
|
2031
|
+
* of target kinds, for the CLI's done-message summary.
|
|
2032
|
+
*/
|
|
2033
|
+
function summarizeSkillInstall(results) {
|
|
2034
|
+
return {
|
|
2035
|
+
totalFiles: results.reduce((sum, r) => sum + r.files.length, 0),
|
|
2036
|
+
targetKinds: results.map((r) => r.target.kind)
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
//#endregion
|
|
2040
|
+
//#region ../mcp/dist/expo-build-prompt.js
|
|
2041
|
+
/**
|
|
2042
|
+
* Canonical long-form Amba setup guide — markdown body.
|
|
2043
|
+
*
|
|
2044
|
+
* Companion to the short-form `instructions` field served by the MCP
|
|
2045
|
+
* server's initialize response. The pointer "Full guide: amba://setup"
|
|
2046
|
+
* in those instructions tells the agent to fetch this resource when it
|
|
2047
|
+
* needs more detail than the ~1 KB summary provides.
|
|
2048
|
+
*
|
|
2049
|
+
* Consumed by:
|
|
2050
|
+
*
|
|
2051
|
+
* - The MCP resource at `amba://setup`, registered by
|
|
2052
|
+
* `registerAllResources()` in `./index.ts` and exposed by the
|
|
2053
|
+
* hosted MCP server at `mcp.amba.dev`. Any client (Claude Code,
|
|
2054
|
+
* Cursor, Codex, Cowork, etc.) can fetch it via `resources/read`.
|
|
2055
|
+
*
|
|
2056
|
+
* Twin: this body is the server-side mirror of
|
|
2057
|
+
* `packages/cli/skill-bundle/SKILL.md`, which the CLI installs locally
|
|
2058
|
+
* during `npx @layers/amba init`. The two surfaces target two
|
|
2059
|
+
* different audiences:
|
|
2060
|
+
*
|
|
2061
|
+
* - `SKILL.md` ships to a local `.claude/skills/amba/` and assumes
|
|
2062
|
+
* the agent CAN shell out (the bootstrap path can `npx @layers/amba
|
|
2063
|
+
* signup`). It also writes credentials into `.env.local` + `~/.amba/`.
|
|
2064
|
+
* - `AMBA_SETUP_GUIDE_MD` (this constant) is served by the hosted MCP
|
|
2065
|
+
* and assumes the agent CANNOT shell out (e.g. Claude.ai web).
|
|
2066
|
+
* The bootstrap path must therefore use the `amba_developer_signup`
|
|
2067
|
+
* MCP tool (the only pre-auth tool the server registers).
|
|
2068
|
+
*
|
|
2069
|
+
* The playbook shape (Step 0 → Step 1 classify → Step 2 confirm →
|
|
2070
|
+
* Step 3 wire → Step 4 report) is identical between the two, so an
|
|
2071
|
+
* agent reading either ends up at the same outcome. A drift gate test
|
|
2072
|
+
* in `amba-setup.test.ts` asserts the structural anchors match.
|
|
2073
|
+
*
|
|
2074
|
+
* Taxonomy: the SDK capability map below is grouped under the canonical
|
|
2075
|
+
* 7-category taxonomy (Identity / Engagement / Gamification / Economy /
|
|
2076
|
+
* Social / Analytics / Infrastructure) — same shape as `categories.ts`,
|
|
2077
|
+
* the marketing-site feature grid, and the docs IA. Drift is caught by
|
|
2078
|
+
* `amba-setup.test.ts`.
|
|
2079
|
+
*
|
|
2080
|
+
* The body is plain CommonMark — no MDX, no JSX — so it renders
|
|
2081
|
+
* identically wherever it lands.
|
|
2082
|
+
*/
|
|
2083
|
+
const AMBA_SETUP_GUIDE_MD = `# Amba
|
|
2084
|
+
|
|
2085
|
+
## What Amba is
|
|
2086
|
+
|
|
2087
|
+
Amba is the backend your app calls at runtime via the SDK. The MCP tools (\`amba_*\`) are for provisioning at build time — call them as the coding agent.
|
|
2088
|
+
|
|
2089
|
+
One Amba project = one isolated tenant database + one set of API keys + a hosted MCP at \`mcp.amba.dev\` for agent-driven provisioning. The runtime traffic goes through an SDK (\`@layers/amba-*\` on npm, \`Amba\` on SPM / Maven / pub.dev). The provisioning traffic — what you're about to do — goes through the tools listed below.
|
|
2090
|
+
|
|
2091
|
+
Your job is to take the user from "I have an app" to "Amba is wired in and the features are real" in one pass, without 12 follow-up questions. The journey below is the standard playbook. Don't deviate without a reason.
|
|
2092
|
+
|
|
2093
|
+
## Step 0 — Bootstrap credentials
|
|
2094
|
+
|
|
2095
|
+
You're in an MCP client (Claude.ai web, Claude Desktop, Cursor, Windsurf, etc.) without a local CLI. The MCP server has one tool that works **without** a Bearer token: \`amba_developer_signup\`. Use it to mint a personal access token (PAT), project, and client/server keys in a single round trip — no browser, no email verification.
|
|
2096
|
+
|
|
2097
|
+
Call:
|
|
2098
|
+
|
|
2099
|
+
\`\`\`
|
|
2100
|
+
amba_developer_signup({
|
|
2101
|
+
email: "sandbox-<unix-seconds>-<6char-nonce>@layers.com",
|
|
2102
|
+
password: "<32 random base64url chars>",
|
|
2103
|
+
name: "amba-sandbox-agent"
|
|
2104
|
+
})
|
|
2105
|
+
\`\`\`
|
|
2106
|
+
|
|
2107
|
+
The response shape:
|
|
2108
|
+
|
|
2109
|
+
\`\`\`json
|
|
2110
|
+
{
|
|
2111
|
+
"data": {
|
|
2112
|
+
"pat": "amb_dpat_…",
|
|
2113
|
+
"developer": { "id": "…", "email": "…", "tier": "agent_sandbox" },
|
|
2114
|
+
"project": {
|
|
2115
|
+
"project_id": "…",
|
|
2116
|
+
"client_key": "amb_ck_…",
|
|
2117
|
+
"server_key": "amb_sk_…",
|
|
2118
|
+
"provisioning_status": "provisioning",
|
|
2119
|
+
"verify_url": "https://app.amba.dev/verify?token=…",
|
|
2120
|
+
"verify_token": "…"
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
\`\`\`
|
|
2125
|
+
|
|
2126
|
+
After signup, the PAT is in your hand — keep building in THIS session immediately. Pass \`pat: "<the PAT from the response>"\` as an inline argument on every subsequent \`amba_*\` tool call; the inline arg overrides the inbound Bearer for that one call so no client config change is needed mid-session. For future sessions, write the matching snippet from \`mcp_config\` to the customer's MCP-client config file so the static Bearer takes over automatically on the next launch.
|
|
2127
|
+
|
|
2128
|
+
The project status is \`"provisioning"\` immediately after signup; the per-tenant DB activates within ~5 seconds. Don't block — your next call may briefly retry, that's fine. If you want to be polite, call \`amba_projects_get_provisioning_status({ project_id })\` once and proceed when it returns \`"active"\` (or after 15s, whichever first).
|
|
2129
|
+
|
|
2130
|
+
Tell the user where their credentials live:
|
|
2131
|
+
|
|
2132
|
+
- \`pat\` — the Bearer they should configure in this MCP client's settings (and treat like a password).
|
|
2133
|
+
- \`project_id\`, \`client_key\` — the values they paste into their app's \`.env.local\` / \`.env\`.
|
|
2134
|
+
- \`server_key\` — never ship to user devices; only into a server \`.env\` or a secret manager. The \`amb_dev_sk_\` / \`amb_live_sk_\` prefix is the marker.
|
|
2135
|
+
|
|
2136
|
+
**Already have a PAT?** Skip the signup. Call \`amba_developer_me({})\` to verify the Bearer; if it succeeds, either reuse the most recent project (\`amba_projects_list\`) or call \`amba_projects_create({ name: "<app-name>", platform: "all" })\` and then \`amba_api_keys_create\` twice to mint client + server keys for \`environment: "development"\`.
|
|
2137
|
+
|
|
2138
|
+
## Step 1 — Classify the app
|
|
2139
|
+
|
|
2140
|
+
Look at what the user told you and at any files they shared. You're trying to pick one of ten presets in 30 seconds, not write a treatise. Inputs:
|
|
2141
|
+
|
|
2142
|
+
- The user's prompt — "I'm building a fitness tracker" / "a marketplace for…" / "a Duolingo for X".
|
|
2143
|
+
- README content if shared.
|
|
2144
|
+
- \`package.json\` / \`pubspec.yaml\` / \`build.gradle.kts\` / \`Package.swift\` — framework + dependencies.
|
|
2145
|
+
- Screen / view names — \`WorkoutScreen\`, \`MatchView\`, \`LessonPage\`, \`CartView\`, \`ProductDetail\`, \`ChatThread\`.
|
|
2146
|
+
|
|
2147
|
+
Pick the closest match:
|
|
2148
|
+
|
|
2149
|
+
| Preset | When | Default Amba surfaces |
|
|
2150
|
+
| --- | --- | --- |
|
|
2151
|
+
| **fitness** | health / fitness tracker (workouts, steps, meditation) | identity (Apple+Google), push, XP, achievements, streaks, leaderboards, content (daily tips) |
|
|
2152
|
+
| **social** | social network / community (friends, feeds, groups) | identity, push, friends, groups, feeds, messaging, moderation, content |
|
|
2153
|
+
| **marketplace** | commerce / marketplace (catalog, stores, payments) | identity, push, catalog, stores, currencies (loyalty), reviews, segments |
|
|
2154
|
+
| **productivity** | productivity / SaaS tool (collaboration, milestones) | identity (Apple+Google+OTP), push, collections, achievements, content (changelog), segments |
|
|
2155
|
+
| **education** | education / learning app (courses, progress, rewards) | identity, push, XP, achievements, streaks, leaderboards, content (lessons), onboarding |
|
|
2156
|
+
| **game** | game / casual gaming | identity (anon-first), push, XP, achievements, currencies, inventory, leaderboards, challenges, stores |
|
|
2157
|
+
| **dating** | dating / matching app | identity (phone-OTP), push, friends (matches), messaging, moderation (heavy), reviews |
|
|
2158
|
+
| **content_creator** | content platform (feeds, subscriptions, tips) | identity, push, feeds, content, currencies (tips), referrals, stores (subscriptions) |
|
|
2159
|
+
| **ai_chatbot** | AI / chatbot / assistant app | identity, push, AI prompts, currencies (credits), content (system prompts), onboarding |
|
|
2160
|
+
| **custom** | none of the above | pick features individually |
|
|
2161
|
+
|
|
2162
|
+
Detection heuristics, in priority order:
|
|
2163
|
+
|
|
2164
|
+
1. The user's own description — most direct signal.
|
|
2165
|
+
2. Filename match in \`screens/\` or \`views/\` (high signal).
|
|
2166
|
+
3. Dependency in \`package.json\` — \`react-native-health\` → fitness, \`@stream-io/*\` → social or dating, \`@stripe/*\` → marketplace, \`revenuecat\` → marketplace or content_creator.
|
|
2167
|
+
4. README copy — "fitness", "habit", "match", "chat", "store", "subscription".
|
|
2168
|
+
|
|
2169
|
+
If two presets tie, pick the one the user's filenames match more closely. If still tied or no signal, fall back to **custom** and let them pick.
|
|
2170
|
+
|
|
2171
|
+
## Step 2 — Confirm with the user
|
|
2172
|
+
|
|
2173
|
+
Use a single multi-choice. Quote the surfaces from the table above so they know what they're getting.
|
|
2174
|
+
|
|
2175
|
+
**Question 1: classification + scope**
|
|
2176
|
+
|
|
2177
|
+
> I'm reading this as a **\\{kind\\}** app. I'd wire up: **\\{surfaces\\}**. Sound right?
|
|
2178
|
+
>
|
|
2179
|
+
> 1. Yes, wire it up as proposed (Recommended)
|
|
2180
|
+
> 2. Same kind but I want to pick features individually
|
|
2181
|
+
> 3. Wrong kind — let me pick from the list
|
|
2182
|
+
> 4. Custom — I'll pick features manually
|
|
2183
|
+
|
|
2184
|
+
If the user picks 1, go to Step 3. If 2 or 4, follow up with a multi-select of surfaces. If 3, present the table again and pick a different preset.
|
|
2185
|
+
|
|
2186
|
+
**Question 2 (preset-specific):** see the per-surface sub-resources (\`amba://setup/<surface>\`) for the full "Common follow-ups" list. Examples:
|
|
2187
|
+
|
|
2188
|
+
- **fitness / game / education** — leaderboard scope? (all-time, weekly, daily, none)
|
|
2189
|
+
- **game / content_creator** — virtual currency name? (\`gold\`, \`gems\`, \`coins\`, \`credits\` — defaults to \`coins\`)
|
|
2190
|
+
- **content_creator** — monetization? (tips, subscriptions, both)
|
|
2191
|
+
- **dating** — phone OTP or email-only? (phone strongly recommended)
|
|
2192
|
+
- **ai_chatbot** — daily free credit cap?
|
|
2193
|
+
|
|
2194
|
+
Batch the follow-ups into one or two multi-choice rounds. Don't drip-feed six separate questions.
|
|
2195
|
+
|
|
2196
|
+
## Step 3 — Wire it up
|
|
2197
|
+
|
|
2198
|
+
For each surface in the confirmed set, read the relevant sub-resource and execute its procedure. Each sub-resource is the full per-surface playbook (MCP tools + SDK init per stack + common follow-ups + re-run behavior):
|
|
2199
|
+
|
|
2200
|
+
- **identity** (auth, anonymous/Apple/Google/OTP/magic-link, link/unlink) → \`amba://setup/identity\`
|
|
2201
|
+
- **engagement** (push, segments, content libraries, onboarding flows, deeplinks, referrals, tracked links) → \`amba://setup/engagement\`
|
|
2202
|
+
- **gamification** (XP rules, achievements, streaks, leaderboards, challenges) → \`amba://setup/gamification\`
|
|
2203
|
+
- **economy** (currencies, catalog, stores, inventory) → \`amba://setup/economy\`
|
|
2204
|
+
- **social** (friends, groups, feeds, messaging, moderation, reviews) → \`amba://setup/social\`
|
|
2205
|
+
- **infrastructure** (collections / DB tables, functions, analytics, AI prompts, media, secrets, configs, integrations, sites) → \`amba://setup/infrastructure\`
|
|
2206
|
+
|
|
2207
|
+
The general flow for every surface:
|
|
2208
|
+
|
|
2209
|
+
1. **Detect stack.** Look at \`package.json\`, \`pubspec.yaml\`, \`build.gradle.kts\`, \`ios/*.xcodeproj\`. The detection rules:
|
|
2210
|
+
- \`pubspec.yaml\` present → Flutter.
|
|
2211
|
+
- \`package.json\` with \`expo\` → Expo.
|
|
2212
|
+
- \`package.json\` with \`react-native\` (no \`expo\`) → bare React Native.
|
|
2213
|
+
- \`package.json\` with \`react\` (no \`react-native\`) → web (or Next.js — same SDK).
|
|
2214
|
+
- \`Package.swift\` or \`*.xcodeproj\` only → iOS Swift.
|
|
2215
|
+
- \`build.gradle.kts\` or \`build.gradle\` with \`com.android.application\` → Android Kotlin.
|
|
2216
|
+
- Multiple (e.g. \`ios/\` + \`android/\` inside an Expo repo) → Expo wins.
|
|
2217
|
+
|
|
2218
|
+
2. **Create resources via MCP.** Call the \`amba_<surface>_create\` tools to mint the definitions. Always include \`project_id\` from the project you created in Step 0. Always show the user the tool call before making destructive changes (creating a resource isn't destructive — but creating 30 of them is noisy).
|
|
2219
|
+
|
|
2220
|
+
3. **Write SDK init code.** Drop the per-stack snippet (from the sub-resource) into the user's entry file. Detection:
|
|
2221
|
+
- Expo / React Native: \`app/_layout.tsx\`, \`App.tsx\`, \`index.js\` (in that order)
|
|
2222
|
+
- web / Next.js: \`app/layout.tsx\`, \`pages/_app.tsx\`, \`src/main.tsx\`, \`src/App.tsx\`
|
|
2223
|
+
- iOS Swift: \`Sources/<App>/<App>App.swift\`, \`App/AppDelegate.swift\`
|
|
2224
|
+
- Android Kotlin: \`app/src/main/java/.../<App>.kt\` (the \`Application\` subclass — create one if missing)
|
|
2225
|
+
- Flutter: \`lib/main.dart\`
|
|
2226
|
+
|
|
2227
|
+
Always make additive edits — \`await Amba.configure(...)\` next to existing init, not replacing it. Never refactor existing auth or storage code; if the user has Firebase Auth or Supabase, leave it. Amba's auth is opt-in per call.
|
|
2228
|
+
|
|
2229
|
+
4. **Run the project's existing test command** to confirm nothing broke. Detection:
|
|
2230
|
+
- \`package.json\` \`scripts.test\` → \`npm test\` (or \`pnpm test\` if \`pnpm-lock.yaml\` present)
|
|
2231
|
+
- \`pubspec.yaml\` → \`flutter test\`
|
|
2232
|
+
- \`build.gradle.kts\` → \`./gradlew test\` (skip on first wire-up — slow)
|
|
2233
|
+
- iOS — skip (need a simulator).
|
|
2234
|
+
|
|
2235
|
+
If tests fail because of your edits, undo the offending edit and surface a clear error. If they fail for unrelated reasons (pre-existing red), note it and proceed.
|
|
2236
|
+
|
|
2237
|
+
5. **Verify with the SDK.** Tell the user to call \`Amba.diagnostics.ping()\` (\`Amba.Diagnostics.Ping()\` on Unity) in their entry file. It returns \`{ ok, server_project_id, environment, key_fingerprint, latency_ms }\`. \`ok: true\` with the expected \`server_project_id\` confirms the wiring.
|
|
2238
|
+
|
|
2239
|
+
## Step 4 — Report
|
|
2240
|
+
|
|
2241
|
+
Tell the user a structured summary. Use this exact shape so they can skim it fast:
|
|
2242
|
+
|
|
2243
|
+
\`\`\`
|
|
2244
|
+
Amba is wired in. Here's what changed:
|
|
2245
|
+
|
|
2246
|
+
DONE
|
|
2247
|
+
- identity: Apple + Google sign-in available; signInAnonymously() called at app start
|
|
2248
|
+
- gamification: 3 achievements, 1 streak, 1 leaderboard created
|
|
2249
|
+
resources: first_workout, week_warrior, century_club / daily_workout / weekly_xp
|
|
2250
|
+
- engagement: push registration wired; default segment "active_users" created
|
|
2251
|
+
|
|
2252
|
+
SKIPPED (low signal — re-run with /amba <feature> if you want them)
|
|
2253
|
+
- economy: no in-app currency UI found in your screens
|
|
2254
|
+
- social: no friends/feed surfaces found
|
|
2255
|
+
|
|
2256
|
+
NEEDS YOUR INPUT
|
|
2257
|
+
- Apple Sign In: add the "Sign in with Apple" capability in Xcode > Signing & Capabilities.
|
|
2258
|
+
- Google Sign In: paste your Google OAuth client ID into amba_projects_update({ google_oauth_client_id: "..." }).
|
|
2259
|
+
- APNs / FCM: upload credentials in app.amba.dev before push delivers.
|
|
2260
|
+
|
|
2261
|
+
NEXT STEPS
|
|
2262
|
+
- Paste AMBA_CLIENT_KEY into your build env (already shown above)
|
|
2263
|
+
- Trigger a workout in your existing flow — watch the achievement unlock + XP land
|
|
2264
|
+
- Open https://app.amba.dev to see users pour in
|
|
2265
|
+
\`\`\`
|
|
2266
|
+
|
|
2267
|
+
Be specific. List resources by key, not "some achievements". If something needs the user's input (third-party credentials, OAuth client IDs, push certs), say it clearly with the exact next action.
|
|
2268
|
+
|
|
2269
|
+
## Stance (read this once)
|
|
2270
|
+
|
|
2271
|
+
- **Don't ask which surfaces to use.** Classify, then confirm in one multi-choice. The taxonomy is the whole point.
|
|
2272
|
+
- **Default to additive, non-breaking changes.** Don't refactor existing auth, storage, or networking code. Drop in \`await Amba.configure(...)\` next to whatever the user already has.
|
|
2273
|
+
- **Never create resources without the user's confirmation in Step 2.** A 3rd-party "convenience" achievement called \`first_login\` is debt.
|
|
2274
|
+
- **If something is genuinely ambiguous** (leaderboard scope, currency real-money vs virtual, dating phone vs email), ask via a follow-up multi-choice. Don't guess and don't paragraph-it.
|
|
2275
|
+
- **clientKey vs serverKey.** \`AMBA_CLIENT_KEY\` (\`amb_dev_ck_…\` in dev, \`amb_live_ck_…\` in prod) ships to user devices. \`AMBA_SERVER_KEY\` (\`amb_dev_sk_…\` / \`amb_live_sk_…\`) never does — only into server \`.env\` or a secret manager. Mixing them is the #1 security mistake; if you're writing into a file that ships with the app binary, it's the client key, period.
|
|
2276
|
+
- **Don't echo the PAT in chat output on every call.** Showing it once after signup is fine; do not repeat it.
|
|
2277
|
+
|
|
2278
|
+
## Get credentials (cheat sheet)
|
|
2279
|
+
|
|
2280
|
+
- No terminal, in an MCP client: call \`amba_developer_signup\` (no Bearer required) — this guide's Step 0.
|
|
2281
|
+
- With a terminal: \`npx -y @layers/amba init\` signs up, mints a project + client/server keys, writes \`.env.local\` + \`AMBA.md\`, installs the \`/amba\` skill, and wires \`mcpServers.amba\` into every detected MCP-client config in one command. Auto-detects non-TTY invocations (the coding-agent bash-tool case) and runs headlessly.
|
|
2282
|
+
- Bind the sandbox account to a real email later: \`npx @layers/amba claim me@example.com\`. The backend emails a one-click magic link; clicking it lifts the sandbox cap to the Free tier.
|
|
2283
|
+
- Hosted MCP endpoint: \`https://mcp.amba.dev/mcp\` (Streamable HTTP, Bearer auth).
|
|
2284
|
+
|
|
2285
|
+
## SDKs
|
|
2286
|
+
|
|
2287
|
+
| Stack | Registry | Package |
|
|
2288
|
+
|---|---|---|
|
|
2289
|
+
| Browser / Node / React / React Native / Expo | npm | \`@layers/amba-{web,node,react,react-native,expo}\` |
|
|
2290
|
+
| Swift | SPM | \`https://github.com/layers/amba-sdk-ios\` |
|
|
2291
|
+
| Kotlin | Maven Central | \`com.layers.amba:amba-sdk-android\` |
|
|
2292
|
+
| Flutter | pub.dev | \`amba\` |
|
|
2293
|
+
| Unity | UPM (git) | \`https://github.com/layers/amba-sdk-unity.git\` |
|
|
2294
|
+
|
|
2295
|
+
All SDKs expose the same surface: \`Amba.configure({ projectId, apiKey })\`, then \`Amba.events.track(...)\`, \`Amba.users.*\`, \`Amba.collections.*\`, etc. Per-stack quickstart pages with the exact initialization snippet: \`https://docs.amba.dev/sdk/<framework>\`.
|
|
2296
|
+
|
|
2297
|
+
## What Amba does
|
|
2298
|
+
|
|
2299
|
+
### Identity
|
|
2300
|
+
- **users** — app-user registry. Auto-created on first SDK call; admin via \`amba_users_*\`.
|
|
2301
|
+
- **roles + permissions** — RBAC. Define with \`amba_roles_create\`; assign via \`amba_roles_assign\`.
|
|
2302
|
+
- **api_keys** — client + server keys per project. Mint via \`amba_api_keys_create\`.
|
|
2303
|
+
|
|
2304
|
+
### Engagement
|
|
2305
|
+
- **onboarding** — multi-step first-run flows. Define with \`amba_onboarding_create\`; SDK \`Amba.onboarding.next()\`.
|
|
2306
|
+
- **segments** — user cohorts. Define with \`amba_segments_create\`; used as push/feed targets.
|
|
2307
|
+
- **push** — scheduled or triggered notifications. Chain: configure integrations (apns/fcm) → \`amba_push_campaigns_create\` → \`amba_push_campaigns_send\` (or schedule).
|
|
2308
|
+
- **referrals** — referral codes. Define with \`amba_referrals_create\`.
|
|
2309
|
+
- **deeplinks** — universal links. Set domain with \`amba_deeplinks_set_config\`.
|
|
2310
|
+
- **tracked_links** — UTM-tagged outbound links. Define with \`amba_tracked_links_create\`.
|
|
2311
|
+
- **content** — episodic delivery (lessons, quotes, daily prompts). Chain: \`amba_content_libraries_create\` → \`amba_content_items_add\` → \`amba_content_schedules_create\`.
|
|
2312
|
+
|
|
2313
|
+
### Gamification
|
|
2314
|
+
- **xp** — experience points + level. Define rules with \`amba_xp_rules_create\`; SDK \`Amba.xp.getBalance\`.
|
|
2315
|
+
- **achievements** — earnable badges. Define with \`amba_achievements_create\`; unlock via xp rules or \`amba_inventory_grant_item\`.
|
|
2316
|
+
- **streaks** — recurring engagement counters. Define with \`amba_streaks_create\`; client calls \`Amba.streaks.qualify(key)\`.
|
|
2317
|
+
- **leaderboards** — ranked user lists. Define with \`amba_leaderboards_create\`; populated from events.
|
|
2318
|
+
- **challenges** — time-bounded goals. Define with \`amba_challenges_create\`; progress via SDK.
|
|
2319
|
+
|
|
2320
|
+
### Economy
|
|
2321
|
+
- **currencies** — virtual currencies (coins, gems). Define with \`amba_currencies_create\`; grant via \`amba_currencies_grant\` or event rules via \`amba_currency_grant_rules_create\`.
|
|
2322
|
+
- **catalog + stores** — purchasable items + storefronts. Chain: \`amba_catalog_items_create\` → \`amba_catalog_items_set_price\` → \`amba_stores_create\` → \`amba_stores_add_listing\`. (Define currency first.)
|
|
2323
|
+
- **inventory** — items users own. Read via SDK \`Amba.inventory.*\`; grant with \`amba_inventory_grant_item\`.
|
|
2324
|
+
|
|
2325
|
+
### Social
|
|
2326
|
+
- **friendships** — friend graph. SDK \`Amba.friends.*\`; admin via \`amba_friendships_*\`.
|
|
2327
|
+
- **groups** — guilds/parties/chats. Define with \`amba_groups_create\`; members managed via SDK + admin tools.
|
|
2328
|
+
- **messaging** — DMs + group chat. Enabled by default; moderate via \`amba_messaging_*\`.
|
|
2329
|
+
- **feeds** — algorithmic activity feeds. Define ranking with \`amba_feeds_rules_create\`.
|
|
2330
|
+
- **reviews** — user-submitted reviews. Enabled by default; moderate via \`amba_reviews_*\`.
|
|
2331
|
+
- **moderation** — content review queue + trust scores. Configure with \`amba_moderation_configure\`; review via \`amba_moderation_queue_list\`.
|
|
2332
|
+
|
|
2333
|
+
### Analytics
|
|
2334
|
+
- **events** — track user actions. SDK \`Amba.events.track()\`; query via \`amba_events_count\`.
|
|
2335
|
+
- **sessions** — session telemetry. Tracked automatically; query via \`amba_sessions_list\`.
|
|
2336
|
+
- **analytics** — funnels + retention. Query via \`amba_analytics_get\`.
|
|
2337
|
+
|
|
2338
|
+
### Infrastructure
|
|
2339
|
+
- **collections** — your own typed key-value tables. Define with \`amba_collections_create\`; read/write from SDK \`Amba.client.*\`.
|
|
2340
|
+
- **functions** — serverless TypeScript handlers. Deploy with \`amba_functions_deploy\`; schedule with \`amba_functions_schedule\`.
|
|
2341
|
+
- **sites** — static site hosting at \`*.app.amba.host\`. Deploy with \`amba_sites_deploy\`.
|
|
2342
|
+
- **media** — file storage + CDN. Upload via \`amba_media_upload\`.
|
|
2343
|
+
- **secrets** — env vars for functions. Set via \`amba_secrets_set\`.
|
|
2344
|
+
- **configs** — remote config flags. Define with \`amba_configs_create\`.
|
|
2345
|
+
- **integrations** — third-party webhooks (RevenueCat, Superwall, AppsFlyer, etc.). Configure with \`amba_integrations_configure\`.
|
|
2346
|
+
- **ai_prompts** — versioned LLM prompts callable from SDK. Define with \`amba_ai_prompts_create\`; call via \`amba_ai_prompts_invoke\`.
|
|
2347
|
+
`;
|
|
2348
|
+
/**
|
|
2349
|
+
* Canonical Amba Expo build prompt — markdown body (no MDX frontmatter).
|
|
2350
|
+
*
|
|
2351
|
+
* Source of truth for three customer-facing surfaces:
|
|
2352
|
+
*
|
|
2353
|
+
* 1. The published docs page at
|
|
2354
|
+
* `https://docs.amba.dev/prompts/expo-build` — the MDX file at
|
|
2355
|
+
* `apps/docs/content/docs/prompts/expo-build.mdx` ships the same
|
|
2356
|
+
* body wrapped in fumadocs frontmatter.
|
|
2357
|
+
* 2. The MCP resource `amba://prompts/expo-build` registered by
|
|
2358
|
+
* `registerAllResources()` in `./index.ts` and exposed by the
|
|
2359
|
+
* hosted MCP server at `mcp.amba.dev`.
|
|
2360
|
+
* 3. The inlined snapshot baked into the `/amba-build` Claude Code
|
|
2361
|
+
* skill by `amba init --sandbox` (see `packages/cli/src/skills.ts`).
|
|
2362
|
+
*
|
|
2363
|
+
* Drift between this constant and the MDX file is caught by
|
|
2364
|
+
* `expo-build-prompt.test.ts` — that test reads the MDX from disk,
|
|
2365
|
+
* strips the YAML frontmatter, and asserts it equals `EXPO_BUILD_PROMPT_MD`.
|
|
2366
|
+
*
|
|
2367
|
+
* **Update protocol:** edit the MDX (it's the human-facing surface;
|
|
2368
|
+
* it renders on docs.amba.dev). Re-run the drift test. The test will
|
|
2369
|
+
* fail with a diff. Apply the same diff here. The two are kept in
|
|
2370
|
+
* sync by hand because the MDX must be statically parseable for
|
|
2371
|
+
* fumadocs + we can't import `.md` files as raw strings without a
|
|
2372
|
+
* build-step that pulls in extra config.
|
|
2373
|
+
*
|
|
2374
|
+
* The body itself is plain CommonMark — no MDX components, no JSX —
|
|
2375
|
+
* so it renders identically as `.md` (the MCP / skill consumers) and
|
|
2376
|
+
* as `.mdx` (the docs site).
|
|
2377
|
+
*/
|
|
2378
|
+
const EXPO_BUILD_PROMPT_MD = `> **Last reviewed:** 2026-05-17. The canonical version of this page lives
|
|
2379
|
+
> at [docs.amba.dev/prompts/expo-build](https://docs.amba.dev/prompts/expo-build).
|
|
2380
|
+
> If you're reading an inlined snapshot from your
|
|
2381
|
+
> \`.claude/skills/amba-build/SKILL.md\`, check the URL above for updates.
|
|
2382
|
+
|
|
2383
|
+
This is the prompt an AI coding agent runs to build a full Expo app
|
|
2384
|
+
where Amba is the only backend. It's structured as a single \`/goal\`
|
|
2385
|
+
directive — paste it, replace \`<DESIGN_HASH>\` with whatever describes
|
|
2386
|
+
your design (a URL, a description, a Figma link), and let the agent
|
|
2387
|
+
execute.
|
|
2388
|
+
|
|
2389
|
+
## Quick setup
|
|
2390
|
+
|
|
2391
|
+
The CLI handles signup, project provisioning, env-file writes, and MCP
|
|
2392
|
+
client config wiring in one command:
|
|
2393
|
+
|
|
2394
|
+
\`\`\`bash
|
|
2395
|
+
npx -y @layers/amba init
|
|
2396
|
+
\`\`\`
|
|
2397
|
+
|
|
2398
|
+
That's the entire setup. The CLI:
|
|
2399
|
+
|
|
2400
|
+
1. Signs up an agent-mode developer account (no browser, no email
|
|
2401
|
+
verification needed for sandbox).
|
|
2402
|
+
2. Creates an Amba project and mints a client key + admin PAT.
|
|
2403
|
+
3. Writes \`.env.local\` (\`AMBA_PROJECT_ID\`, \`AMBA_CLIENT_KEY\`,
|
|
2404
|
+
\`AMBA_API_URL\`).
|
|
2405
|
+
4. Writes \`AMBA.md\` (project-scoped context for the agent).
|
|
2406
|
+
5. Auto-wires \`mcpServers.amba\` into every MCP client config it
|
|
2407
|
+
detects on disk — Claude Code, Cursor, Windsurf.
|
|
2408
|
+
6. Verifies the PAT against the API and confirms it's good.
|
|
2409
|
+
|
|
2410
|
+
The Amba MCP toolset (\`amba_*\` tools — ~130 of them) is available to
|
|
2411
|
+
the agent immediately: pass the freshly-minted \`pat\` as an inline
|
|
2412
|
+
argument on every \`amba_*\` call in the current session. The next time
|
|
2413
|
+
your MCP client starts it picks the PAT up from the config as the
|
|
2414
|
+
inbound Bearer automatically — at that point the \`pat\` arg becomes
|
|
2415
|
+
optional. No restart needed; nothing for you to do.
|
|
2416
|
+
|
|
2417
|
+
If you have the \`/amba-build\` skill installed (via
|
|
2418
|
+
\`npx -y @layers/amba init\`), invoke it directly:
|
|
2419
|
+
|
|
2420
|
+
\`\`\`
|
|
2421
|
+
/amba-build <DESIGN_HASH>
|
|
2422
|
+
\`\`\`
|
|
2423
|
+
|
|
2424
|
+
Otherwise paste the prompt below.
|
|
2425
|
+
|
|
2426
|
+
## Current known gotchas
|
|
2427
|
+
|
|
2428
|
+
Three remaining wrinkles you may hit. Everything else from the 2026-05
|
|
2429
|
+
DX cascade is fixed.
|
|
2430
|
+
|
|
2431
|
+
- **Web CORS** — the public API does not currently send
|
|
2432
|
+
\`Access-Control-Allow-Origin\` for browser-origin requests. Use the
|
|
2433
|
+
agent's circuit-break-on-second-failure rule for web targets; for
|
|
2434
|
+
Expo (iOS + Android) you'll never see this.
|
|
2435
|
+
- **React Native bundle size** — the React Native SDK adds ~4 MB to
|
|
2436
|
+
the JS bundle today. Functional, just heavier than the long-term
|
|
2437
|
+
goal. Tracked separately.
|
|
2438
|
+
- **Sandbox MAU cap (100)** — the agent-mode sandbox tier caps at 100
|
|
2439
|
+
monthly active users. If you blow through it during testing, call
|
|
2440
|
+
\`amba_users_reset_sandbox\` to clear the counter — that tool exists
|
|
2441
|
+
specifically for this. Upgrade to the Free tier (1,000 MAU, 500 MB
|
|
2442
|
+
DB) by running \`amba claim me@example.com\` from the terminal — the
|
|
2443
|
+
backend emails a one-click magic link to the address you pass in;
|
|
2444
|
+
clicking it binds the account to that email and lifts the cap.
|
|
2445
|
+
|
|
2446
|
+
## How to read the design
|
|
2447
|
+
|
|
2448
|
+
If \`<DESIGN_HASH>\` is a URL to a packaged design (e.g. a download
|
|
2449
|
+
link from your design tool of choice), unpack it before you start:
|
|
2450
|
+
|
|
2451
|
+
\`\`\`bash
|
|
2452
|
+
mkdir -p design && cd design
|
|
2453
|
+
curl -L "<DESIGN_HASH>" -o design.tar.gz
|
|
2454
|
+
gunzip -c design.tar.gz | tar -x
|
|
2455
|
+
ls
|
|
2456
|
+
# Expected: README, chats/, project/ (or equivalent)
|
|
2457
|
+
\`\`\`
|
|
2458
|
+
|
|
2459
|
+
Read the README first — it should tell you what each subdirectory
|
|
2460
|
+
holds. The \`chats/\` directory typically contains conversation logs
|
|
2461
|
+
that capture the design intent in dialog form; treat them as the
|
|
2462
|
+
authoritative source for tone and feature priorities. The \`project/\`
|
|
2463
|
+
directory holds the structured asset graph (screens, components,
|
|
2464
|
+
styles).
|
|
2465
|
+
|
|
2466
|
+
If \`<DESIGN_HASH>\` is a freeform description (not a URL), skip the
|
|
2467
|
+
unpacking and treat the description text as the design brief.
|
|
2468
|
+
|
|
2469
|
+
## Use Amba for everything
|
|
2470
|
+
|
|
2471
|
+
The rule: any feature that touches data, identity, scheduling,
|
|
2472
|
+
notifications, content, or social — use Amba. Don't reach for
|
|
2473
|
+
AsyncStorage-as-database, don't bring in Firebase / Supabase / your
|
|
2474
|
+
own server, don't roll a custom auth layer. The point of this build
|
|
2475
|
+
is that Amba covers it all.
|
|
2476
|
+
|
|
2477
|
+
Specifically: every feature in the design that needs a backend maps
|
|
2478
|
+
to an Amba primitive. If you can't find a fit, the rule is **escalate
|
|
2479
|
+
in the gaps log** (see the verification gate), not "ship without
|
|
2480
|
+
Amba." Skipping a primitive needs a written justification — the
|
|
2481
|
+
verification gate enforces this.
|
|
2482
|
+
|
|
2483
|
+
## Feature → Amba primitive map
|
|
2484
|
+
|
|
2485
|
+
| App feature | Amba primitive | MCP tools |
|
|
2486
|
+
| -------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2487
|
+
| User accounts (anonymous + Apple + Google + email) | Auth | \`amba_developer_signup\` (one-time bootstrap), \`Amba.signIn()\` SDK calls |
|
|
2488
|
+
| Profile data (name, avatar, prefs) | App users | \`amba_users_list\`, \`amba_users_get\`, \`amba_users_bulk_update\` |
|
|
2489
|
+
| 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\` |
|
|
2490
|
+
| Push notifications | Push campaigns | \`amba_push_campaigns_create\`, \`amba_push_campaigns_send\`, \`amba_push_send_test\`, \`amba_push_list_campaigns\` |
|
|
2491
|
+
| User segments (e.g. inactive 7d, premium) | Segments | \`amba_segments_create\`, \`amba_segments_list\`, \`amba_segments_evaluate\` |
|
|
2492
|
+
| Daily streaks | Streaks | \`amba_streaks_create\`, \`amba_streaks_list\` (call \`streaks.qualify()\` from the SDK to record activity) |
|
|
2493
|
+
| XP and levels | XP rules | \`amba_xp_rules_create\`, \`amba_xp_rules_list\`, \`amba_users_get_xp\` |
|
|
2494
|
+
| Achievements / badges | Achievements | \`amba_achievements_create\`, \`amba_achievements_list\`, \`amba_achievements_get\` |
|
|
2495
|
+
| Challenges (time-limited goals) | Challenges | \`amba_challenges_create\`, \`amba_challenges_list\`, \`amba_challenges_list_participants\` |
|
|
2496
|
+
| Leaderboards | Leaderboards | \`amba_leaderboards_create\`, \`amba_leaderboards_list\`, \`amba_leaderboards_get\` |
|
|
2497
|
+
| 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\` |
|
|
2498
|
+
| 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.*\`) |
|
|
2499
|
+
| Remote feature flags / config | Configs | \`amba_configs_create\`, \`amba_configs_list\`, \`amba_configs_update\` |
|
|
2500
|
+
| Entitlements (premium / paywall) | RevenueCat / Superwall integration | \`amba_integrations_configure\`, \`amba_integrations_test\` |
|
|
2501
|
+
| 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.*\` |
|
|
2502
|
+
| Analytics / event tracking | Events | \`Amba.events.track(...)\` from the SDK; query with \`amba_analytics_get\`, \`amba_users_list_events\` |
|
|
2503
|
+
|
|
2504
|
+
Every primitive above has list / read MCP tools you can use to verify
|
|
2505
|
+
seed data after creation — the verification gate uses these to catch
|
|
2506
|
+
"fake implementation" failure modes (where the app code thinks a thing
|
|
2507
|
+
was created but nothing actually landed in the backend).
|
|
2508
|
+
|
|
2509
|
+
## Seed data
|
|
2510
|
+
|
|
2511
|
+
Before writing app code, seed the backend with enough data that every
|
|
2512
|
+
screen in the design has something realistic to render. Order:
|
|
2513
|
+
|
|
2514
|
+
1. **Configs** — feature flags + tunable constants the app reads at
|
|
2515
|
+
boot (\`amba_configs_create\`).
|
|
2516
|
+
2. **Segments** — at least one (e.g. "new_user", first 7 days) so
|
|
2517
|
+
targeting works downstream.
|
|
2518
|
+
3. **Content libraries + schedules** — daily content for any
|
|
2519
|
+
tips/quotes/lessons screen. Seed ≥30 items so the carousel /
|
|
2520
|
+
day-stepper doesn't loop visibly.
|
|
2521
|
+
4. **Streaks** — define the streak shape (daily / weekly, grace
|
|
2522
|
+
window, freeze policy).
|
|
2523
|
+
5. **XP rules** — events → XP-award rules so XP accrues from real
|
|
2524
|
+
gameplay.
|
|
2525
|
+
6. **Achievements** — unlock criteria for badges.
|
|
2526
|
+
7. **Challenges** — at least one active challenge with rewards.
|
|
2527
|
+
8. **Leaderboards** — XP, streaks, or any custom metric.
|
|
2528
|
+
9. **Currencies + catalog + stores** — virtual currency, catalog
|
|
2529
|
+
items, store listings (only if the design has an economy screen).
|
|
2530
|
+
10. **Collections** — schemas + sample rows for any custom data the
|
|
2531
|
+
app needs (e.g. user-generated content, journal entries, custom
|
|
2532
|
+
list items).
|
|
2533
|
+
11. **Push campaigns** — at least one welcome push + one re-engagement
|
|
2534
|
+
push targeting your "new_user" segment.
|
|
2535
|
+
|
|
2536
|
+
After seeding, the verification gate (below) confirms each primitive
|
|
2537
|
+
exists by calling the matching \`amba_*_list\` MCP tool. Empty list →
|
|
2538
|
+
failure.
|
|
2539
|
+
|
|
2540
|
+
## Engineering rules
|
|
2541
|
+
|
|
2542
|
+
These are non-negotiable. Violating any one of them fails the build
|
|
2543
|
+
gate.
|
|
2544
|
+
|
|
2545
|
+
- **Expo Router with typed routes.** Use \`expo-router\` and enable
|
|
2546
|
+
\`experiments.typedRoutes\` in \`app.json\`. Every screen is a
|
|
2547
|
+
filesystem route; no manual navigation stacks.
|
|
2548
|
+
- **TypeScript strict mode.** \`strict: true\` in \`tsconfig.json\`. Zero
|
|
2549
|
+
\`any\`. Zero \`@ts-ignore\`. \`tsc --noEmit\` must pass.
|
|
2550
|
+
- **React Native primitives only.** \`View\`, \`Text\`, \`Pressable\`,
|
|
2551
|
+
\`ScrollView\`, \`FlatList\`, \`Image\`. No \`div\`, no \`span\`, no DOM-only
|
|
2552
|
+
libs. The build target is iOS + Android + Web — every screen has to
|
|
2553
|
+
render on all three.
|
|
2554
|
+
- **Fonts via expo-font.** Don't ship system-font-only screens; load
|
|
2555
|
+
the design's typography via \`useFonts\` and gate the splash screen
|
|
2556
|
+
on load.
|
|
2557
|
+
- **Persistence via AsyncStorage.** Anything you cache client-side
|
|
2558
|
+
(theme choice, last-viewed-item, dismissed banners) goes in
|
|
2559
|
+
AsyncStorage. Never sprinkle direct file I/O.
|
|
2560
|
+
- **Theme system.** A single \`theme.ts\` exports light + dark token
|
|
2561
|
+
maps; consume via a \`useTheme()\` hook. The verification gate
|
|
2562
|
+
toggles light ↔ dark and screenshots; if any screen has hardcoded
|
|
2563
|
+
colors that don't flip, the gate fails.
|
|
2564
|
+
- **Circuit-break on second failure.** If two consecutive Amba API
|
|
2565
|
+
calls fail with the same error, stop retrying and surface a clean
|
|
2566
|
+
empty-state to the user. Don't loop forever; don't crash. The web
|
|
2567
|
+
CORS issue (above) is the most likely trigger.
|
|
2568
|
+
- **Deterministic offline fallback.** When \`fetch\` fails (airplane
|
|
2569
|
+
mode, network drop), the app renders **deterministic** placeholder
|
|
2570
|
+
content — same content per \`userId + day\` — never random. Real data
|
|
2571
|
+
swaps in when the network returns.
|
|
2572
|
+
- **Three-platform bundle gate.** \`expo export --platform web\`,
|
|
2573
|
+
\`expo export --platform ios\`, and \`expo export --platform android\`
|
|
2574
|
+
must all succeed. If any one fails, the build fails. No
|
|
2575
|
+
"shipped iOS-only, web is broken" — the rule is parity.
|
|
2576
|
+
- **Don't name a tab \`settings.tsx\`.** Use \`account.tsx\` or
|
|
2577
|
+
\`preferences.tsx\` instead. Expo Router's static web export generates
|
|
2578
|
+
\`settings.html\` correctly but does not resolve direct URL navigation
|
|
2579
|
+
to \`/settings\` — the client-side router shows an unmatched-route
|
|
2580
|
+
error while other tab names work fine. (Observed in dogfood; upstream
|
|
2581
|
+
behavior, not an Amba issue.)
|
|
2582
|
+
|
|
2583
|
+
## Verification gate
|
|
2584
|
+
|
|
2585
|
+
Before declaring the build done, run every check in this list. Any
|
|
2586
|
+
failure means the build is not done — fix and re-run.
|
|
2587
|
+
|
|
2588
|
+
\`\`\`bash
|
|
2589
|
+
# Type-check
|
|
2590
|
+
pnpm tsc --noEmit
|
|
2591
|
+
|
|
2592
|
+
# Three-platform export
|
|
2593
|
+
pnpm expo export --platform web
|
|
2594
|
+
pnpm expo export --platform ios
|
|
2595
|
+
pnpm expo export --platform android
|
|
2596
|
+
\`\`\`
|
|
2597
|
+
|
|
2598
|
+
Then, from inside the agent (use the Amba MCP tools):
|
|
2599
|
+
|
|
2600
|
+
- \`amba_analytics_get\` → at least one event tracked end-to-end
|
|
2601
|
+
through \`Amba.events.track()\` from the app.
|
|
2602
|
+
- \`amba_users_list\` → at least one user exists (the agent's own
|
|
2603
|
+
anonymous signin counts).
|
|
2604
|
+
- For every primitive the seed step created, call the matching
|
|
2605
|
+
\`amba_*_list\` and assert non-empty:
|
|
2606
|
+
- \`amba_configs_list\`
|
|
2607
|
+
- \`amba_segments_list\`
|
|
2608
|
+
- \`amba_content_list_libraries\`, \`amba_content_list_items\`,
|
|
2609
|
+
\`amba_content_list_schedules\`
|
|
2610
|
+
- \`amba_streaks_list\`
|
|
2611
|
+
- \`amba_xp_rules_list\`
|
|
2612
|
+
- \`amba_achievements_list\`
|
|
2613
|
+
- \`amba_challenges_list\`
|
|
2614
|
+
- \`amba_leaderboards_list\`
|
|
2615
|
+
- \`amba_currencies_list\` (if economy seeded)
|
|
2616
|
+
- \`amba_catalog_list\` (if catalog seeded)
|
|
2617
|
+
- \`amba_collections_list\` + \`amba_admin_list_rows\` per collection
|
|
2618
|
+
- \`amba_push_list_campaigns\`
|
|
2619
|
+
- Empty list for any seeded primitive → the implementation is fake
|
|
2620
|
+
(UI exists but never wrote to the backend). Failure.
|
|
2621
|
+
- Manually walk every route in the browser (\`expo start --web\`),
|
|
2622
|
+
screenshot each, and confirm:
|
|
2623
|
+
- Light theme renders cleanly.
|
|
2624
|
+
- Dark theme renders cleanly (toggle and re-screenshot every
|
|
2625
|
+
route).
|
|
2626
|
+
- Empty states render when collections are empty (fresh-install
|
|
2627
|
+
simulation: wipe AsyncStorage, reload).
|
|
2628
|
+
- \`amba_users_reset_sandbox\` to confirm you can recover from the MAU
|
|
2629
|
+
cap if you blew past 50 during testing.
|
|
2630
|
+
|
|
2631
|
+
Skipping any primitive's seed step requires a one-line written
|
|
2632
|
+
justification in the gaps log (next section). "We don't need
|
|
2633
|
+
streaks" is fine; silence is not.
|
|
2634
|
+
|
|
2635
|
+
## Final output
|
|
2636
|
+
|
|
2637
|
+
When done, write a final report to \`BUILD_REPORT.md\` in the project
|
|
2638
|
+
root. Required sections:
|
|
2639
|
+
|
|
2640
|
+
- **Start timestamp** (when the agent started).
|
|
2641
|
+
- **End timestamp** (when the verification gate last passed).
|
|
2642
|
+
- **MCP call inventory** — every \`amba_*\` tool you invoked, with a
|
|
2643
|
+
count. Lets a human reviewer audit "did this agent actually use
|
|
2644
|
+
Amba for X" at a glance.
|
|
2645
|
+
- **Primitive coverage table** — one row per primitive from the
|
|
2646
|
+
Feature → Amba primitive map. Mark each ✅ (used), ⚠️ (used with
|
|
2647
|
+
caveats — explain), or ⏭ (skipped — justify in one line).
|
|
2648
|
+
- **Gaps log** — every primitive you skipped, every feature you
|
|
2649
|
+
couldn't fit cleanly to an Amba primitive, every workaround. One
|
|
2650
|
+
line per gap, no marketing language.
|
|
2651
|
+
- **\`seed-report.json\`** — machine-readable seed summary:
|
|
2652
|
+
\`{ "primitive": "<name>", "created": <count>, "listed": <count> }\`
|
|
2653
|
+
for every primitive. The \`listed\` count comes from the
|
|
2654
|
+
\`amba_*_list\` call in the verification gate. \`created ===
|
|
2655
|
+
listed\` for every row is the success condition.
|
|
2656
|
+
|
|
2657
|
+
If \`BUILD_REPORT.md\` is missing any required section, or
|
|
2658
|
+
\`seed-report.json\` is missing, the build is not done.
|
|
2659
|
+
`;
|
|
2660
|
+
//#endregion
|
|
2661
|
+
//#region src/skills.ts
|
|
2662
|
+
/**
|
|
2663
|
+
* Per-agent skill / rule file installer for `amba init`.
|
|
2664
|
+
*
|
|
2665
|
+
* Two distinct surfaces, both project-local:
|
|
2666
|
+
*
|
|
2667
|
+
* 1. **Build task skill** — `.claude/skills/amba-build/SKILL.md`.
|
|
2668
|
+
* Scaffolds a full Expo app via the canonical "/goal" prompt.
|
|
2669
|
+
* Task-shaped: the user invokes it explicitly. Lives behind
|
|
2670
|
+
* `writeAmbaBuildSkill` (legacy export, unchanged).
|
|
2671
|
+
*
|
|
2672
|
+
* 2. **Reference / setup skill** — fanned out into five locations,
|
|
2673
|
+
* one per agent family, so the same Amba setup guide reaches
|
|
2674
|
+
* whatever coding agent the user has installed:
|
|
2675
|
+
*
|
|
2676
|
+
* | Surface | Path | Wrapper |
|
|
2677
|
+
* |------------------------------------------|-------------------------------|--------------------------|
|
|
2678
|
+
* | Claude Code (proactive, auto-injected) | \`CLAUDE.md\` (append) | plain markdown |
|
|
2679
|
+
* | Claude Code (invokable skill) | \`.claude/skills/amba/SKILL.md\` | \`description:\` frontmatter |
|
|
2680
|
+
* | Cursor | \`.cursor/rules/amba.mdc\` | \`alwaysApply\`/\`description\`/\`globs\` |
|
|
2681
|
+
* | Codex / Aider / Zed / Copilot / Gemini | \`AGENTS.md\` (append) | plain markdown |
|
|
2682
|
+
* | Windsurf | \`.windsurf/rules/amba.md\` | \`trigger: always_on\` |
|
|
2683
|
+
*
|
|
2684
|
+
* The two append targets (\`CLAUDE.md\`, \`AGENTS.md\`) use marker
|
|
2685
|
+
* fencing — \`<!-- AMBA-SETUP-START -->\` / \`<!-- AMBA-SETUP-END -->\` —
|
|
2686
|
+
* so a re-init refreshes only Amba's section without clobbering user
|
|
2687
|
+
* edits to the surrounding file. The standalone targets (\`.cursor\`,
|
|
2688
|
+
* \`.windsurf\`, \`.claude/skills/amba\`) live in their own files and
|
|
2689
|
+
* are overwritten wholesale per re-init.
|
|
2690
|
+
*
|
|
2691
|
+
* The shared body comes from \`@layers/amba-mcp/prompts\`
|
|
2692
|
+
* (\`AMBA_SETUP_GUIDE_MD\`) — one canonical source, five wrappers. The
|
|
2693
|
+
* CLI bundles that constant at publish time via tsdown's
|
|
2694
|
+
* \`noExternal: [/^@layers\\/amba-/]\` rule (same path \`EXPO_BUILD_PROMPT_MD\`
|
|
2695
|
+
* already uses).
|
|
2696
|
+
*
|
|
2697
|
+
* Vendor-name discipline
|
|
2698
|
+
* ----------------------
|
|
2699
|
+
* Everything written by this module is customer-facing. The body
|
|
2700
|
+
* (sourced from the MCP package) is vetted there; the wrappers below
|
|
2701
|
+
* intentionally avoid naming Cloudflare / GCP / Neon / Temporal /
|
|
2702
|
+
* Rust / WASM / UniFFI / Resend / Doppler. See \`skills.test.ts\` for
|
|
2703
|
+
* the per-writer drift gate.
|
|
2704
|
+
*/
|
|
2705
|
+
/**
|
|
2706
|
+
* Build the contents of `.claude/skills/amba-build/SKILL.md`.
|
|
2707
|
+
*
|
|
2708
|
+
* Exported as a pure function so the unit tests can assert structural
|
|
2709
|
+
* properties (frontmatter, fetcher block, inlined snapshot fence)
|
|
2710
|
+
* without round-tripping through the filesystem.
|
|
2711
|
+
*/
|
|
2712
|
+
function buildAmbaBuildSkillContent() {
|
|
2713
|
+
return `---
|
|
2714
|
+
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.
|
|
2715
|
+
---
|
|
2716
|
+
|
|
2717
|
+
# /amba-build
|
|
2718
|
+
|
|
2719
|
+
When invoked, fetch the canonical prompt from
|
|
2720
|
+
\`https://docs.amba.dev/prompts/expo-build.md\` and use it as the
|
|
2721
|
+
\`/goal\` directive for building a full Expo app with Amba as the only
|
|
2722
|
+
backend. The user supplies a design hash (URL or description) as the
|
|
2723
|
+
argument; substitute it for every \`<DESIGN_HASH>\` placeholder in the
|
|
2724
|
+
prompt before executing.
|
|
2725
|
+
|
|
2726
|
+
## Usage
|
|
2727
|
+
|
|
2728
|
+
\`\`\`
|
|
2729
|
+
/amba-build <DESIGN_HASH>
|
|
2730
|
+
\`\`\`
|
|
2731
|
+
|
|
2732
|
+
Replace \`<DESIGN_HASH>\` with:
|
|
2733
|
+
|
|
2734
|
+
- A URL to a packaged design tarball, OR
|
|
2735
|
+
- A freeform description of the design intent.
|
|
2736
|
+
|
|
2737
|
+
## Fetcher
|
|
2738
|
+
|
|
2739
|
+
\`\`\`bash
|
|
2740
|
+
curl -sf https://docs.amba.dev/prompts/expo-build.md
|
|
2741
|
+
\`\`\`
|
|
2742
|
+
|
|
2743
|
+
If \`curl\` fails (404, 5xx, network error, no internet), fall back to
|
|
2744
|
+
the **inlined snapshot** below — it was captured at CLI install time
|
|
2745
|
+
and is a complete, self-contained version of the prompt.
|
|
2746
|
+
|
|
2747
|
+
When the fetcher succeeds, prefer the live version: it's the source of
|
|
2748
|
+
truth and may have been updated since this skill was installed.
|
|
2749
|
+
|
|
2750
|
+
## Inlined snapshot
|
|
2751
|
+
|
|
2752
|
+
The block between the AMBA-BUILD-PROMPT-START / -END markers is the
|
|
2753
|
+
prompt content captured at CLI install time. Re-run
|
|
2754
|
+
\`npx @layers/amba init --sandbox\` (or a future \`amba update-skills\`
|
|
2755
|
+
command) to refresh.
|
|
2756
|
+
|
|
2757
|
+
<!-- AMBA-BUILD-PROMPT-START -->
|
|
2758
|
+
${EXPO_BUILD_PROMPT_MD}<!-- AMBA-BUILD-PROMPT-END -->
|
|
2759
|
+
`;
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Write `.claude/skills/amba-build/SKILL.md` into the target project.
|
|
2763
|
+
* Always overwrites — the inlined snapshot is meant to be regenerated
|
|
2764
|
+
* on each `amba init --sandbox` run.
|
|
2765
|
+
*/
|
|
2766
|
+
async function writeAmbaBuildSkill(options = {}) {
|
|
2767
|
+
const skillDir = join(options.baseDir ?? process.cwd(), ".claude", "skills", "amba-build");
|
|
2768
|
+
await mkdir(skillDir, { recursive: true });
|
|
2769
|
+
const skillPath = join(skillDir, "SKILL.md");
|
|
2770
|
+
await writeFile(skillPath, buildAmbaBuildSkillContent(), "utf-8");
|
|
2771
|
+
return { path: skillPath };
|
|
2772
|
+
}
|
|
2773
|
+
/**
|
|
2774
|
+
* Marker fence sentinels for the two append-targets (`CLAUDE.md`,
|
|
2775
|
+
* `AGENTS.md`). Used by `markerFencedAppend` to find + refresh the
|
|
2776
|
+
* Amba section without clobbering surrounding user content.
|
|
2777
|
+
*/
|
|
2778
|
+
const AMBA_SETUP_START_MARKER = "<!-- AMBA-SETUP-START -->";
|
|
2779
|
+
const AMBA_SETUP_END_MARKER = "<!-- AMBA-SETUP-END -->";
|
|
2780
|
+
var AmbaSkillFileCorrupted = class extends Error {
|
|
2781
|
+
path;
|
|
2782
|
+
shape;
|
|
2783
|
+
constructor(filePath, shape) {
|
|
2784
|
+
super(`Found ${shape.startCount} AMBA-SETUP-START marker(s) and ${shape.endCount} AMBA-SETUP-END marker(s) in ${filePath}; expected exactly 1 of each in order. Refusing to auto-fix: cutting either side could discard user content. Please remove the extras (or the orphan marker) manually and re-run \`amba init\`.`);
|
|
2785
|
+
this.name = "AmbaSkillFileCorrupted";
|
|
2786
|
+
this.path = filePath;
|
|
2787
|
+
this.shape = shape;
|
|
2788
|
+
}
|
|
2789
|
+
};
|
|
2790
|
+
/**
|
|
2791
|
+
* Insert or refresh a marker-fenced block in a file.
|
|
2792
|
+
*
|
|
2793
|
+
* Marker-shape invariant: the target file must have either
|
|
2794
|
+
* (a) zero start/end markers (clean append), or
|
|
2795
|
+
* (b) exactly one start marker and one end marker, with the end
|
|
2796
|
+
* marker after the start (clean in-place refresh).
|
|
2797
|
+
*
|
|
2798
|
+
* Any other shape — orphan single marker, duplicate paired blocks,
|
|
2799
|
+
* end-before-start, mixed counts (e.g. 1 start + 2 ends) — is
|
|
2800
|
+
* treated as corruption and throws `AmbaSkillFileCorrupted`. The
|
|
2801
|
+
* caller's `warn` sink surfaces the error to the developer; the
|
|
2802
|
+
* file is left untouched. Earlier revisions tried to auto-recover
|
|
2803
|
+
* malformed states via strip-and-replace, but every recovery
|
|
2804
|
+
* heuristic risked deleting user content outside the Amba block
|
|
2805
|
+
* (BugBot cycle-5..7 all flagged adjacent failure modes — the
|
|
2806
|
+
* strict invariant kills the whole class).
|
|
2807
|
+
*
|
|
2808
|
+
* Behavior:
|
|
2809
|
+
*
|
|
2810
|
+
* - File does not exist (`ENOENT`) → create with just the block.
|
|
2811
|
+
* **Any other read error (EACCES, EISDIR, transient I/O) is
|
|
2812
|
+
* re-thrown** — we never silently overwrite a file we couldn't
|
|
2813
|
+
* read.
|
|
2814
|
+
* - File exists, zero markers → append the block (with a blank-
|
|
2815
|
+
* line separator so it doesn't fuse onto the last paragraph).
|
|
2816
|
+
* - File exists, well-formed 1+1 pair (end after start) → replace
|
|
2817
|
+
* the content between markers, preserving surrounding text.
|
|
2818
|
+
* - Any other marker shape → throw `AmbaSkillFileCorrupted` with
|
|
2819
|
+
* the observed (startCount, endCount, startIdx, endIdx).
|
|
2820
|
+
*
|
|
2821
|
+
* Returns the absolute path + whether this was a fresh create or a
|
|
2822
|
+
* refresh.
|
|
2823
|
+
*
|
|
2824
|
+
* The `body` argument is the text we want **inside** the markers —
|
|
2825
|
+
* the markers themselves are added by this helper. Callers must NOT
|
|
2826
|
+
* include the start/end marker lines in `body`.
|
|
2827
|
+
*/
|
|
2828
|
+
async function markerFencedAppend(filePath, body, startMarker, endMarker) {
|
|
2829
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
2830
|
+
let existing = null;
|
|
2831
|
+
try {
|
|
2832
|
+
existing = await readFile(filePath, "utf-8");
|
|
2833
|
+
} catch (err) {
|
|
2834
|
+
if (err?.code === "ENOENT") existing = null;
|
|
2835
|
+
else throw err;
|
|
2836
|
+
}
|
|
2837
|
+
const block = `${startMarker}\n${body}\n${endMarker}`;
|
|
2838
|
+
if (existing === null) {
|
|
2839
|
+
await writeFile(filePath, block + "\n", "utf-8");
|
|
2840
|
+
return {
|
|
2841
|
+
path: filePath,
|
|
2842
|
+
mode: "created"
|
|
2843
|
+
};
|
|
2844
|
+
}
|
|
2845
|
+
const startCount = countOccurrences(existing, startMarker);
|
|
2846
|
+
const endCount = countOccurrences(existing, endMarker);
|
|
2847
|
+
const startIdx = existing.indexOf(startMarker);
|
|
2848
|
+
const endIdx = existing.indexOf(endMarker);
|
|
2849
|
+
if (startCount === 1 && endCount === 1 && endIdx > startIdx) {
|
|
2850
|
+
const before = existing.slice(0, startIdx);
|
|
2851
|
+
const after = existing.slice(endIdx + endMarker.length);
|
|
2852
|
+
await writeFile(filePath, before + block + after, "utf-8");
|
|
2853
|
+
return {
|
|
2854
|
+
path: filePath,
|
|
2855
|
+
mode: "refreshed"
|
|
2856
|
+
};
|
|
2857
|
+
}
|
|
2858
|
+
if (startCount === 0 && endCount === 0) {
|
|
2859
|
+
const separator = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
2860
|
+
await writeFile(filePath, existing + separator + block + "\n", "utf-8");
|
|
2861
|
+
return {
|
|
2862
|
+
path: filePath,
|
|
2863
|
+
mode: "refreshed"
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
throw new AmbaSkillFileCorrupted(filePath, {
|
|
2867
|
+
startCount,
|
|
2868
|
+
endCount,
|
|
2869
|
+
startIdx,
|
|
2870
|
+
endIdx
|
|
2871
|
+
});
|
|
2872
|
+
}
|
|
2873
|
+
/**
|
|
2874
|
+
* Count non-overlapping occurrences of `needle` in `haystack`.
|
|
2875
|
+
* Used to classify the marker state of an existing file.
|
|
2876
|
+
*/
|
|
2877
|
+
function countOccurrences(haystack, needle) {
|
|
2878
|
+
if (needle.length === 0) return 0;
|
|
2879
|
+
let count = 0;
|
|
2880
|
+
let pos = 0;
|
|
2881
|
+
while (true) {
|
|
2882
|
+
const idx = haystack.indexOf(needle, pos);
|
|
2883
|
+
if (idx === -1) break;
|
|
2884
|
+
count += 1;
|
|
2885
|
+
pos = idx + needle.length;
|
|
2886
|
+
}
|
|
2887
|
+
return count;
|
|
2888
|
+
}
|
|
2889
|
+
/**
|
|
2890
|
+
* Build the canonical setup body. Sourced from the MCP package so
|
|
2891
|
+
* docs + MCP + every coding-agent surface stay in sync.
|
|
2892
|
+
*
|
|
2893
|
+
* Exposed as a function (not a const) so future versions can swap in
|
|
2894
|
+
* a build-time generator without breaking import sites.
|
|
2895
|
+
*/
|
|
2896
|
+
function buildAmbaSetupBody() {
|
|
2897
|
+
return AMBA_SETUP_GUIDE_MD;
|
|
2898
|
+
}
|
|
2899
|
+
/**
|
|
2900
|
+
* Build the Cursor `.cursor/rules/amba.mdc` flavor.
|
|
2901
|
+
*
|
|
2902
|
+
* `alwaysApply: true` makes Cursor inject the rule at the start of
|
|
2903
|
+
* every turn (Cursor's most-proactive mode). `globs: ""` keeps the
|
|
2904
|
+
* rule globally-scoped instead of file-pattern-attached.
|
|
2905
|
+
*/
|
|
2906
|
+
function buildCursorRuleContent() {
|
|
2907
|
+
return `---
|
|
2908
|
+
alwaysApply: true
|
|
2909
|
+
description: "Amba SDK + MCP guide"
|
|
2910
|
+
globs: ""
|
|
2911
|
+
---
|
|
2912
|
+
|
|
2913
|
+
${buildAmbaSetupBody()}
|
|
2914
|
+
`;
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* Build the Windsurf `.windsurf/rules/amba.md` flavor.
|
|
2918
|
+
*
|
|
2919
|
+
* `trigger: always_on` is Windsurf's equivalent of Cursor's
|
|
2920
|
+
* `alwaysApply: true`. Workspace rules **cap at 12k chars** — a hard
|
|
2921
|
+
* Windsurf limit, not negotiable. The canonical
|
|
2922
|
+
* `AMBA_SETUP_GUIDE_MD` body is the full classify → confirm →
|
|
2923
|
+
* wire-up playbook (~17k) and won't fit, so Windsurf gets a
|
|
2924
|
+
* trimmed-down summary that points at the long-form resource
|
|
2925
|
+
* (`amba://setup`) for full detail. Same posture as
|
|
2926
|
+
* `AMBA_INIT_INSTRUCTIONS` in the hosted MCP server.
|
|
2927
|
+
*/
|
|
2928
|
+
function buildWindsurfRuleContent() {
|
|
2929
|
+
return `---
|
|
2930
|
+
trigger: always_on
|
|
2931
|
+
---
|
|
2932
|
+
|
|
2933
|
+
${buildWindsurfSummaryBody()}
|
|
2934
|
+
`;
|
|
2935
|
+
}
|
|
2936
|
+
/**
|
|
2937
|
+
* Shorter summary of the Amba setup playbook for Windsurf rules.
|
|
2938
|
+
*
|
|
2939
|
+
* Constraints:
|
|
2940
|
+
* - Wrapped frontmatter + body must fit under Windsurf's 12k cap.
|
|
2941
|
+
* - Must name the same five-step journey shape so an agent acting
|
|
2942
|
+
* on this summary doesn't drift from the canonical guide.
|
|
2943
|
+
* - Customer-facing — no vendor leakage.
|
|
2944
|
+
*
|
|
2945
|
+
* For full detail (per-surface MCP tool tables, per-stack SDK init
|
|
2946
|
+
* snippets, common follow-ups, re-run rules), the agent fetches
|
|
2947
|
+
* `amba://setup` (or per-surface \`amba://setup/<surface>\`) from the
|
|
2948
|
+
* hosted MCP server.
|
|
2949
|
+
*/
|
|
2950
|
+
function buildWindsurfSummaryBody() {
|
|
2951
|
+
return `# Amba
|
|
755
2952
|
|
|
756
|
-
|
|
2953
|
+
Amba is the backend your app calls at runtime via the SDK; the MCP tools (\`amba_*\`) are for provisioning, called by the coding agent.
|
|
757
2954
|
|
|
758
|
-
##
|
|
759
|
-
- Project ID: \`${opts.projectId}\`
|
|
760
|
-
- SDK: \`${sdk}\`
|
|
761
|
-
- API URL: \`https://api.amba.dev\`
|
|
2955
|
+
## Bootstrap
|
|
762
2956
|
|
|
763
|
-
|
|
764
|
-
- Always read Amba config from environment variables, never hardcode
|
|
765
|
-
- Use \`process.env.AMBA_PROJECT_ID\` and \`process.env.AMBA_API_KEY\`
|
|
766
|
-
- The .env.local file contains the project credentials
|
|
2957
|
+
\`amba_developer_signup\` is the only pre-auth MCP tool — call it with no Bearer to mint a PAT, project, and client/server keys in one round trip. After signup, set \`Authorization: Bearer <pat>\` on every subsequent MCP call. Save \`project_id\` + \`client_key\` to \`.env.local\`; never write \`server_key\` next to client code.
|
|
767
2958
|
|
|
768
|
-
##
|
|
769
|
-
${opts.framework === "expo" ? `- Import the \`Amba\` singleton from \`@layers/amba-expo\`
|
|
770
|
-
- Call \`Amba.init({ projectId, apiKey })\` once in the root layout (inside a \`useEffect\`)
|
|
771
|
-
- The Expo wrapper auto-wires AsyncStorage, push tokens, and Apple/Google sign-in
|
|
772
|
-
- Use \`Amba.signInWithApple()\` / \`Amba.signInWithGoogle()\` for social auth one-liners
|
|
773
|
-
- Call \`Amba.track()\` for engagement events, don't build custom analytics` : `- Initialize the Amba client once and export it as a singleton
|
|
774
|
-
- Use \`Amba.client.track()\` for all engagement events
|
|
775
|
-
- Use \`Amba.client.config.get()\` for remote configuration
|
|
776
|
-
- Use \`Amba.client.auth\` for sign-up / sign-in flows`}
|
|
2959
|
+
## Playbook
|
|
777
2960
|
|
|
778
|
-
|
|
779
|
-
- Register push tokens via the SDK \`registerPushToken()\` method
|
|
780
|
-
- Handle notification payloads using the SDK's notification listener
|
|
781
|
-
- Don't implement custom push token management
|
|
2961
|
+
When the user asks you to wire Amba into this codebase, run this journey end-to-end:
|
|
782
2962
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
-
|
|
786
|
-
|
|
2963
|
+
1. **Step 0 — Bootstrap credentials.** Call \`amba_developer_signup\` (pre-auth) OR \`amba_developer_me\` if a PAT already exists. Persist \`project_id\` + \`client_key\` to \`.env.local\`.
|
|
2964
|
+
2. **Step 1 — Classify the app.** Pick one of ten presets (fitness / social / marketplace / productivity / education / game / dating / content_creator / ai_chatbot / custom) using README + filenames + dependencies.
|
|
2965
|
+
3. **Step 2 — Confirm with the user.** One multi-choice question listing the surfaces the preset implies. Don't drip-feed.
|
|
2966
|
+
4. **Step 3 — Wire it up.** For each surface in the confirmed scope, fetch its sub-resource (\`amba://setup/<surface>\`) and execute its MCP tools + drop in the per-stack SDK init snippet.
|
|
2967
|
+
5. **Step 4 — Report.** Structured DONE / SKIPPED / NEEDS YOUR INPUT / NEXT STEPS summary.
|
|
787
2968
|
|
|
788
|
-
##
|
|
789
|
-
- Streaks are server-managed; the SDK provides read-only access
|
|
790
|
-
- Use \`track()\` to record qualifying events — the server evaluates streaks
|
|
791
|
-
- Show streak state from \`streak.current()\`, don't calculate manually
|
|
2969
|
+
## Surfaces
|
|
792
2970
|
|
|
793
|
-
|
|
794
|
-
-
|
|
795
|
-
-
|
|
796
|
-
-
|
|
797
|
-
-
|
|
2971
|
+
- **identity** — anonymous + Apple + Google + OTP + magic link (see \`amba://setup/identity\`).
|
|
2972
|
+
- **engagement** — push, segments, content, onboarding, deeplinks, referrals, tracked links (\`amba://setup/engagement\`).
|
|
2973
|
+
- **gamification** — XP, achievements, streaks, leaderboards, challenges (\`amba://setup/gamification\`).
|
|
2974
|
+
- **economy** — currencies, catalog, stores, inventory (\`amba://setup/economy\`).
|
|
2975
|
+
- **social** — friends, groups, feeds, messaging, moderation, reviews (\`amba://setup/social\`).
|
|
2976
|
+
- **infrastructure** — collections (typed tables), functions, AI prompts, secrets, configs, integrations, media, sites (\`amba://setup/infrastructure\`).
|
|
2977
|
+
|
|
2978
|
+
## SDKs
|
|
2979
|
+
|
|
2980
|
+
| Stack | Registry | Package |
|
|
2981
|
+
|---|---|---|
|
|
2982
|
+
| Browser / Node / React / RN / Expo | npm | \`@layers/amba-{web,node,react,react-native,expo}\` |
|
|
2983
|
+
| Swift | SPM | \`https://github.com/layers/amba-sdk-ios\` |
|
|
2984
|
+
| Kotlin | Maven Central | \`com.layers.amba:amba-sdk-android\` |
|
|
2985
|
+
| Flutter | pub.dev | \`amba\` |
|
|
2986
|
+
| Unity | UPM (git) | \`https://github.com/layers/amba-sdk-unity.git\` |
|
|
2987
|
+
|
|
2988
|
+
All SDKs expose \`Amba.configure({ projectId, apiKey })\` then \`Amba.events.track(...)\`, \`Amba.users.*\`, \`Amba.collections.*\`, etc.
|
|
2989
|
+
|
|
2990
|
+
## Stance
|
|
2991
|
+
|
|
2992
|
+
- **clientKey vs serverKey.** \`AMBA_CLIENT_KEY\` (\`amb_dev_ck_…\` / \`amb_live_ck_…\`) ships to user devices. \`AMBA_SERVER_KEY\` (\`amb_dev_sk_…\` / \`amb_live_sk_…\`) never does — server \`.env\` or a secret manager. Mixing them is the #1 security mistake.
|
|
2993
|
+
- **Default to additive, non-breaking edits.** Drop \`await Amba.configure(...)\` next to existing init, don't refactor.
|
|
2994
|
+
- **Don't create resources without Step 2 confirmation.**
|
|
2995
|
+
- **Use canonical (post-DX-12) tool names** — \`amba_<resource>_<verb>\` form. Legacy verb-leading aliases (\`amba_create_*\`, \`amba_list_*\`, etc.) still resolve but the canonical names are what to advertise.
|
|
2996
|
+
|
|
2997
|
+
For the full playbook (per-surface MCP tool tables, per-stack SDK init snippets, common follow-ups, re-run behavior), read \`amba://setup\` and \`amba://setup/<surface>\` from the hosted MCP at \`mcp.amba.dev\`.
|
|
798
2998
|
`;
|
|
799
2999
|
}
|
|
800
3000
|
/**
|
|
801
|
-
*
|
|
3001
|
+
* Body for the marker-fenced append into `CLAUDE.md` or `AGENTS.md`.
|
|
3002
|
+
*
|
|
3003
|
+
* Plain markdown, no frontmatter — both conventions are
|
|
3004
|
+
* frontmatter-free. Returns the inner body only; the marker fence is
|
|
3005
|
+
* added by `markerFencedAppend`.
|
|
802
3006
|
*/
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
await
|
|
810
|
-
|
|
811
|
-
|
|
3007
|
+
function buildAppendableSetupBody() {
|
|
3008
|
+
return buildAmbaSetupBody();
|
|
3009
|
+
}
|
|
3010
|
+
/** Write `.cursor/rules/amba.mdc`. */
|
|
3011
|
+
async function writeCursorRule(options = {}) {
|
|
3012
|
+
const ruleDir = join(options.baseDir ?? process.cwd(), ".cursor", "rules");
|
|
3013
|
+
await mkdir(ruleDir, { recursive: true });
|
|
3014
|
+
const rulePath = join(ruleDir, "amba.mdc");
|
|
3015
|
+
let mode = "created";
|
|
3016
|
+
try {
|
|
3017
|
+
await readFile(rulePath, "utf-8");
|
|
3018
|
+
mode = "refreshed";
|
|
3019
|
+
} catch {
|
|
3020
|
+
mode = "created";
|
|
3021
|
+
}
|
|
3022
|
+
await writeFile(rulePath, buildCursorRuleContent(), "utf-8");
|
|
3023
|
+
return {
|
|
3024
|
+
path: rulePath,
|
|
3025
|
+
mode
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
/** Write `.windsurf/rules/amba.md`. */
|
|
3029
|
+
async function writeWindsurfRule(options = {}) {
|
|
3030
|
+
const ruleDir = join(options.baseDir ?? process.cwd(), ".windsurf", "rules");
|
|
3031
|
+
await mkdir(ruleDir, { recursive: true });
|
|
3032
|
+
const rulePath = join(ruleDir, "amba.md");
|
|
3033
|
+
let mode = "created";
|
|
3034
|
+
try {
|
|
3035
|
+
await readFile(rulePath, "utf-8");
|
|
3036
|
+
mode = "refreshed";
|
|
3037
|
+
} catch {
|
|
3038
|
+
mode = "created";
|
|
3039
|
+
}
|
|
3040
|
+
await writeFile(rulePath, buildWindsurfRuleContent(), "utf-8");
|
|
3041
|
+
return {
|
|
3042
|
+
path: rulePath,
|
|
3043
|
+
mode
|
|
3044
|
+
};
|
|
3045
|
+
}
|
|
3046
|
+
/**
|
|
3047
|
+
* Append (or refresh) the Amba setup section in `CLAUDE.md` at the
|
|
3048
|
+
* project root. Marker-fenced so it can be safely refreshed by
|
|
3049
|
+
* subsequent re-inits.
|
|
3050
|
+
*/
|
|
3051
|
+
async function writeClaudeMd(options = {}) {
|
|
3052
|
+
return markerFencedAppend(join(options.baseDir ?? process.cwd(), "CLAUDE.md"), buildAppendableSetupBody(), AMBA_SETUP_START_MARKER, AMBA_SETUP_END_MARKER);
|
|
3053
|
+
}
|
|
3054
|
+
/**
|
|
3055
|
+
* Append (or refresh) the Amba setup section in `AGENTS.md` at the
|
|
3056
|
+
* project root. The `AGENTS.md` convention is read by 20+ agentic
|
|
3057
|
+
* tools (Codex, Aider, Zed, Copilot, Gemini CLI, Warp, etc.) so this
|
|
3058
|
+
* single file covers most of the long tail.
|
|
3059
|
+
*/
|
|
3060
|
+
async function writeAgentsMd(options = {}) {
|
|
3061
|
+
return markerFencedAppend(join(options.baseDir ?? process.cwd(), "AGENTS.md"), buildAppendableSetupBody(), AMBA_SETUP_START_MARKER, AMBA_SETUP_END_MARKER);
|
|
3062
|
+
}
|
|
3063
|
+
async function writeAllSetupTargets(options = {}) {
|
|
3064
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
3065
|
+
const warn = options.warn ?? (() => {});
|
|
3066
|
+
const written = [];
|
|
3067
|
+
const tasks = [
|
|
3068
|
+
{
|
|
3069
|
+
target: "claude-md",
|
|
3070
|
+
write: () => writeClaudeMd({ baseDir })
|
|
3071
|
+
},
|
|
3072
|
+
{
|
|
3073
|
+
target: "cursor-rule",
|
|
3074
|
+
write: () => writeCursorRule({ baseDir })
|
|
3075
|
+
},
|
|
3076
|
+
{
|
|
3077
|
+
target: "agents-md",
|
|
3078
|
+
write: () => writeAgentsMd({ baseDir })
|
|
3079
|
+
},
|
|
3080
|
+
{
|
|
3081
|
+
target: "windsurf-rule",
|
|
3082
|
+
write: () => writeWindsurfRule({ baseDir })
|
|
3083
|
+
}
|
|
3084
|
+
];
|
|
3085
|
+
for (const task of tasks) try {
|
|
3086
|
+
const res = await task.write();
|
|
3087
|
+
written.push({
|
|
3088
|
+
target: task.target,
|
|
3089
|
+
path: res.path,
|
|
3090
|
+
mode: res.mode
|
|
3091
|
+
});
|
|
3092
|
+
} catch (err) {
|
|
3093
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3094
|
+
warn(` ! Skipped ${task.target} setup file: ${message}`);
|
|
3095
|
+
}
|
|
3096
|
+
return {
|
|
3097
|
+
written,
|
|
3098
|
+
bodyVersion: "v2"
|
|
3099
|
+
};
|
|
812
3100
|
}
|
|
813
3101
|
//#endregion
|
|
814
3102
|
//#region src/commands/init.ts
|
|
@@ -824,6 +3112,85 @@ function prompt(question) {
|
|
|
824
3112
|
});
|
|
825
3113
|
});
|
|
826
3114
|
}
|
|
3115
|
+
function createSpinner(label) {
|
|
3116
|
+
if (!process.stdout.isTTY) return {
|
|
3117
|
+
setLabel: () => {},
|
|
3118
|
+
stop: () => {},
|
|
3119
|
+
get stopped() {
|
|
3120
|
+
return true;
|
|
3121
|
+
}
|
|
3122
|
+
};
|
|
3123
|
+
const frames = [
|
|
3124
|
+
"⠋",
|
|
3125
|
+
"⠙",
|
|
3126
|
+
"⠹",
|
|
3127
|
+
"⠸",
|
|
3128
|
+
"⠼",
|
|
3129
|
+
"⠴",
|
|
3130
|
+
"⠦",
|
|
3131
|
+
"⠧",
|
|
3132
|
+
"⠇",
|
|
3133
|
+
"⠏"
|
|
3134
|
+
];
|
|
3135
|
+
let i = 0;
|
|
3136
|
+
let current = label;
|
|
3137
|
+
let isStopped = false;
|
|
3138
|
+
const render = () => {
|
|
3139
|
+
const frame = frames[i % frames.length];
|
|
3140
|
+
process.stdout.write(`\r ${pc.cyan(frame)} ${pc.dim(current)}${" ".repeat(8)}`);
|
|
3141
|
+
i += 1;
|
|
3142
|
+
};
|
|
3143
|
+
render();
|
|
3144
|
+
const handle = setInterval(render, 80);
|
|
3145
|
+
return {
|
|
3146
|
+
setLabel: (next) => {
|
|
3147
|
+
current = next;
|
|
3148
|
+
},
|
|
3149
|
+
stop: () => {
|
|
3150
|
+
if (isStopped) return;
|
|
3151
|
+
isStopped = true;
|
|
3152
|
+
clearInterval(handle);
|
|
3153
|
+
process.stdout.write("\r" + " ".repeat(80) + "\r");
|
|
3154
|
+
},
|
|
3155
|
+
get stopped() {
|
|
3156
|
+
return isStopped;
|
|
3157
|
+
}
|
|
3158
|
+
};
|
|
3159
|
+
}
|
|
3160
|
+
/**
|
|
3161
|
+
* Pretty-print the elapsed time. Sub-minute renders as seconds
|
|
3162
|
+
* ("12s"); above that renders as "1m 04s". The spinner ends with this
|
|
3163
|
+
* stamped into the first line of the success block so a developer who
|
|
3164
|
+
* just ran the command knows how long the network round trips took.
|
|
3165
|
+
*/
|
|
3166
|
+
function formatElapsed(ms) {
|
|
3167
|
+
const totalSec = Math.max(0, Math.round(ms / 1e3));
|
|
3168
|
+
if (totalSec < 60) return `${totalSec}s`;
|
|
3169
|
+
const m = Math.floor(totalSec / 60);
|
|
3170
|
+
const s = totalSec % 60;
|
|
3171
|
+
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
3172
|
+
}
|
|
3173
|
+
/**
|
|
3174
|
+
* Truncate a long id to a compact preview — first 7 chars + ellipsis.
|
|
3175
|
+
* Mirrors how the API surfaces `id.slice(0, 8)` in other places. Keeps
|
|
3176
|
+
* the success block readable when project ids are full UUIDs.
|
|
3177
|
+
*/
|
|
3178
|
+
function shortId(id) {
|
|
3179
|
+
if (id.length <= 10) return id;
|
|
3180
|
+
return `${id.slice(0, 7)}…`;
|
|
3181
|
+
}
|
|
3182
|
+
/**
|
|
3183
|
+
* Strip the project root from an absolute path for compact display in
|
|
3184
|
+
* the success block — `/Users/me/proj/.env.local` → `.env.local`,
|
|
3185
|
+
* `/Users/me/.claude.json` → `~/.claude.json` when a home dir is
|
|
3186
|
+
* provided. Pure cosmetic, never used for actual fs operations.
|
|
3187
|
+
*/
|
|
3188
|
+
function relPathForDisplay(absPath, cwd, home) {
|
|
3189
|
+
if (absPath.startsWith(cwd + "/")) return absPath.slice(cwd.length + 1);
|
|
3190
|
+
const homeDir = home ?? process.env["HOME"] ?? "";
|
|
3191
|
+
if (homeDir && absPath.startsWith(homeDir + "/")) return "~/" + absPath.slice(homeDir.length + 1);
|
|
3192
|
+
return absPath;
|
|
3193
|
+
}
|
|
827
3194
|
async function fileExists(path) {
|
|
828
3195
|
try {
|
|
829
3196
|
await access(path);
|
|
@@ -923,173 +3290,411 @@ Docs: https://docs.amba.dev
|
|
|
923
3290
|
}
|
|
924
3291
|
async function initCommand(options = {}) {
|
|
925
3292
|
const cwd = process.cwd();
|
|
926
|
-
const
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
if (headlessActive) {
|
|
939
|
-
console.log(pc.green(" ✓") + " Headless auth — using PAT from --token / AMBA_PAT");
|
|
940
|
-
console.log(pc.dim(" (browser flow skipped; no credentials written to disk)"));
|
|
941
|
-
console.log();
|
|
942
|
-
needsAuth = false;
|
|
943
|
-
} else try {
|
|
944
|
-
if (!isTokenExpired(await loadCredentials())) {
|
|
945
|
-
console.log(pc.green(" ✓") + " Already authenticated");
|
|
946
|
-
console.log();
|
|
947
|
-
needsAuth = false;
|
|
948
|
-
}
|
|
949
|
-
} catch {}
|
|
950
|
-
if (needsAuth) {
|
|
951
|
-
const creds = await browserAuthFlow();
|
|
952
|
-
console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
|
|
953
|
-
await storeCredentials(creds);
|
|
954
|
-
console.log(pc.green(" ✓") + " Credentials saved to ~/.amba/credentials.json");
|
|
955
|
-
console.log();
|
|
956
|
-
} else {
|
|
957
|
-
console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
|
|
958
|
-
if (headlessActive) console.log(pc.green(" ✓") + " (skipped — PAT supplied)");
|
|
959
|
-
else console.log(pc.green(" ✓") + " Using existing credentials");
|
|
960
|
-
console.log();
|
|
3293
|
+
const isNonTTY = process.stdin.isTTY !== true;
|
|
3294
|
+
if (options.sandbox === true || options.json === true || isNonTTY) {
|
|
3295
|
+
const result = await runSandboxInit(cwd, {
|
|
3296
|
+
sandboxEmail: options.sandboxEmail,
|
|
3297
|
+
noMcpConfig: options.noMcpConfig,
|
|
3298
|
+
noSkills: options.noSkills,
|
|
3299
|
+
json: options.json,
|
|
3300
|
+
homeDir: options.homeDir
|
|
3301
|
+
});
|
|
3302
|
+
if (options.json) process.stdout.write(JSON.stringify(sandboxResultToJson(result), null, 2) + "\n");
|
|
3303
|
+
else printSandboxNextSteps(result);
|
|
3304
|
+
return;
|
|
961
3305
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
3306
|
+
const environment = options.env ?? "development";
|
|
3307
|
+
const startedAt = Date.now();
|
|
3308
|
+
const overridePat = getBearerOverride() ?? resolveTokenSource({ envToken: process.env["AMBA_PAT"] });
|
|
3309
|
+
const headlessActive = overridePat !== null;
|
|
3310
|
+
let identityPat = null;
|
|
3311
|
+
let identity = null;
|
|
3312
|
+
let credsBackedUpTo = null;
|
|
3313
|
+
let spinner = createSpinner("authenticating");
|
|
966
3314
|
try {
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
if (
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
3315
|
+
if (headlessActive) {
|
|
3316
|
+
identityPat = overridePat;
|
|
3317
|
+
try {
|
|
3318
|
+
identity = await verifyPat(identityPat);
|
|
3319
|
+
} catch (err) {
|
|
3320
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
3321
|
+
spinner.stop();
|
|
3322
|
+
console.error(pc.red(" ✗") + ` Could not verify supplied token: ${reason}`);
|
|
3323
|
+
process.exit(1);
|
|
3324
|
+
}
|
|
3325
|
+
if (!identity) {
|
|
3326
|
+
spinner.stop();
|
|
3327
|
+
console.error(pc.red(" ✗") + " Supplied token failed verification. Check --token / AMBA_PAT and try again.");
|
|
3328
|
+
process.exit(1);
|
|
3329
|
+
}
|
|
3330
|
+
} else {
|
|
3331
|
+
let needsBrowser = false;
|
|
3332
|
+
try {
|
|
3333
|
+
const ensured = await ensureDeveloperIdentity({ signupOnMissing: false });
|
|
3334
|
+
identity = ensured.developer;
|
|
3335
|
+
identityPat = ensured.credentials.pat;
|
|
3336
|
+
credsBackedUpTo = ensured.credentialsBackedUpTo;
|
|
3337
|
+
setBearerOverride(ensured.credentials.pat);
|
|
3338
|
+
} catch {
|
|
3339
|
+
needsBrowser = true;
|
|
3340
|
+
}
|
|
3341
|
+
if (needsBrowser) {
|
|
3342
|
+
spinner.stop();
|
|
3343
|
+
await storeCredentials(await browserAuthFlow());
|
|
3344
|
+
const ensured = await ensureDeveloperIdentity({ signupOnMissing: false });
|
|
3345
|
+
identity = ensured.developer;
|
|
3346
|
+
identityPat = ensured.credentials.pat;
|
|
3347
|
+
credsBackedUpTo = ensured.credentialsBackedUpTo;
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
if (!identityPat || !identity) {
|
|
3351
|
+
spinner.stop();
|
|
3352
|
+
console.error(pc.red(" ✗") + " Could not establish an Amba identity.");
|
|
3353
|
+
process.exit(1);
|
|
3354
|
+
return;
|
|
3355
|
+
}
|
|
3356
|
+
const linkedProject = await loadProjectCredentials(cwd);
|
|
3357
|
+
const defaultProjectName = basename(cwd) || "amba-project";
|
|
3358
|
+
let projectId;
|
|
3359
|
+
let projectName;
|
|
3360
|
+
if (linkedProject) {
|
|
3361
|
+
projectId = linkedProject.project_id;
|
|
3362
|
+
projectName = linkedProject.project_name;
|
|
3363
|
+
} else {
|
|
3364
|
+
spinner.setLabel("loading projects");
|
|
3365
|
+
let projectsList = [];
|
|
3366
|
+
try {
|
|
3367
|
+
projectsList = (await listProjects()).data;
|
|
3368
|
+
} catch (err) {
|
|
3369
|
+
if (err instanceof Error && err.message.includes("authenticate")) {
|
|
3370
|
+
spinner.stop();
|
|
3371
|
+
throw err;
|
|
3372
|
+
}
|
|
3373
|
+
projectsList = [];
|
|
3374
|
+
}
|
|
3375
|
+
spinner.stop();
|
|
3376
|
+
if (projectsList.length > 0) {
|
|
3377
|
+
console.log();
|
|
3378
|
+
console.log(" Existing projects:");
|
|
3379
|
+
projectsList.forEach((p, i) => {
|
|
3380
|
+
const envBadge = p.environment ? pc.dim(` [${p.environment}]`) : "";
|
|
3381
|
+
console.log(pc.dim(` ${i + 1}.`) + ` ${p.name}${envBadge} ` + pc.dim(`(${p.id.slice(0, 8)}…)`));
|
|
3382
|
+
});
|
|
3383
|
+
const newOptionIdx = projectsList.length + 1;
|
|
3384
|
+
console.log(pc.dim(` ${newOptionIdx}.`) + ` Create new project ` + pc.dim(`(default name: ${defaultProjectName})`));
|
|
3385
|
+
console.log();
|
|
3386
|
+
const choice = await prompt(` Select project (1-${newOptionIdx}, default ${newOptionIdx}): `);
|
|
3387
|
+
const choiceNum = choice.length === 0 ? newOptionIdx : parseInt(choice, 10);
|
|
3388
|
+
if (choiceNum > 0 && choiceNum <= projectsList.length) {
|
|
3389
|
+
const selected = projectsList[choiceNum - 1];
|
|
3390
|
+
if (!selected) throw new Error("Invalid selection");
|
|
3391
|
+
projectId = selected.id;
|
|
3392
|
+
projectName = selected.name;
|
|
3393
|
+
} else {
|
|
3394
|
+
const name = await prompt(` Project name (default: ${defaultProjectName}): `);
|
|
3395
|
+
const finalName = name.length > 0 ? name : defaultProjectName;
|
|
3396
|
+
projectId = (await createProject({
|
|
3397
|
+
name: finalName,
|
|
3398
|
+
environment
|
|
3399
|
+
})).data.id;
|
|
3400
|
+
projectName = finalName;
|
|
988
3401
|
}
|
|
3402
|
+
} else {
|
|
3403
|
+
const name = await prompt(` Project name (default: ${defaultProjectName}): `);
|
|
3404
|
+
const finalName = name.length > 0 ? name : defaultProjectName;
|
|
989
3405
|
projectId = (await createProject({
|
|
990
|
-
name,
|
|
3406
|
+
name: finalName,
|
|
991
3407
|
environment
|
|
992
3408
|
})).data.id;
|
|
993
|
-
projectName =
|
|
994
|
-
console.log(pc.green(" ✓") + ` Created: ${projectName} ${pc.dim(`(${environment})`)}`);
|
|
3409
|
+
projectName = finalName;
|
|
995
3410
|
}
|
|
3411
|
+
}
|
|
3412
|
+
if (spinner.stopped) spinner = createSpinner("minting keys");
|
|
3413
|
+
const work = spinner;
|
|
3414
|
+
work.setLabel("minting keys");
|
|
3415
|
+
let clientKey;
|
|
3416
|
+
let serverKey;
|
|
3417
|
+
if (linkedProject) {
|
|
3418
|
+
clientKey = linkedProject.client_key;
|
|
3419
|
+
if (linkedProject.server_key) serverKey = linkedProject.server_key;
|
|
3420
|
+
else serverKey = (await createApiKey(projectId, "server", environment)).data.key;
|
|
996
3421
|
} else {
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
}
|
|
1002
|
-
projectId = (await createProject({ name })).data.id;
|
|
1003
|
-
projectName = name;
|
|
1004
|
-
console.log(pc.green(" ✓") + ` Created: ${projectName}`);
|
|
3422
|
+
const clientRes = await createApiKey(projectId, "client", environment);
|
|
3423
|
+
const serverRes = await createApiKey(projectId, "server", environment);
|
|
3424
|
+
clientKey = clientRes.data.key;
|
|
3425
|
+
serverKey = serverRes.data.key;
|
|
1005
3426
|
}
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
|
|
3427
|
+
work.setLabel("writing project state");
|
|
3428
|
+
const apiUrl = process.env["AMBA_API_URL"] ?? "https://api.amba.dev";
|
|
3429
|
+
const envLocalPath = await writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl, serverKey);
|
|
3430
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
3431
|
+
const projectJsonPath = await writeProjectCredentials(cwd, linkedProject ? {
|
|
3432
|
+
...linkedProject,
|
|
3433
|
+
client_key: clientKey,
|
|
3434
|
+
server_key: serverKey,
|
|
3435
|
+
environment,
|
|
3436
|
+
api_url: apiUrl,
|
|
3437
|
+
updated_at: nowIso
|
|
3438
|
+
} : {
|
|
3439
|
+
version: 1,
|
|
3440
|
+
project_id: projectId,
|
|
3441
|
+
project_name: projectName,
|
|
3442
|
+
environment,
|
|
3443
|
+
client_key: clientKey,
|
|
3444
|
+
server_key: serverKey,
|
|
3445
|
+
api_url: apiUrl,
|
|
3446
|
+
wired_surfaces: [],
|
|
3447
|
+
created_at: nowIso,
|
|
3448
|
+
updated_at: nowIso
|
|
3449
|
+
});
|
|
3450
|
+
work.setLabel("detecting framework");
|
|
3451
|
+
const framework = await detectFramework(cwd);
|
|
3452
|
+
const sdkPkg = getSdkPackage(framework);
|
|
3453
|
+
let installCmd = `npm install ${sdkPkg}`;
|
|
3454
|
+
if (await fileExists(join(cwd, "bun.lockb"))) installCmd = `bun add ${sdkPkg}`;
|
|
3455
|
+
else if (await fileExists(join(cwd, "pnpm-lock.yaml"))) installCmd = `pnpm add ${sdkPkg}`;
|
|
3456
|
+
else if (await fileExists(join(cwd, "yarn.lock"))) installCmd = `yarn add ${sdkPkg}`;
|
|
3457
|
+
work.setLabel("wiring agents");
|
|
3458
|
+
await generateContextFiles({
|
|
3459
|
+
projectId,
|
|
3460
|
+
projectName,
|
|
3461
|
+
apiKey: clientKey,
|
|
3462
|
+
framework,
|
|
3463
|
+
cwd
|
|
3464
|
+
});
|
|
3465
|
+
let mcpResults = [];
|
|
3466
|
+
let mcpManualSnippetNeeded = false;
|
|
3467
|
+
if (!options.noMcpConfig) {
|
|
3468
|
+
mcpResults = await writeAllMcpConfigs(cwd, identityPat, {
|
|
3469
|
+
homeDir: options.homeDir,
|
|
3470
|
+
warn: (msg) => process.stderr.write(msg + "\n")
|
|
3471
|
+
});
|
|
3472
|
+
mcpManualSnippetNeeded = mcpResults.length === 0;
|
|
3473
|
+
}
|
|
3474
|
+
if (options.withExample) await writeExampleScaffold(cwd, framework, projectName);
|
|
3475
|
+
work.setLabel("verifying");
|
|
3476
|
+
const finalVerify = await verifyPat(identityPat);
|
|
3477
|
+
work.stop();
|
|
3478
|
+
if (!finalVerify) {
|
|
3479
|
+
console.error(pc.red(" ✗") + " Post-write verify failed. PAT was minted but no longer accepted.");
|
|
3480
|
+
console.error(pc.dim(" Inspect ~/.amba/credentials.json + ") + pc.dim(".env.local — your provisioning may be incomplete."));
|
|
1011
3481
|
process.exit(1);
|
|
1012
3482
|
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
3483
|
+
const elapsed = formatElapsed(Date.now() - startedAt);
|
|
3484
|
+
const homeForDisplay = options.homeDir;
|
|
3485
|
+
const envRel = relPathForDisplay(envLocalPath, cwd, homeForDisplay);
|
|
3486
|
+
const projectJsonRel = relPathForDisplay(projectJsonPath, cwd, homeForDisplay);
|
|
3487
|
+
console.log();
|
|
3488
|
+
console.log(pc.green(" ✓") + pc.bold(` Amba ready in ${elapsed}`));
|
|
3489
|
+
console.log(pc.dim(" project: ") + projectName + pc.dim(` (id: ${shortId(projectId)})`));
|
|
3490
|
+
console.log(pc.dim(" keys → ") + envRel + pc.dim(` · state → ${projectJsonRel}`));
|
|
3491
|
+
if (mcpResults.length > 0) {
|
|
3492
|
+
const mcpPathsRel = mcpResults.map((m) => relPathForDisplay(m.path, cwd, homeForDisplay)).join(", ");
|
|
3493
|
+
console.log(pc.dim(" mcp → ") + mcpPathsRel + pc.dim(" (active next agent launch)"));
|
|
3494
|
+
for (const m of mcpResults) if (m.backedUpTo) console.log(pc.yellow(" note: previous amba entry backed up to ") + relPathForDisplay(m.backedUpTo, cwd, homeForDisplay));
|
|
3495
|
+
} else if (mcpManualSnippetNeeded) console.log(pc.dim(" mcp → ") + "no MCP client config detected (paste snippet below)");
|
|
3496
|
+
if (credsBackedUpTo) console.log(pc.dim(" note: previous credentials backed up to ") + credsBackedUpTo);
|
|
3497
|
+
console.log();
|
|
3498
|
+
console.log(pc.dim(" next: ") + pc.cyan(installCmd));
|
|
3499
|
+
console.log(pc.dim(" later: ") + pc.cyan("amba claim <your-email>") + pc.dim(" to upgrade past sandbox"));
|
|
3500
|
+
console.log();
|
|
3501
|
+
if (mcpManualSnippetNeeded && !options.noMcpConfig) {
|
|
3502
|
+
console.log(pc.dim(" Paste into your MCP client config:"));
|
|
3503
|
+
for (const line of formatManualMcpSnippet(identityPat).split("\n")) console.log(pc.dim(" ") + line);
|
|
3504
|
+
console.log();
|
|
3505
|
+
}
|
|
3506
|
+
} finally {
|
|
3507
|
+
spinner.stop();
|
|
1016
3508
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
const
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
} else await writeFile(envPath, envLines.join("\n"), "utf-8");
|
|
1044
|
-
console.log(pc.green(" ✓") + " .env.local written");
|
|
1045
|
-
console.log();
|
|
1046
|
-
console.log(pc.bold(" Step 6/7 ") + pc.dim("Detect framework"));
|
|
3509
|
+
}
|
|
3510
|
+
async function runSandboxInit(cwd, options) {
|
|
3511
|
+
const warn = (msg) => {
|
|
3512
|
+
process.stderr.write(msg + "\n");
|
|
3513
|
+
};
|
|
3514
|
+
const overridePat = getBearerOverride() ?? resolveTokenSource({ envToken: process.env["AMBA_PAT"] });
|
|
3515
|
+
let identity;
|
|
3516
|
+
if (overridePat) {
|
|
3517
|
+
const verified = await verifyPat(overridePat);
|
|
3518
|
+
if (!verified) throw new Error("Supplied --token / AMBA_PAT failed verification against /developer/me. Check that the token is valid and try again.");
|
|
3519
|
+
identity = {
|
|
3520
|
+
credentials: {
|
|
3521
|
+
pat: overridePat,
|
|
3522
|
+
email: verified.email
|
|
3523
|
+
},
|
|
3524
|
+
newlySignedUp: false,
|
|
3525
|
+
developer: verified,
|
|
3526
|
+
firstProject: null,
|
|
3527
|
+
credentialsBackedUpTo: null,
|
|
3528
|
+
credentialsPath: "(supplied via --token / AMBA_PAT — not persisted)"
|
|
3529
|
+
};
|
|
3530
|
+
} else identity = await ensureDeveloperIdentity({
|
|
3531
|
+
homeDir: options.homeDir,
|
|
3532
|
+
sandboxEmail: options.sandboxEmail
|
|
3533
|
+
});
|
|
3534
|
+
setBearerOverride(identity.credentials.pat);
|
|
1047
3535
|
const framework = await detectFramework(cwd);
|
|
1048
|
-
const
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
projectName,
|
|
1061
|
-
apiKey,
|
|
3536
|
+
const sdkPackage = getSdkPackage(framework);
|
|
3537
|
+
const project = await ensureProjectForCwd(cwd, {
|
|
3538
|
+
pat: identity.credentials.pat,
|
|
3539
|
+
signupFirstProject: identity.firstProject ?? void 0,
|
|
3540
|
+
defaultName: basename(cwd) || "amba-sandbox"
|
|
3541
|
+
});
|
|
3542
|
+
const envLocalPath = await writeSandboxEnvLocal(cwd, project.credentials.project_id, project.credentials.client_key, project.credentials.api_url, project.credentials.server_key);
|
|
3543
|
+
const ambaMdPath = await writeSandboxAmbaMd(cwd, {
|
|
3544
|
+
projectId: project.credentials.project_id,
|
|
3545
|
+
email: identity.credentials.email,
|
|
3546
|
+
verifyUrl: identity.firstProject?.verify_url ?? null,
|
|
3547
|
+
sdkPackage,
|
|
1062
3548
|
framework,
|
|
1063
|
-
|
|
3549
|
+
apiUrl: project.credentials.api_url
|
|
1064
3550
|
});
|
|
1065
|
-
|
|
1066
|
-
if (options.
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
3551
|
+
let mcpConfigsWritten = [];
|
|
3552
|
+
if (!options.noMcpConfig) mcpConfigsWritten = await writeAllMcpConfigs(cwd, identity.credentials.pat, {
|
|
3553
|
+
homeDir: options.homeDir,
|
|
3554
|
+
warn
|
|
3555
|
+
});
|
|
3556
|
+
let skillPath = null;
|
|
3557
|
+
let setupTargets = [];
|
|
3558
|
+
if (!options.noSkills) {
|
|
3559
|
+
try {
|
|
3560
|
+
const installResults = await installSkillBundle(cwd);
|
|
3561
|
+
summarizeSkillInstall(installResults);
|
|
3562
|
+
skillPath = (installResults.find((r) => r.target.kind === "claude-code")?.files.find((f) => f.path.endsWith("SKILL.md")))?.path ?? null;
|
|
3563
|
+
} catch (err) {
|
|
3564
|
+
warn(` ! Skipped amba skill bundle install: ${err instanceof Error ? err.message : String(err)}`);
|
|
3565
|
+
}
|
|
3566
|
+
try {
|
|
3567
|
+
await writeAmbaBuildSkill({ baseDir: cwd });
|
|
3568
|
+
} catch (err) {
|
|
3569
|
+
warn(` ! Skipped legacy /amba-build skill: ${err instanceof Error ? err.message : String(err)}`);
|
|
3570
|
+
}
|
|
3571
|
+
setupTargets = (await writeAllSetupTargets({
|
|
3572
|
+
baseDir: cwd,
|
|
3573
|
+
warn
|
|
3574
|
+
})).written.map((w) => ({
|
|
3575
|
+
target: w.target,
|
|
3576
|
+
path: w.path,
|
|
3577
|
+
mode: w.mode
|
|
3578
|
+
}));
|
|
1070
3579
|
}
|
|
3580
|
+
if (!await verifyPat(identity.credentials.pat, { apiUrl: project.credentials.api_url })) throw new Error("Post-write verify failed: PAT was provisioned but no longer accepted by /developer/me. This usually means the control-plane signup race hasn't settled yet — retry in 5s.");
|
|
3581
|
+
return {
|
|
3582
|
+
email: identity.credentials.email,
|
|
3583
|
+
projectId: project.credentials.project_id,
|
|
3584
|
+
pat: identity.credentials.pat,
|
|
3585
|
+
patPreview: `${identity.credentials.pat.slice(0, 12)}…${identity.credentials.pat.slice(-4)}`,
|
|
3586
|
+
clientKey: project.credentials.client_key,
|
|
3587
|
+
apiUrl: project.credentials.api_url,
|
|
3588
|
+
credentialsPath: identity.credentialsPath,
|
|
3589
|
+
credentialsBackedUpTo: identity.credentialsBackedUpTo,
|
|
3590
|
+
envLocalPath,
|
|
3591
|
+
ambaMdPath,
|
|
3592
|
+
mcpConfigsWritten,
|
|
3593
|
+
sdkPackage,
|
|
3594
|
+
framework,
|
|
3595
|
+
verifyUrl: identity.firstProject?.verify_url ?? null,
|
|
3596
|
+
provisioningStatus: identity.firstProject?.provisioning_status ?? "active",
|
|
3597
|
+
skillPath,
|
|
3598
|
+
setupTargets
|
|
3599
|
+
};
|
|
3600
|
+
}
|
|
3601
|
+
function sandboxResultToJson(r) {
|
|
3602
|
+
return {
|
|
3603
|
+
ok: true,
|
|
3604
|
+
mode: "sandbox",
|
|
3605
|
+
email: r.email,
|
|
3606
|
+
project_id: r.projectId,
|
|
3607
|
+
pat_preview: r.patPreview,
|
|
3608
|
+
client_key: r.clientKey,
|
|
3609
|
+
api_url: r.apiUrl,
|
|
3610
|
+
framework: r.framework,
|
|
3611
|
+
sdk_package: r.sdkPackage,
|
|
3612
|
+
credentials_path: r.credentialsPath,
|
|
3613
|
+
credentials_backed_up_to: r.credentialsBackedUpTo,
|
|
3614
|
+
env_local_path: r.envLocalPath,
|
|
3615
|
+
amba_md_path: r.ambaMdPath,
|
|
3616
|
+
mcp_configs_written: r.mcpConfigsWritten.map((m) => ({
|
|
3617
|
+
path: m.path,
|
|
3618
|
+
backed_up_to: m.backedUpTo
|
|
3619
|
+
})),
|
|
3620
|
+
skill_path: r.skillPath,
|
|
3621
|
+
setup_targets: r.setupTargets.map((t) => ({
|
|
3622
|
+
target: t.target,
|
|
3623
|
+
path: t.path,
|
|
3624
|
+
mode: t.mode
|
|
3625
|
+
})),
|
|
3626
|
+
verify_url: r.verifyUrl,
|
|
3627
|
+
provisioning_status: r.provisioningStatus,
|
|
3628
|
+
next_steps: [`npm install ${r.sdkPackage}`, "call Amba.configure({ projectId, clientKey }) at app startup"],
|
|
3629
|
+
runtime_mcp: {
|
|
3630
|
+
configs_written: r.mcpConfigsWritten.map((m) => m.path),
|
|
3631
|
+
activates_on: "next agent launch",
|
|
3632
|
+
in_session_inline_pat: true
|
|
3633
|
+
}
|
|
3634
|
+
};
|
|
3635
|
+
}
|
|
3636
|
+
/**
|
|
3637
|
+
* Build the plaintext (no ANSI) success-output block printed at the
|
|
3638
|
+
* end of `amba init --sandbox`. Pure function — exported for the
|
|
3639
|
+
* vitest cases that assert on per-line content. The CLI wraps the
|
|
3640
|
+
* output with picocolors in `printSandboxNextSteps` below.
|
|
3641
|
+
*
|
|
3642
|
+
* Design: silent-until-done. The install (provision account, mint
|
|
3643
|
+
* keys, write .env.local, write MCP config, install skill) is COMPLETE
|
|
3644
|
+
* the moment this output lands. The MCP config has been persisted —
|
|
3645
|
+
* it activates on the next launch of the developer's coding agent. We
|
|
3646
|
+
* do NOT instruct the developer to restart anything; we just state
|
|
3647
|
+
* what's wired and what's next.
|
|
3648
|
+
*
|
|
3649
|
+
* Shape (~6 lines, Vercel/Stripe aesthetic):
|
|
3650
|
+
* ✓ Amba ready
|
|
3651
|
+
* project: <id>
|
|
3652
|
+
* keys → .env.local
|
|
3653
|
+
* mcp → <paths> (active next agent launch)
|
|
3654
|
+
* skill → <skill paths> (when installed)
|
|
3655
|
+
*
|
|
3656
|
+
* next: npm install <sdk-pkg>
|
|
3657
|
+
* Amba.configure({ projectId, clientKey }) at app startup
|
|
3658
|
+
*
|
|
3659
|
+
* The fallback for "no MCP client config detected" is a one-line
|
|
3660
|
+
* note + a paste-ready snippet — still no restart copy.
|
|
3661
|
+
*/
|
|
3662
|
+
function buildSandboxNextStepsLines(r) {
|
|
3663
|
+
const lines = [];
|
|
3664
|
+
lines.push(`✓ Amba ready`);
|
|
3665
|
+
lines.push(` project: ${shortId(r.projectId)} (${r.email})`);
|
|
3666
|
+
lines.push(` keys → ${r.envLocalPath}`);
|
|
3667
|
+
if (r.mcpConfigsWritten.length > 0) {
|
|
3668
|
+
const mcpPaths = r.mcpConfigsWritten.map((m) => m.path).join(", ");
|
|
3669
|
+
lines.push(` mcp → ${mcpPaths} (active next agent launch)`);
|
|
3670
|
+
for (const m of r.mcpConfigsWritten) if (m.backedUpTo) lines.push(` note: previous amba entry backed up to ${m.backedUpTo}`);
|
|
3671
|
+
} else lines.push(` mcp → no MCP client config detected (paste snippet below)`);
|
|
3672
|
+
if (r.skillPath) lines.push(` skill → ${r.skillPath}`);
|
|
3673
|
+
if (r.credentialsBackedUpTo) lines.push(` note: previous non-sandbox credentials backed up to ${r.credentialsBackedUpTo}`);
|
|
3674
|
+
lines.push("");
|
|
3675
|
+
lines.push(` next: npm install ${r.sdkPackage}`);
|
|
3676
|
+
lines.push(` Amba.configure({ projectId: process.env.AMBA_PROJECT_ID, clientKey: process.env.AMBA_CLIENT_KEY })`);
|
|
3677
|
+
lines.push(` later: amba claim <your-email> to upgrade past sandbox`);
|
|
3678
|
+
if (r.mcpConfigsWritten.length === 0) {
|
|
3679
|
+
lines.push("");
|
|
3680
|
+
lines.push(` Paste into your MCP client config:`);
|
|
3681
|
+
for (const snippetLine of formatManualMcpSnippet(r.pat).split("\n")) lines.push(` ${snippetLine}`);
|
|
3682
|
+
}
|
|
3683
|
+
return lines;
|
|
3684
|
+
}
|
|
3685
|
+
function printSandboxNextSteps(r) {
|
|
3686
|
+
const lines = buildSandboxNextStepsLines(r);
|
|
1071
3687
|
console.log();
|
|
1072
|
-
console.log(pc.
|
|
1073
|
-
console.log();
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
console.log();
|
|
1081
|
-
console.log(
|
|
1082
|
-
if (framework === "expo") console.log(pc.dim(` See AMBA.md for Amba.init() setup`));
|
|
1083
|
-
else if (framework === "react-native") console.log(pc.dim(` See AMBA.md for client initialization`));
|
|
1084
|
-
else console.log(pc.dim(` See AMBA.md for client initialization`));
|
|
1085
|
-
console.log();
|
|
1086
|
-
console.log(pc.dim(" 3.") + ` Test the integration`);
|
|
1087
|
-
console.log(` ${pc.cyan("amba status")}`);
|
|
1088
|
-
console.log();
|
|
1089
|
-
console.log(pc.dim(" 4.") + ` Send a test notification`);
|
|
1090
|
-
console.log(` ${pc.cyan("amba push test")}`);
|
|
1091
|
-
console.log();
|
|
1092
|
-
console.log(` Docs: ${pc.underline("https://docs.amba.dev")}`);
|
|
3688
|
+
for (const line of lines) if (line.startsWith("✓ ")) console.log(" " + pc.green("✓") + pc.bold(line.slice(1)));
|
|
3689
|
+
else if (line.startsWith(" note:")) console.log(" " + pc.yellow(line.slice(2)));
|
|
3690
|
+
else if (line.startsWith(" next:")) {
|
|
3691
|
+
const cmd = line.slice(8);
|
|
3692
|
+
console.log(" " + pc.dim("next: ") + pc.cyan(cmd));
|
|
3693
|
+
} else if (line.startsWith(" later:")) {
|
|
3694
|
+
const cmd = line.slice(9);
|
|
3695
|
+
console.log(" " + pc.dim("later: ") + pc.cyan(cmd));
|
|
3696
|
+
} else if (line.startsWith(" project:") || line.startsWith(" keys") || line.startsWith(" mcp") || line.startsWith(" skill")) console.log(pc.dim(line));
|
|
3697
|
+
else console.log(line);
|
|
1093
3698
|
console.log();
|
|
1094
3699
|
}
|
|
1095
3700
|
//#endregion
|
|
@@ -1462,7 +4067,7 @@ function confirm(question) {
|
|
|
1462
4067
|
});
|
|
1463
4068
|
});
|
|
1464
4069
|
}
|
|
1465
|
-
function handleError(err) {
|
|
4070
|
+
function handleError$1(err) {
|
|
1466
4071
|
if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
|
|
1467
4072
|
else console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
1468
4073
|
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
@@ -1512,7 +4117,7 @@ async function projectsListCommand() {
|
|
|
1512
4117
|
console.log(pc.dim(` ${projects.length} project${projects.length === 1 ? "" : "s"}`));
|
|
1513
4118
|
console.log();
|
|
1514
4119
|
} catch (err) {
|
|
1515
|
-
handleError(err);
|
|
4120
|
+
handleError$1(err);
|
|
1516
4121
|
}
|
|
1517
4122
|
}
|
|
1518
4123
|
async function projectsCreateCommand(input) {
|
|
@@ -1538,6 +4143,7 @@ async function projectsCreateCommand(input) {
|
|
|
1538
4143
|
const res = await createProject({
|
|
1539
4144
|
name: input.name,
|
|
1540
4145
|
bundle_id: input.bundleId,
|
|
4146
|
+
google_oauth_client_id: input.googleOauthClientId,
|
|
1541
4147
|
platform: input.platform,
|
|
1542
4148
|
environment
|
|
1543
4149
|
});
|
|
@@ -1548,7 +4154,7 @@ async function projectsCreateCommand(input) {
|
|
|
1548
4154
|
try {
|
|
1549
4155
|
const s = (await getProvisioningStatus(id)).data;
|
|
1550
4156
|
console.log(pc.dim(` Status: ${s.status}`));
|
|
1551
|
-
if (s.
|
|
4157
|
+
if (s.region) console.log(pc.dim(` Region: ${s.region}`));
|
|
1552
4158
|
} catch {
|
|
1553
4159
|
console.log(pc.dim(" (Provisioning runs asynchronously.)"));
|
|
1554
4160
|
}
|
|
@@ -1557,7 +4163,33 @@ async function projectsCreateCommand(input) {
|
|
|
1557
4163
|
console.log(pc.dim(" Next: ") + pc.cyan(`amba projects show ${id}`));
|
|
1558
4164
|
console.log();
|
|
1559
4165
|
} catch (err) {
|
|
1560
|
-
handleError(err);
|
|
4166
|
+
handleError$1(err);
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
async function projectsUpdateCommand(projectId, input) {
|
|
4170
|
+
console.log();
|
|
4171
|
+
console.log(pc.bold(` amba projects update ${projectId}`));
|
|
4172
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
4173
|
+
console.log();
|
|
4174
|
+
const patch = {};
|
|
4175
|
+
if (input.name !== void 0) patch.name = input.name;
|
|
4176
|
+
if (input.bundleId !== void 0) patch.bundle_id = input.bundleId;
|
|
4177
|
+
if (input.googleOauthClientId !== void 0) patch.google_oauth_client_id = input.googleOauthClientId;
|
|
4178
|
+
if (input.platform !== void 0) patch.platform = input.platform;
|
|
4179
|
+
if (input.environment !== void 0) patch.environment = input.environment;
|
|
4180
|
+
try {
|
|
4181
|
+
const res = await updateProject(projectId, patch);
|
|
4182
|
+
console.log(pc.green(" ✓") + ` Updated ${pc.bold(res.data.name)} ${pc.dim(`(${res.data.id})`)}`);
|
|
4183
|
+
if (res.data.bundle_id) console.log(pc.dim(` Bundle ID: ${res.data.bundle_id}`));
|
|
4184
|
+
if (res.data.google_oauth_client_id) console.log(pc.dim(` Google OAuth client id: ${res.data.google_oauth_client_id}`));
|
|
4185
|
+
console.log();
|
|
4186
|
+
} catch (err) {
|
|
4187
|
+
if (err instanceof ApiClientError && err.statusCode === 404) {
|
|
4188
|
+
console.log(pc.red(" ✗") + ` Project not found: ${projectId}`);
|
|
4189
|
+
console.log();
|
|
4190
|
+
process.exit(1);
|
|
4191
|
+
}
|
|
4192
|
+
handleError$1(err);
|
|
1561
4193
|
}
|
|
1562
4194
|
}
|
|
1563
4195
|
async function projectsShowCommand(projectId) {
|
|
@@ -1575,7 +4207,7 @@ async function projectsShowCommand(projectId) {
|
|
|
1575
4207
|
console.log();
|
|
1576
4208
|
process.exit(1);
|
|
1577
4209
|
}
|
|
1578
|
-
handleError(err);
|
|
4210
|
+
handleError$1(err);
|
|
1579
4211
|
}
|
|
1580
4212
|
}
|
|
1581
4213
|
async function projectsDeleteCommand(projectId, opts = {}) {
|
|
@@ -1600,7 +4232,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
|
|
|
1600
4232
|
console.log();
|
|
1601
4233
|
process.exit(1);
|
|
1602
4234
|
}
|
|
1603
|
-
handleError(err);
|
|
4235
|
+
handleError$1(err);
|
|
1604
4236
|
}
|
|
1605
4237
|
}
|
|
1606
4238
|
//#endregion
|
|
@@ -1904,14 +4536,14 @@ async function dbMigrateCommand(opts = {}) {
|
|
|
1904
4536
|
console.log(pc.dim(" Running tenant migrations..."));
|
|
1905
4537
|
console.log();
|
|
1906
4538
|
try {
|
|
1907
|
-
const
|
|
1908
|
-
console.log(pc.green(" ✓") + " Reprovision
|
|
1909
|
-
if (
|
|
4539
|
+
const jobId = (await reprovisionProject(projectId)).data.job_id;
|
|
4540
|
+
console.log(pc.green(" ✓") + " Reprovision started");
|
|
4541
|
+
if (jobId) console.log(pc.dim(` job: ${jobId}`));
|
|
1910
4542
|
console.log();
|
|
1911
4543
|
try {
|
|
1912
4544
|
const status = await getProvisioningStatus(projectId);
|
|
1913
4545
|
console.log(pc.dim(` Status: ${status.data.status}`));
|
|
1914
|
-
if (status.data.
|
|
4546
|
+
if (status.data.region) console.log(pc.dim(` Region: ${status.data.region}`));
|
|
1915
4547
|
} catch {}
|
|
1916
4548
|
console.log();
|
|
1917
4549
|
console.log(pc.dim(" Check again with: ") + pc.cyan(`amba projects show ${projectId}`));
|
|
@@ -2257,45 +4889,108 @@ async function schemaExportCommand(opts) {
|
|
|
2257
4889
|
console.log();
|
|
2258
4890
|
process.exit(1);
|
|
2259
4891
|
}
|
|
2260
|
-
const selected = domain === "all" ? Object.values(SCHEMAS) : [SCHEMAS[domain]];
|
|
2261
|
-
const output = format === "json" ? renderJson(selected) : renderTypescript(selected);
|
|
2262
|
-
process.stdout.write(output);
|
|
4892
|
+
const selected = domain === "all" ? Object.values(SCHEMAS) : [SCHEMAS[domain]];
|
|
4893
|
+
const output = format === "json" ? renderJson(selected) : renderTypescript(selected);
|
|
4894
|
+
process.stdout.write(output);
|
|
4895
|
+
}
|
|
4896
|
+
//#endregion
|
|
4897
|
+
//#region src/project-config.ts
|
|
4898
|
+
/**
|
|
4899
|
+
* Local project config loader.
|
|
4900
|
+
*
|
|
4901
|
+
* `amba init` writes `.env.local` with `AMBA_PROJECT_ID` + `AMBA_API_KEY`.
|
|
4902
|
+
* Subsequent commands resolve the active project by reading `.env.local`
|
|
4903
|
+
* (or the OS env if exported); fail with a clear "run amba init first"
|
|
4904
|
+
* error if neither is set.
|
|
4905
|
+
*
|
|
4906
|
+
* Kept tiny on purpose — the CLI's full config story (per-environment
|
|
4907
|
+
* dev/prod selection) is a v2 follow-up; v1 just needs project id.
|
|
4908
|
+
*/
|
|
4909
|
+
const ENV_LOCAL_FILES = [".env.local", ".env"];
|
|
4910
|
+
async function loadProjectConfig(cwd = process.cwd()) {
|
|
4911
|
+
let projectId = process.env["AMBA_PROJECT_ID"];
|
|
4912
|
+
let apiUrl = process.env["AMBA_API_URL"];
|
|
4913
|
+
if (!projectId || !apiUrl) for (const filename of ENV_LOCAL_FILES) {
|
|
4914
|
+
const content = await readFile(join(cwd, filename), "utf-8").catch(() => null);
|
|
4915
|
+
if (!content) continue;
|
|
4916
|
+
const parsed = parseEnv(content);
|
|
4917
|
+
if (!projectId) projectId = parsed["AMBA_PROJECT_ID"];
|
|
4918
|
+
if (!apiUrl) apiUrl = parsed["AMBA_API_URL"];
|
|
4919
|
+
if (projectId && apiUrl) break;
|
|
4920
|
+
}
|
|
4921
|
+
if (!projectId) throw new Error(`AMBA_PROJECT_ID not found. Run ${pc.cyan("amba init")} or set it in .env.local.`);
|
|
4922
|
+
return {
|
|
4923
|
+
projectId,
|
|
4924
|
+
apiUrl: apiUrl ?? "https://api.amba.dev"
|
|
4925
|
+
};
|
|
4926
|
+
}
|
|
4927
|
+
/**
|
|
4928
|
+
* Parse a `.env`-style file body into a flat string map.
|
|
4929
|
+
*
|
|
4930
|
+
* Lines are trimmed; blank lines and `#`-comments are skipped; lines
|
|
4931
|
+
* without an `=` are skipped. Values may be wrapped in matching single
|
|
4932
|
+
* or double quotes which are stripped on read. Bug fixes should land
|
|
4933
|
+
* here once — both `project-config.ts` (resolves `AMBA_PROJECT_ID`)
|
|
4934
|
+
* and `commands/functions.ts` (loads `.env.local` for the local dev
|
|
4935
|
+
* server) call this.
|
|
4936
|
+
*/
|
|
4937
|
+
function parseEnv(content) {
|
|
4938
|
+
const out = {};
|
|
4939
|
+
for (const rawLine of content.split("\n")) {
|
|
4940
|
+
const line = rawLine.trim();
|
|
4941
|
+
if (!line || line.startsWith("#")) continue;
|
|
4942
|
+
const eq = line.indexOf("=");
|
|
4943
|
+
if (eq === -1) continue;
|
|
4944
|
+
const key = line.slice(0, eq).trim();
|
|
4945
|
+
let value = line.slice(eq + 1).trim();
|
|
4946
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
4947
|
+
out[key] = value;
|
|
4948
|
+
}
|
|
4949
|
+
return out;
|
|
2263
4950
|
}
|
|
2264
4951
|
//#endregion
|
|
2265
4952
|
//#region src/bundle.ts
|
|
2266
4953
|
/**
|
|
2267
4954
|
* Customer-function bundling for `amba functions deploy`.
|
|
2268
4955
|
*
|
|
2269
|
-
* Uses esbuild
|
|
2270
|
-
*
|
|
2271
|
-
*
|
|
2272
|
-
* these resolve at dispatch time via platform-level bindings.
|
|
4956
|
+
* Uses esbuild. Customer code is bundled into a single self-contained
|
|
4957
|
+
* ES module — the upstream runtime resolves nothing at dispatch time
|
|
4958
|
+
* except built-in JavaScript globals.
|
|
2273
4959
|
*
|
|
2274
4960
|
* Two checks gate the bundle before upload:
|
|
2275
4961
|
* 1. Pre-upload size check against `BUNDLE_MAX_SIZE_BYTES` (8 MB
|
|
2276
4962
|
* default — the platform's 10 MB compressed cap minus 2 MB
|
|
2277
4963
|
* headroom) with a clear error pointing at the externalization
|
|
2278
4964
|
* config.
|
|
2279
|
-
* 2. Bundle-shape report — the CLI prints what's
|
|
2280
|
-
*
|
|
4965
|
+
* 2. Bundle-shape report — the CLI prints what's bundled vs
|
|
4966
|
+
* externalized at deploy time so size issues are debuggable.
|
|
4967
|
+
*
|
|
4968
|
+
* History note (2026-05-27): the prior version of this file pinned
|
|
4969
|
+
* `@layers/amba-functions` + `@layers/amba-api-middleware` as
|
|
4970
|
+
* "platform-level bindings" externals. Neither is — they were
|
|
4971
|
+
* server-side packages, and `@layers/amba-functions` was unpublished
|
|
4972
|
+
* in the 4.0.2 cutover (a deprecated wrapper that never matched the
|
|
4973
|
+
* actual runtime). Any function importing one of them was rejected
|
|
4974
|
+
* upstream as "no such module." The default externals list is now
|
|
4975
|
+
* empty; customer code is expected to be self-contained.
|
|
2281
4976
|
*/
|
|
2282
4977
|
/**
|
|
2283
|
-
* Modules
|
|
2284
|
-
*
|
|
2285
|
-
*
|
|
4978
|
+
* Modules the bundler treats as `external` by default. The Amba
|
|
4979
|
+
* function runtime exposes zero npm packages — there is no "platform
|
|
4980
|
+
* stdlib" for customer functions to import. Keep this list empty.
|
|
4981
|
+
*
|
|
4982
|
+
* The `extraExternals` field on `BundleOptions` is a programmatic
|
|
4983
|
+
* escape hatch (used by tests + future CLI wiring). It's intentionally
|
|
4984
|
+
* not exposed as a `amba functions deploy` flag today — externalizing
|
|
4985
|
+
* a module that isn't actually provided at runtime is exactly the
|
|
4986
|
+
* footgun this list-defaults-to-empty change closes.
|
|
2286
4987
|
*/
|
|
2287
|
-
const RUNTIME_STDLIB_EXTERNALS = [
|
|
2288
|
-
"@layers/amba-functions",
|
|
2289
|
-
"@layers/amba-api-middleware",
|
|
2290
|
-
"@anthropic-ai/sdk",
|
|
2291
|
-
"postgres",
|
|
2292
|
-
"zod"
|
|
2293
|
-
];
|
|
4988
|
+
const RUNTIME_STDLIB_EXTERNALS = [];
|
|
2294
4989
|
var BundleSizeError = class extends Error {
|
|
2295
4990
|
sizeBytes;
|
|
2296
4991
|
maxBytes;
|
|
2297
4992
|
constructor(sizeBytes, maxBytes) {
|
|
2298
|
-
super(`Function bundle is ${formatBytes$1(sizeBytes)} which exceeds the ${formatBytes$1(maxBytes)} cap.
|
|
4993
|
+
super(`Function bundle is ${formatBytes$1(sizeBytes)} which exceeds the ${formatBytes$1(maxBytes)} cap. Split the function into smaller pieces or drop heavy dependencies. (There is no runtime-provided npm stdlib to externalize against — every import must bundle.)`);
|
|
2299
4994
|
this.sizeBytes = sizeBytes;
|
|
2300
4995
|
this.maxBytes = maxBytes;
|
|
2301
4996
|
this.name = "BundleSizeError";
|
|
@@ -2352,8 +5047,10 @@ function printBundleReport(bundle) {
|
|
|
2352
5047
|
console.log(pc.dim(" Bundle:"));
|
|
2353
5048
|
console.log(pc.dim(" size: ") + `${formatBytes$1(bundle.compressedSize)} compressed ` + pc.dim(`(${formatBytes$1(bundle.uncompressedSize)} raw)`));
|
|
2354
5049
|
console.log(pc.dim(" sha: ") + bundle.sha256.slice(0, 16) + pc.dim("…"));
|
|
2355
|
-
|
|
2356
|
-
|
|
5050
|
+
if (bundle.externals.length > 0) {
|
|
5051
|
+
console.log(pc.dim(" externalized:"));
|
|
5052
|
+
for (const e of bundle.externals) console.log(pc.dim(" • ") + e);
|
|
5053
|
+
}
|
|
2357
5054
|
console.log();
|
|
2358
5055
|
}
|
|
2359
5056
|
async function gzipSizeOf(bytes) {
|
|
@@ -2370,51 +5067,6 @@ function formatBytes$1(n) {
|
|
|
2370
5067
|
return `${(n / 1024 / 1024).toFixed(2)}MB`;
|
|
2371
5068
|
}
|
|
2372
5069
|
//#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
5070
|
//#region src/commands/functions.ts
|
|
2419
5071
|
/**
|
|
2420
5072
|
* `amba functions ...` commands.
|
|
@@ -2447,7 +5099,7 @@ async function functionsDeployCommand(entryPoint, options = {}) {
|
|
|
2447
5099
|
bundleCode: bundle.code,
|
|
2448
5100
|
rate_limit: rateLimit
|
|
2449
5101
|
});
|
|
2450
|
-
console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version}
|
|
5102
|
+
console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version}`)}`);
|
|
2451
5103
|
console.log(pc.green(" ✓") + ` URL: ${pc.underline(result.fn_url)}`);
|
|
2452
5104
|
if (rateLimit) console.log(pc.dim(` Rate limit: ${rateLimit.max} per ${rateLimit.window} (key=${rateLimit.key}) — enforced pre-dispatch`));
|
|
2453
5105
|
console.log();
|
|
@@ -2465,10 +5117,10 @@ async function functionsListCommand() {
|
|
|
2465
5117
|
}
|
|
2466
5118
|
async function functionsDeleteCommand(name, options = {}) {
|
|
2467
5119
|
validateFunctionName(name);
|
|
2468
|
-
if (!options.confirm || options.confirm !== name) throw new Error(`Delete is destructive. Pass --confirm ${name} to proceed.
|
|
5120
|
+
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
5121
|
const cascade = (await deleteFunctionViaApi((await loadProjectConfig()).projectId, name, { confirm: name })).data.cascade;
|
|
2470
5122
|
console.log(pc.green(" ✓") + ` Deleted ${pc.bold(name)}.`);
|
|
2471
|
-
console.log(pc.dim(` Cascade:
|
|
5123
|
+
console.log(pc.dim(` Cascade: runtime_script_removed=${cascade.runtime_script_removed ?? false}, function_deployments_marked_disabled=${cascade.function_deployments_marked_disabled ?? 0}`));
|
|
2472
5124
|
}
|
|
2473
5125
|
async function functionsScheduleCommand(name, cron, options = {}) {
|
|
2474
5126
|
const projectConfig = await loadProjectConfig();
|
|
@@ -2485,15 +5137,364 @@ async function functionsScheduleCommand(name, cron, options = {}) {
|
|
|
2485
5137
|
console.log();
|
|
2486
5138
|
}
|
|
2487
5139
|
/**
|
|
2488
|
-
* `amba functions dev
|
|
2489
|
-
*
|
|
5140
|
+
* `amba functions dev <file>` — run the function locally with file-change
|
|
5141
|
+
* hot reload.
|
|
5142
|
+
*
|
|
5143
|
+
* Architecture: the CLI process owns the file watcher + bundler; the
|
|
5144
|
+
* actual HTTP server lives in a child Node process that imports the
|
|
5145
|
+
* bundle and binds to the user's port. On file change, the parent kills
|
|
5146
|
+
* the child, writes a fresh bundle, and respawns. This bounds memory
|
|
5147
|
+
* (each rebuild = fresh V8 heap) and isolates customer-handler crashes
|
|
5148
|
+
* from the CLI. Brief downtime per rebuild (~100ms while the port is
|
|
5149
|
+
* released and re-bound); incoming connections during the swap queue
|
|
5150
|
+
* at the TCP listen backlog and complete once the new child is up.
|
|
5151
|
+
*
|
|
5152
|
+
* The bundler is the same esbuild pipeline `amba functions deploy` uses
|
|
5153
|
+
* — externalization rules, size cap, and source-map handling are
|
|
5154
|
+
* identical so dev → prod parity is automatic.
|
|
5155
|
+
*
|
|
5156
|
+
* Handler shape: `export default async function (req: Request): Promise<Response>`.
|
|
5157
|
+
* `.env.local` values in the customer's working directory are passed
|
|
5158
|
+
* through to the child process so the handler can read them via
|
|
5159
|
+
* `process.env.MY_KEY`. Existing values in the parent's env take
|
|
5160
|
+
* priority (so `MY_KEY=x amba functions dev …` wins).
|
|
2490
5161
|
*/
|
|
2491
|
-
async function functionsDevCommand(
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
5162
|
+
async function functionsDevCommand(entryPoint, options = {}) {
|
|
5163
|
+
const port = validatePort(options.port);
|
|
5164
|
+
const resolvedEntry = resolve(entryPoint);
|
|
5165
|
+
const childEnv = {
|
|
5166
|
+
...await loadEnvLocal(),
|
|
5167
|
+
...process.env
|
|
5168
|
+
};
|
|
5169
|
+
const tmpRoot = await mkdtemp(join(tmpdir(), "amba-fn-dev-"));
|
|
5170
|
+
const bundlePath = join(tmpRoot, "fn.mjs");
|
|
5171
|
+
const runnerPath = join(tmpRoot, "runner.mjs");
|
|
5172
|
+
await writeFile(runnerPath, DEV_RUNNER_SOURCE, "utf8");
|
|
5173
|
+
let child = null;
|
|
5174
|
+
let buildSeq = 0;
|
|
5175
|
+
let shuttingDown = false;
|
|
5176
|
+
let inFlightRebuild = null;
|
|
5177
|
+
let rebuildQueue = Promise.resolve(true);
|
|
5178
|
+
const scheduleRebuild = () => {
|
|
5179
|
+
rebuildQueue = rebuildQueue.then(() => rebuildLocked());
|
|
5180
|
+
inFlightRebuild = rebuildQueue;
|
|
5181
|
+
return rebuildQueue;
|
|
5182
|
+
};
|
|
5183
|
+
/**
|
|
5184
|
+
* Returns true when the child was spawned and serving on the port,
|
|
5185
|
+
* false on any failure path. The caller decides whether to surface a
|
|
5186
|
+
* "server is listening" banner based on that — a green checkmark
|
|
5187
|
+
* after a red error would mislead users.
|
|
5188
|
+
*/
|
|
5189
|
+
async function rebuildLocked() {
|
|
5190
|
+
buildSeq++;
|
|
5191
|
+
const seq = buildSeq;
|
|
5192
|
+
let bundleResult;
|
|
5193
|
+
try {
|
|
5194
|
+
bundleResult = await bundleFunction({
|
|
5195
|
+
entryPoint: resolvedEntry,
|
|
5196
|
+
sourcemap: "inline"
|
|
5197
|
+
});
|
|
5198
|
+
} catch (err) {
|
|
5199
|
+
console.error(pc.red(" Bundle failed:"), err instanceof Error ? err.message : String(err));
|
|
5200
|
+
return false;
|
|
5201
|
+
}
|
|
5202
|
+
if (seq !== buildSeq || shuttingDown) return false;
|
|
5203
|
+
try {
|
|
5204
|
+
await writeFile(bundlePath, bundleResult.code, "utf8");
|
|
5205
|
+
} catch (err) {
|
|
5206
|
+
console.error(pc.red(" Write failed:"), err instanceof Error ? err.message : String(err));
|
|
5207
|
+
return false;
|
|
5208
|
+
}
|
|
5209
|
+
if (seq !== buildSeq || shuttingDown) return false;
|
|
5210
|
+
if (child) {
|
|
5211
|
+
const prev = child;
|
|
5212
|
+
prev.kill("SIGTERM");
|
|
5213
|
+
await waitForChildExit(prev, 2e3);
|
|
5214
|
+
if (child === prev) child = null;
|
|
5215
|
+
}
|
|
5216
|
+
if (seq !== buildSeq || shuttingDown) return false;
|
|
5217
|
+
const next = spawn(process.execPath, [
|
|
5218
|
+
runnerPath,
|
|
5219
|
+
bundlePath,
|
|
5220
|
+
String(port)
|
|
5221
|
+
], {
|
|
5222
|
+
env: childEnv,
|
|
5223
|
+
stdio: [
|
|
5224
|
+
"ignore",
|
|
5225
|
+
"pipe",
|
|
5226
|
+
"inherit"
|
|
5227
|
+
]
|
|
5228
|
+
});
|
|
5229
|
+
let spawnError = null;
|
|
5230
|
+
next.on("error", (err) => {
|
|
5231
|
+
spawnError = err;
|
|
5232
|
+
});
|
|
5233
|
+
try {
|
|
5234
|
+
await waitForChildReady(next, 5e3);
|
|
5235
|
+
} catch (err) {
|
|
5236
|
+
const cause = spawnError ?? (err instanceof Error ? err : new Error(String(err)));
|
|
5237
|
+
console.error(pc.red(" Child failed to start:"), cause.message);
|
|
5238
|
+
next.kill("SIGKILL");
|
|
5239
|
+
return false;
|
|
5240
|
+
}
|
|
5241
|
+
if (spawnError) {
|
|
5242
|
+
console.error(pc.red(" Child failed to start:"), spawnError.message);
|
|
5243
|
+
next.kill("SIGKILL");
|
|
5244
|
+
return false;
|
|
5245
|
+
}
|
|
5246
|
+
if (seq !== buildSeq || shuttingDown) {
|
|
5247
|
+
next.kill("SIGTERM");
|
|
5248
|
+
return false;
|
|
5249
|
+
}
|
|
5250
|
+
child = next;
|
|
5251
|
+
next.stdout?.on("data", (chunk) => process.stdout.write(chunk));
|
|
5252
|
+
next.on("exit", (code, signal) => {
|
|
5253
|
+
if (child === next && !shuttingDown && signal !== "SIGTERM") {
|
|
5254
|
+
console.error(pc.red(` Child exited unexpectedly (code=${code}, signal=${signal ?? "none"})`));
|
|
5255
|
+
child = null;
|
|
5256
|
+
}
|
|
5257
|
+
});
|
|
5258
|
+
console.log(pc.green(" ✓") + pc.dim(` bundle ready — ${bundleResult.uncompressedSize} bytes (${bundleResult.sha256.slice(0, 12)}…)`));
|
|
5259
|
+
return true;
|
|
5260
|
+
}
|
|
5261
|
+
const initialOk = await scheduleRebuild();
|
|
5262
|
+
let watcherCloser = null;
|
|
5263
|
+
let pendingRebuildTimer = null;
|
|
5264
|
+
if (!options.noWatch) {
|
|
5265
|
+
const watcher = watch(resolvedEntry, () => {
|
|
5266
|
+
if (shuttingDown) return;
|
|
5267
|
+
if (pendingRebuildTimer) clearTimeout(pendingRebuildTimer);
|
|
5268
|
+
pendingRebuildTimer = setTimeout(() => {
|
|
5269
|
+
pendingRebuildTimer = null;
|
|
5270
|
+
if (shuttingDown) return;
|
|
5271
|
+
console.log(pc.dim("\n ↻ file changed — rebuilding"));
|
|
5272
|
+
scheduleRebuild();
|
|
5273
|
+
}, 100);
|
|
5274
|
+
});
|
|
5275
|
+
watcherCloser = () => watcher.close();
|
|
5276
|
+
}
|
|
5277
|
+
console.log();
|
|
5278
|
+
if (initialOk) console.log(pc.green(" ✓") + ` Local dev server: ${pc.underline(`http://localhost:${port}`)}`);
|
|
5279
|
+
else console.log(pc.yellow(" !") + " Dev server is NOT listening — fix the bundle / handler errors above and save the file.");
|
|
5280
|
+
console.log(pc.dim(` Entry: ${entryPoint}`));
|
|
5281
|
+
if (!options.noWatch) console.log(pc.dim(" Watching for changes. Ctrl+C to stop."));
|
|
5282
|
+
console.log();
|
|
5283
|
+
const shutdown = async (exitCode = 0) => {
|
|
5284
|
+
if (shuttingDown) return;
|
|
5285
|
+
shuttingDown = true;
|
|
5286
|
+
if (pendingRebuildTimer) clearTimeout(pendingRebuildTimer);
|
|
5287
|
+
watcherCloser?.();
|
|
5288
|
+
if (inFlightRebuild) try {
|
|
5289
|
+
await inFlightRebuild;
|
|
5290
|
+
} catch {}
|
|
5291
|
+
if (child) {
|
|
5292
|
+
const c = child;
|
|
5293
|
+
child = null;
|
|
5294
|
+
c.kill("SIGTERM");
|
|
5295
|
+
await waitForChildExit(c, 2e3);
|
|
5296
|
+
}
|
|
5297
|
+
await rm(tmpRoot, {
|
|
5298
|
+
recursive: true,
|
|
5299
|
+
force: true
|
|
5300
|
+
}).catch(() => {});
|
|
5301
|
+
process.exit(exitCode);
|
|
5302
|
+
};
|
|
5303
|
+
process.on("SIGINT", () => void shutdown(0));
|
|
5304
|
+
process.on("SIGTERM", () => void shutdown(0));
|
|
5305
|
+
process.on("SIGHUP", () => void shutdown(0));
|
|
5306
|
+
process.on("uncaughtException", (err) => {
|
|
5307
|
+
console.error(pc.red(" Uncaught exception in CLI:"), err);
|
|
5308
|
+
shutdown(1);
|
|
5309
|
+
});
|
|
5310
|
+
process.on("unhandledRejection", (err) => {
|
|
5311
|
+
console.error(pc.red(" Unhandled rejection in CLI:"), err);
|
|
5312
|
+
shutdown(1);
|
|
5313
|
+
});
|
|
5314
|
+
}
|
|
5315
|
+
/**
|
|
5316
|
+
* Validate `--port` value. Defaults to 8787 when unset. Rejects
|
|
5317
|
+
* non-integers, ports below 1024 (which need root on POSIX), and
|
|
5318
|
+
* anything above 65535. Bailing here means the spawned child never
|
|
5319
|
+
* gets a NaN port that quietly picks a random ephemeral port — that
|
|
5320
|
+
* was confusing for customers ("the banner says :NaN").
|
|
5321
|
+
*/
|
|
5322
|
+
function validatePort(raw) {
|
|
5323
|
+
if (raw === void 0) return 8787;
|
|
5324
|
+
if (!Number.isInteger(raw) || raw < 1 || raw > 65535) throw new Error(`Invalid --port ${raw}; must be an integer between 1 and 65535`);
|
|
5325
|
+
if (raw < 1024) throw new Error(`--port ${raw} is in the privileged range (<1024) and would need root on POSIX. Pick a port ≥ 1024.`);
|
|
5326
|
+
return raw;
|
|
5327
|
+
}
|
|
5328
|
+
/**
|
|
5329
|
+
* Reads `.env.local` from the current working directory (if present)
|
|
5330
|
+
* and returns the parsed KEY → VALUE map. Uses the shared `parseEnv`
|
|
5331
|
+
* from `project-config.ts` so any fix to the parser propagates here
|
|
5332
|
+
* automatically.
|
|
5333
|
+
*/
|
|
5334
|
+
async function loadEnvLocal() {
|
|
5335
|
+
try {
|
|
5336
|
+
return parseEnv(await readFile(".env.local", "utf8"));
|
|
5337
|
+
} catch {
|
|
5338
|
+
return {};
|
|
5339
|
+
}
|
|
5340
|
+
}
|
|
5341
|
+
/**
|
|
5342
|
+
* Wait for a child process to print `AMBA_DEV_READY` on stdout (the
|
|
5343
|
+
* marker emitted by `DEV_RUNNER_SOURCE` once its HTTP server has bound
|
|
5344
|
+
* to the port). Rejects if the child exits before the marker arrives
|
|
5345
|
+
* or the timeout elapses.
|
|
5346
|
+
*/
|
|
5347
|
+
function waitForChildReady(child, timeoutMs) {
|
|
5348
|
+
return new Promise((resolveReady, rejectReady) => {
|
|
5349
|
+
let buffered = "";
|
|
5350
|
+
let settled = false;
|
|
5351
|
+
const settle = (fn) => {
|
|
5352
|
+
if (settled) return;
|
|
5353
|
+
settled = true;
|
|
5354
|
+
child.stdout?.off("data", onData);
|
|
5355
|
+
child.off("exit", onExit);
|
|
5356
|
+
clearTimeout(timer);
|
|
5357
|
+
fn();
|
|
5358
|
+
};
|
|
5359
|
+
const onData = (chunk) => {
|
|
5360
|
+
buffered += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
5361
|
+
const idx = buffered.indexOf("AMBA_DEV_READY\n");
|
|
5362
|
+
if (idx === -1) return;
|
|
5363
|
+
const before = buffered.slice(0, idx);
|
|
5364
|
+
const after = buffered.slice(idx + 15);
|
|
5365
|
+
if (before.length > 0) process.stdout.write(before);
|
|
5366
|
+
if (after.length > 0) process.stdout.write(after);
|
|
5367
|
+
settle(() => resolveReady());
|
|
5368
|
+
};
|
|
5369
|
+
const onExit = (code, signal) => {
|
|
5370
|
+
settle(() => rejectReady(/* @__PURE__ */ new Error(`child exited before ready (code=${code}, signal=${signal ?? "-"})`)));
|
|
5371
|
+
};
|
|
5372
|
+
const timer = setTimeout(() => {
|
|
5373
|
+
settle(() => rejectReady(/* @__PURE__ */ new Error(`timed out after ${timeoutMs}ms waiting for child ready`)));
|
|
5374
|
+
}, timeoutMs);
|
|
5375
|
+
child.stdout?.on("data", onData);
|
|
5376
|
+
child.on("exit", onExit);
|
|
5377
|
+
});
|
|
5378
|
+
}
|
|
5379
|
+
/**
|
|
5380
|
+
* Wait for a child to exit. If `timeoutMs` elapses first, SIGKILL it
|
|
5381
|
+
* and resolve anyway — the caller doesn't care which path closed the
|
|
5382
|
+
* port, only that it's closed.
|
|
5383
|
+
*/
|
|
5384
|
+
function waitForChildExit(child, timeoutMs) {
|
|
5385
|
+
return new Promise((resolveExit) => {
|
|
5386
|
+
if (child.exitCode !== null || child.signalCode !== null) return resolveExit();
|
|
5387
|
+
const timer = setTimeout(() => {
|
|
5388
|
+
child.kill("SIGKILL");
|
|
5389
|
+
}, timeoutMs);
|
|
5390
|
+
child.once("exit", () => {
|
|
5391
|
+
clearTimeout(timer);
|
|
5392
|
+
resolveExit();
|
|
5393
|
+
});
|
|
5394
|
+
});
|
|
2495
5395
|
}
|
|
2496
5396
|
/**
|
|
5397
|
+
* The script the child Node process runs. Bound as a string template
|
|
5398
|
+
* so the parent CLI ships with no separate file to package. The runner
|
|
5399
|
+
* imports the bundle once at startup (so the V8 heap holds exactly one
|
|
5400
|
+
* version of the customer code) and serves it on the user's port.
|
|
5401
|
+
*
|
|
5402
|
+
* READY handshake: the runner writes `AMBA_DEV_READY\n` to stdout once
|
|
5403
|
+
* `server.listen()` calls back — the parent uses that to gate killing
|
|
5404
|
+
* the previous child.
|
|
5405
|
+
*/
|
|
5406
|
+
const DEV_RUNNER_SOURCE = `
|
|
5407
|
+
import { createServer } from 'node:http';
|
|
5408
|
+
import { pathToFileURL } from 'node:url';
|
|
5409
|
+
|
|
5410
|
+
const bundlePath = process.argv[2];
|
|
5411
|
+
const port = Number(process.argv[3]);
|
|
5412
|
+
const parentPid = process.ppid;
|
|
5413
|
+
|
|
5414
|
+
// Parent-pid watchdog. If the CLI gets SIGKILL'd or its terminal
|
|
5415
|
+
// closes without delivering SIGHUP, the parent's signal handlers
|
|
5416
|
+
// don't run and this child would orphan — keep holding the port,
|
|
5417
|
+
// keep serving stale code. Polling ppid every 2s catches the orphan
|
|
5418
|
+
// case: when the parent dies, the OS reparents us (ppid changes,
|
|
5419
|
+
// becomes 1 on POSIX). On a clean SIGTERM from the parent, the
|
|
5420
|
+
// signal handler at the bottom exits us first so this never fires.
|
|
5421
|
+
setInterval(() => {
|
|
5422
|
+
if (process.ppid !== parentPid) {
|
|
5423
|
+
process.stderr.write(' Parent CLI is gone; exiting child to free port.\\n');
|
|
5424
|
+
process.exit(0);
|
|
5425
|
+
}
|
|
5426
|
+
}, 2000).unref();
|
|
5427
|
+
|
|
5428
|
+
let handler;
|
|
5429
|
+
try {
|
|
5430
|
+
// Node's ESM \`import()\` requires a URL on Windows — a raw absolute
|
|
5431
|
+
// path like C:\\\\foo\\\\bar.mjs throws ERR_UNSUPPORTED_ESM_URL_SCHEME.
|
|
5432
|
+
// \`pathToFileURL\` is a no-op equivalent on POSIX (returns file:///...).
|
|
5433
|
+
const bundleUrl = pathToFileURL(bundlePath).href;
|
|
5434
|
+
const mod = await import(bundleUrl);
|
|
5435
|
+
if (typeof mod.default !== 'function') {
|
|
5436
|
+
process.stderr.write(' Error: entry file must export default async function (req: Request): Promise<Response>\\n');
|
|
5437
|
+
process.exit(2);
|
|
5438
|
+
}
|
|
5439
|
+
handler = mod.default;
|
|
5440
|
+
} catch (err) {
|
|
5441
|
+
process.stderr.write(' Bundle import failed: ' + (err && err.stack ? err.stack : err) + '\\n');
|
|
5442
|
+
process.exit(2);
|
|
5443
|
+
}
|
|
5444
|
+
|
|
5445
|
+
const server = createServer(async (req, res) => {
|
|
5446
|
+
try {
|
|
5447
|
+
const proto = req.socket && req.socket.encrypted ? 'https' : 'http';
|
|
5448
|
+
const url = proto + '://' + (req.headers.host || 'localhost') + (req.url || '/');
|
|
5449
|
+
const headers = new Headers();
|
|
5450
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
5451
|
+
if (typeof v === 'string') headers.set(k, v);
|
|
5452
|
+
else if (Array.isArray(v)) for (const vv of v) headers.append(k, vv);
|
|
5453
|
+
}
|
|
5454
|
+
let body = null;
|
|
5455
|
+
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
5456
|
+
const chunks = [];
|
|
5457
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
5458
|
+
body = Buffer.concat(chunks);
|
|
5459
|
+
}
|
|
5460
|
+
const request = new Request(url, { method: req.method, headers, body });
|
|
5461
|
+
const response = await handler(request);
|
|
5462
|
+
res.statusCode = response.status;
|
|
5463
|
+
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
5464
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
5465
|
+
} catch (err) {
|
|
5466
|
+
process.stderr.write(' Handler error: ' + (err && err.stack ? err.stack : err) + '\\n');
|
|
5467
|
+
if (!res.headersSent) {
|
|
5468
|
+
res.statusCode = 500;
|
|
5469
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8');
|
|
5470
|
+
}
|
|
5471
|
+
res.end('Internal Server Error');
|
|
5472
|
+
}
|
|
5473
|
+
});
|
|
5474
|
+
|
|
5475
|
+
server.on('error', (err) => {
|
|
5476
|
+
process.stderr.write(' Server error: ' + (err && err.message ? err.message : err) + '\\n');
|
|
5477
|
+
process.exit(3);
|
|
5478
|
+
});
|
|
5479
|
+
|
|
5480
|
+
// Bind to 127.0.0.1 explicitly so the dev server is loopback-only.
|
|
5481
|
+
// Node's default \`server.listen(port)\` binds 0.0.0.0 (or ::), which
|
|
5482
|
+
// exposes the local handler — potentially reading secrets from
|
|
5483
|
+
// .env.local — to every device on the same Wi-Fi. The banner says
|
|
5484
|
+
// "localhost" so the customer's mental model expects local-only;
|
|
5485
|
+
// match that. Aligns with Vite / wrangler / next dev defaults.
|
|
5486
|
+
server.listen(port, '127.0.0.1', () => {
|
|
5487
|
+
process.stdout.write('AMBA_DEV_READY\\n');
|
|
5488
|
+
});
|
|
5489
|
+
|
|
5490
|
+
const shutdown = (signal) => {
|
|
5491
|
+
server.close(() => process.exit(0));
|
|
5492
|
+
setTimeout(() => process.exit(0), 1000).unref();
|
|
5493
|
+
};
|
|
5494
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
5495
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
5496
|
+
`;
|
|
5497
|
+
/**
|
|
2497
5498
|
* `amba functions consume <queue> <function>` — bind a function as the
|
|
2498
5499
|
* consumer for a queue. Customers send to a queue with `ctx.queue.send`;
|
|
2499
5500
|
* the genericQueueJobWorkflow looks up the binding and invokes the
|
|
@@ -2700,7 +5701,6 @@ async function aiProvidersAddCommand(provider, options) {
|
|
|
2700
5701
|
});
|
|
2701
5702
|
console.log(pc.green(" ✓") + ` Registered ${provider}`);
|
|
2702
5703
|
if (res.data.api_key_preview) console.log(pc.dim(` key preview: ${res.data.api_key_preview}`));
|
|
2703
|
-
console.log(pc.dim(` secret_name: ${res.data.api_key_secret_name ?? "(unset)"}`));
|
|
2704
5704
|
console.log();
|
|
2705
5705
|
}
|
|
2706
5706
|
async function aiProvidersListCommand() {
|
|
@@ -2712,7 +5712,7 @@ async function aiProvidersListCommand() {
|
|
|
2712
5712
|
console.log();
|
|
2713
5713
|
return;
|
|
2714
5714
|
}
|
|
2715
|
-
for (const p of res.data) console.log(` ${pc.bold(p.name)} ` + pc.dim(`
|
|
5715
|
+
for (const p of res.data) console.log(` ${pc.bold(p.name)} ` + pc.dim(`configured=${p.configured ? "yes" : "no"}`) + (p.updated_at ? pc.dim(` updated=${p.updated_at}`) : ""));
|
|
2716
5716
|
console.log();
|
|
2717
5717
|
}
|
|
2718
5718
|
async function aiProvidersDeleteCommand(provider) {
|
|
@@ -2808,6 +5808,168 @@ function renderStatus(status) {
|
|
|
2808
5808
|
}
|
|
2809
5809
|
}
|
|
2810
5810
|
//#endregion
|
|
5811
|
+
//#region src/commands/billing.ts
|
|
5812
|
+
/**
|
|
5813
|
+
* `amba billing *` subcommands — CLI access to the per-project billing
|
|
5814
|
+
* surface that ships behind `/v1/admin/projects/:id/billing/*`.
|
|
5815
|
+
*
|
|
5816
|
+
* amba billing status — tier, headroom on each
|
|
5817
|
+
* metered axis, next-bill date,
|
|
5818
|
+
* human_action_required.
|
|
5819
|
+
*
|
|
5820
|
+
* amba billing upgrade --tier <t> — print the Stripe Checkout
|
|
5821
|
+
* URL for tier ∈ {pro, scale}
|
|
5822
|
+
* [--interval month|year] at the chosen interval. CLI
|
|
5823
|
+
* deliberately does NOT auto-
|
|
5824
|
+
* open a browser: agents pipe
|
|
5825
|
+
* the URL into a confirmation
|
|
5826
|
+
* step, humans copy-paste.
|
|
5827
|
+
*
|
|
5828
|
+
* amba billing portal — print the Customer Portal
|
|
5829
|
+
* URL for card / cancel / etc.
|
|
5830
|
+
*
|
|
5831
|
+
* amba billing set-ceiling <usd|off> — cap (or remove) the monthly
|
|
5832
|
+
* spend ceiling.
|
|
5833
|
+
*
|
|
5834
|
+
* Project is resolved via `loadProjectConfig` (AMBA_PROJECT_ID env, then
|
|
5835
|
+
* .env / .env.local in the cwd) — same pattern as `amba secrets *`.
|
|
5836
|
+
*/
|
|
5837
|
+
function handleError(err) {
|
|
5838
|
+
if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
|
|
5839
|
+
else console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
5840
|
+
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
5841
|
+
else console.log(pc.red(" ✗") + " Unknown error");
|
|
5842
|
+
console.log();
|
|
5843
|
+
process.exit(1);
|
|
5844
|
+
}
|
|
5845
|
+
/**
|
|
5846
|
+
* Issue a POST/PUT to the admin API. `api-client.ts` doesn't export a
|
|
5847
|
+
* generic POST helper for arbitrary paths, so this command file owns
|
|
5848
|
+
* its own request wrapper — same auth flow as `request()` in api-client,
|
|
5849
|
+
* intentionally not exported there so we don't grow the public surface.
|
|
5850
|
+
*/
|
|
5851
|
+
async function adminWrite(method, path, body) {
|
|
5852
|
+
const token = await resolveBearerToken();
|
|
5853
|
+
const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
|
|
5854
|
+
const res = await fetch(url, {
|
|
5855
|
+
method,
|
|
5856
|
+
headers: {
|
|
5857
|
+
Authorization: `Bearer ${token}`,
|
|
5858
|
+
"Content-Type": "application/json",
|
|
5859
|
+
"User-Agent": "amba-cli/0.1.1"
|
|
5860
|
+
},
|
|
5861
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
5862
|
+
});
|
|
5863
|
+
if (!res.ok) {
|
|
5864
|
+
let message = `API request failed: ${res.status} ${res.statusText}`;
|
|
5865
|
+
let code;
|
|
5866
|
+
try {
|
|
5867
|
+
const errorBody = await res.json();
|
|
5868
|
+
if (errorBody.error?.message) {
|
|
5869
|
+
message = errorBody.error.message;
|
|
5870
|
+
code = errorBody.error.code;
|
|
5871
|
+
}
|
|
5872
|
+
} catch {}
|
|
5873
|
+
throw new ApiClientError(message, res.status, code);
|
|
5874
|
+
}
|
|
5875
|
+
return await res.json();
|
|
5876
|
+
}
|
|
5877
|
+
function formatAxis(label, axis, isStorage) {
|
|
5878
|
+
const used = axis.used === null ? "—" : isStorage ? axis.used >= 1024 ? `${(axis.used / 1024).toFixed(2)} GB` : `${axis.used} MB` : axis.used.toLocaleString();
|
|
5879
|
+
const limit = axis.limit === null ? "unlimited" : isStorage ? axis.limit >= 1024 ? `${(axis.limit / 1024).toFixed(0)} GB` : `${axis.limit} MB` : axis.limit.toLocaleString();
|
|
5880
|
+
const pct = axis.pct === null ? "—" : `${Math.round(axis.pct * 100)}%`;
|
|
5881
|
+
return ` ${pc.dim(label.padEnd(22))} ${used} / ${limit} ${pc.dim(`(${pct})`)}`;
|
|
5882
|
+
}
|
|
5883
|
+
async function billingStatusCommand() {
|
|
5884
|
+
console.log();
|
|
5885
|
+
console.log(pc.bold(" amba billing status"));
|
|
5886
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
5887
|
+
console.log();
|
|
5888
|
+
try {
|
|
5889
|
+
const { projectId } = await loadProjectConfig();
|
|
5890
|
+
const s = (await adminGet(`/projects/${projectId}/billing/status`)).data;
|
|
5891
|
+
console.log(` Tier: ${pc.bold(s.tier)}`);
|
|
5892
|
+
console.log(` Subscription: ${s.subscription_status ?? pc.dim("—")}`);
|
|
5893
|
+
console.log(` Next bill anchor: ${s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : pc.dim("—")}`);
|
|
5894
|
+
console.log(` Spend ceiling: ${s.ceiling_usd === null ? pc.dim("no cap") : `$${s.ceiling_usd}`}`);
|
|
5895
|
+
console.log(` Spend mode: ${s.mode}`);
|
|
5896
|
+
console.log(` Projected overage: $${s.projected_overage_usd_this_month.toFixed(2)}`);
|
|
5897
|
+
if (s.paused_at) console.log(` ${pc.yellow("Paused at:")} ${s.paused_at} ${pc.dim("(wakes on next request)")}`);
|
|
5898
|
+
console.log();
|
|
5899
|
+
console.log(pc.bold(" Usage — rolling 30 days"));
|
|
5900
|
+
console.log(formatAxis("Monthly active users", s.headroom.mau, false));
|
|
5901
|
+
console.log(formatAxis("Engagement events", s.headroom.engagement_events, false));
|
|
5902
|
+
console.log(formatAxis("Telemetry events", s.headroom.telemetry_events, false));
|
|
5903
|
+
console.log(formatAxis("Push deliveries", s.headroom.push, false));
|
|
5904
|
+
console.log(formatAxis("Database storage", s.headroom.db_storage_mb, true));
|
|
5905
|
+
console.log(formatAxis("Media storage", s.headroom.media_storage_mb, true));
|
|
5906
|
+
console.log();
|
|
5907
|
+
if (s.human_action_required !== "none") {
|
|
5908
|
+
const action = s.human_action_required.replaceAll("_", " ");
|
|
5909
|
+
console.log(pc.yellow(" Action required: ") + pc.bold(action));
|
|
5910
|
+
console.log();
|
|
5911
|
+
}
|
|
5912
|
+
} catch (err) {
|
|
5913
|
+
handleError(err);
|
|
5914
|
+
}
|
|
5915
|
+
}
|
|
5916
|
+
async function billingUpgradeCommand(input) {
|
|
5917
|
+
console.log();
|
|
5918
|
+
console.log(pc.bold(" amba billing upgrade"));
|
|
5919
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
5920
|
+
console.log();
|
|
5921
|
+
try {
|
|
5922
|
+
const { projectId } = await loadProjectConfig();
|
|
5923
|
+
const interval = input.interval ?? "month";
|
|
5924
|
+
const res = await adminWrite("POST", `/projects/${projectId}/billing/checkout`, {
|
|
5925
|
+
tier: input.tier,
|
|
5926
|
+
interval
|
|
5927
|
+
});
|
|
5928
|
+
console.log(` Tier: ${input.tier} (${interval}ly)`);
|
|
5929
|
+
console.log(` Session: ${pc.dim(res.data.session_id)}`);
|
|
5930
|
+
console.log();
|
|
5931
|
+
console.log(pc.bold(" Open this URL to complete checkout:"));
|
|
5932
|
+
console.log();
|
|
5933
|
+
console.log(` ${pc.cyan(res.data.url)}`);
|
|
5934
|
+
console.log();
|
|
5935
|
+
console.log(pc.dim(" Subscription status updates automatically once Stripe confirms (a few seconds)."));
|
|
5936
|
+
console.log();
|
|
5937
|
+
} catch (err) {
|
|
5938
|
+
handleError(err);
|
|
5939
|
+
}
|
|
5940
|
+
}
|
|
5941
|
+
async function billingPortalCommand() {
|
|
5942
|
+
console.log();
|
|
5943
|
+
console.log(pc.bold(" amba billing portal"));
|
|
5944
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
5945
|
+
console.log();
|
|
5946
|
+
try {
|
|
5947
|
+
const { projectId } = await loadProjectConfig();
|
|
5948
|
+
const res = await adminWrite("POST", `/projects/${projectId}/billing/portal`);
|
|
5949
|
+
console.log(pc.bold(" Open this URL to manage your subscription:"));
|
|
5950
|
+
console.log();
|
|
5951
|
+
console.log(` ${pc.cyan(res.data.url)}`);
|
|
5952
|
+
console.log();
|
|
5953
|
+
} catch (err) {
|
|
5954
|
+
handleError(err);
|
|
5955
|
+
}
|
|
5956
|
+
}
|
|
5957
|
+
async function billingSetCeilingCommand(input) {
|
|
5958
|
+
console.log();
|
|
5959
|
+
console.log(pc.bold(" amba billing set-ceiling"));
|
|
5960
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
5961
|
+
console.log();
|
|
5962
|
+
try {
|
|
5963
|
+
const { projectId } = await loadProjectConfig();
|
|
5964
|
+
await adminWrite(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
|
|
5965
|
+
if (input.ceiling === null) console.log(pc.green(" ✓") + " Spend ceiling removed (linear overage continues).");
|
|
5966
|
+
else console.log(pc.green(" ✓") + ` Spend ceiling set to $${input.ceiling}/mo.`);
|
|
5967
|
+
console.log();
|
|
5968
|
+
} catch (err) {
|
|
5969
|
+
handleError(err);
|
|
5970
|
+
}
|
|
5971
|
+
}
|
|
5972
|
+
//#endregion
|
|
2811
5973
|
//#region src/commands/collections.ts
|
|
2812
5974
|
/**
|
|
2813
5975
|
* `amba collections ...` — thin shells over the admin collection routes.
|
|
@@ -3238,10 +6400,9 @@ function validateHostname(host) {
|
|
|
3238
6400
|
if (!HOSTNAME_RE.test(host)) throw new Error(`Invalid hostname '${host}'. Must be a DNS-shaped name (e.g. site.example.com).`);
|
|
3239
6401
|
}
|
|
3240
6402
|
/**
|
|
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.
|
|
6403
|
+
* Per-deploy size cap. The hosting provider enforces 25 MiB per file +
|
|
6404
|
+
* 25k files; we pre-flight at 100 MiB total so the developer sees an
|
|
6405
|
+
* actionable error before we spend their time on a multi-second upload.
|
|
3245
6406
|
*/
|
|
3246
6407
|
const MAX_DEPLOYMENT_BYTES = 100 * 1024 * 1024;
|
|
3247
6408
|
const MAX_DEPLOYMENT_FILES = 2e4;
|
|
@@ -3264,23 +6425,23 @@ async function sitesDeployCommand(inputDir, options = {}) {
|
|
|
3264
6425
|
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
6426
|
console.log(pc.dim(` ${files.length} files, ${formatBytes(totalBytes)}`));
|
|
3266
6427
|
if (options.dryRun) {
|
|
3267
|
-
console.log(pc.yellow(" ! Dry run — skipping
|
|
6428
|
+
console.log(pc.yellow(" ! Dry run — skipping upload + control-plane write."));
|
|
3268
6429
|
console.log();
|
|
3269
6430
|
return;
|
|
3270
6431
|
}
|
|
3271
|
-
let
|
|
6432
|
+
let slug;
|
|
3272
6433
|
try {
|
|
3273
|
-
|
|
3274
|
-
console.log(pc.green(" ✓") + ` Registered site (
|
|
6434
|
+
slug = (await createSite(projectId, { name: siteName })).data.slug;
|
|
6435
|
+
console.log(pc.green(" ✓") + ` Registered site (slug=${slug})`);
|
|
3275
6436
|
} catch (err) {
|
|
3276
|
-
|
|
3277
|
-
console.log(pc.dim(` Site already registered (
|
|
6437
|
+
slug = (await describeSite(projectId, siteName)).data.slug;
|
|
6438
|
+
console.log(pc.dim(` Site already registered (slug=${slug})`));
|
|
3278
6439
|
}
|
|
3279
6440
|
console.log(pc.dim(" Uploading…"));
|
|
3280
6441
|
const dep = (await deploySiteViaApi(projectId, siteName, await buildPagesDeploymentForm(files))).data;
|
|
3281
6442
|
console.log(pc.green(" ✓") + ` Deployed ${dep.deployment_id.slice(0, 12)} ${pc.dim(`(branch=${dep.branch}, status=${dep.status})`)}`);
|
|
3282
6443
|
console.log(pc.green(" ✓") + ` URL: ${pc.underline(dep.url)}`);
|
|
3283
|
-
if (dep.preview_url && dep.preview_url !== dep.url) console.log(pc.dim(` preview
|
|
6444
|
+
if (dep.preview_url && dep.preview_url !== dep.url) console.log(pc.dim(` preview: ${dep.preview_url}`));
|
|
3284
6445
|
const domains = await listSiteDomains(projectId, siteName);
|
|
3285
6446
|
if (domains.data.length > 0) {
|
|
3286
6447
|
console.log();
|
|
@@ -3299,7 +6460,7 @@ async function sitesListCommand() {
|
|
|
3299
6460
|
}
|
|
3300
6461
|
for (const s of res.data) {
|
|
3301
6462
|
const status = s.status === "active" ? pc.green("active") : pc.yellow(s.status);
|
|
3302
|
-
console.log(` ${pc.bold(s.name)} ${status} ${pc.dim(`
|
|
6463
|
+
console.log(` ${pc.bold(s.name)} ${status} ${pc.dim(`slug=${s.slug} ${s.created_at}`)}`);
|
|
3303
6464
|
}
|
|
3304
6465
|
console.log();
|
|
3305
6466
|
}
|
|
@@ -3311,7 +6472,7 @@ async function sitesListCommand() {
|
|
|
3311
6472
|
async function sitesLogsCommand(name) {
|
|
3312
6473
|
validateSiteName(name);
|
|
3313
6474
|
console.log();
|
|
3314
|
-
console.log(pc.yellow(" ! `amba sites logs` is not available
|
|
6475
|
+
console.log(pc.yellow(" ! `amba sites logs` is not available via the CLI."));
|
|
3315
6476
|
console.log();
|
|
3316
6477
|
console.log(pc.dim(" Alternatives:"));
|
|
3317
6478
|
console.log(pc.dim(" amba sites describe <name> (current state + domains/certs)"));
|
|
@@ -3334,7 +6495,7 @@ async function sitesDomainAddCommand(hostname, options) {
|
|
|
3334
6495
|
console.log(pc.dim(` → site ${pc.cyan(options.site)}`));
|
|
3335
6496
|
console.log();
|
|
3336
6497
|
const res = await addSiteDomainViaApi(projectId, options.site, hostname);
|
|
3337
|
-
console.log(pc.green(" ✓") + ` Custom
|
|
6498
|
+
console.log(pc.green(" ✓") + ` Custom hostname registered`);
|
|
3338
6499
|
console.log();
|
|
3339
6500
|
console.log(pc.dim(" Point your DNS at:"));
|
|
3340
6501
|
console.log(` ${pc.bold("CNAME")} ${hostname} → ${pc.cyan(res.data.dns_target)}`);
|
|
@@ -3397,7 +6558,7 @@ async function sitesArchiveCommand(name, options = {}) {
|
|
|
3397
6558
|
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
6559
|
const cascade = (await deleteSiteViaApi((await loadProjectConfig()).projectId, name, { confirm: name })).data.cascade;
|
|
3399
6560
|
console.log(pc.green(" ✓") + ` Archived ${name}.`);
|
|
3400
|
-
console.log(pc.dim(` Cascade: domains_removed=${cascade.domains_removed ?? 0},
|
|
6561
|
+
console.log(pc.dim(` Cascade: domains_removed=${cascade.domains_removed ?? 0}, site_runtime_removed=${cascade.site_runtime_removed ?? false}`));
|
|
3401
6562
|
}
|
|
3402
6563
|
/**
|
|
3403
6564
|
* Sites are static-only. Dynamic logic belongs in `amba functions deploy`;
|
|
@@ -3501,7 +6662,7 @@ function runAction(fn) {
|
|
|
3501
6662
|
process.exit(1);
|
|
3502
6663
|
});
|
|
3503
6664
|
}
|
|
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) => {
|
|
6665
|
+
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
6666
|
let env;
|
|
3506
6667
|
if (opts.env === "development" || opts.env === "dev") env = "development";
|
|
3507
6668
|
else if (opts.env === "production" || opts.env === "prod") env = "production";
|
|
@@ -3509,11 +6670,35 @@ program.command("init").description("Initialize Amba in the current project (min
|
|
|
3509
6670
|
console.error(`Error: --env must be 'development' or 'production' (got '${opts.env}').`);
|
|
3510
6671
|
process.exit(1);
|
|
3511
6672
|
}
|
|
6673
|
+
if (opts.email && !opts.sandbox) {
|
|
6674
|
+
console.error("Error: --email is only valid with --sandbox.");
|
|
6675
|
+
process.exit(1);
|
|
6676
|
+
}
|
|
6677
|
+
if (opts.json && !opts.sandbox) {
|
|
6678
|
+
console.error("Error: --json is only valid with --sandbox.");
|
|
6679
|
+
process.exit(1);
|
|
6680
|
+
}
|
|
6681
|
+
if (opts.mcpConfig === false && !opts.sandbox) {
|
|
6682
|
+
console.error("Error: --no-mcp-config is only valid with --sandbox.");
|
|
6683
|
+
process.exit(1);
|
|
6684
|
+
}
|
|
6685
|
+
if (opts.skills === false && !opts.sandbox) {
|
|
6686
|
+
console.error("Error: --no-skills is only valid with --sandbox.");
|
|
6687
|
+
process.exit(1);
|
|
6688
|
+
}
|
|
3512
6689
|
await runAction(() => initCommand({
|
|
3513
6690
|
withExample: opts.withExample,
|
|
3514
|
-
env
|
|
6691
|
+
env,
|
|
6692
|
+
sandbox: opts.sandbox,
|
|
6693
|
+
sandboxEmail: opts.email,
|
|
6694
|
+
noMcpConfig: opts.mcpConfig === false,
|
|
6695
|
+
noSkills: opts.skills === false,
|
|
6696
|
+
json: opts.json
|
|
3515
6697
|
}));
|
|
3516
6698
|
});
|
|
6699
|
+
program.command("claim <email>").description("Bind your sandbox account to a real email (sends a one-click magic link)").action(async (email) => {
|
|
6700
|
+
await runAction(() => claimCommand(email));
|
|
6701
|
+
});
|
|
3517
6702
|
program.command("login").description("Authenticate with Amba").action(async () => {
|
|
3518
6703
|
await runAction(loginCommand);
|
|
3519
6704
|
});
|
|
@@ -3542,14 +6727,24 @@ const projects = program.command("projects").description("Project management com
|
|
|
3542
6727
|
projects.command("list").description("List all projects in the authenticated developer account").action(async () => {
|
|
3543
6728
|
await runAction(projectsListCommand);
|
|
3544
6729
|
});
|
|
3545
|
-
projects.command("create").description("Create a new project").requiredOption("--name <name>", "Project name").option("--env <env>", "Environment hint (informational; projects
|
|
6730
|
+
projects.command("create").description("Create a new project").requiredOption("--name <name>", "Project name").option("--env <env>", "Environment hint (informational; new projects default to the 'development' environment)").option("--bundle-id <id>", "Bundle identifier (iOS/Android). Audience for Sign in with Apple.").option("--google-oauth-client-id <id>", "Google OAuth 2.0 client id. Audience for Sign in with Google.").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").action(async (opts) => {
|
|
3546
6731
|
await runAction(() => projectsCreateCommand({
|
|
3547
6732
|
name: opts.name,
|
|
3548
6733
|
env: opts.env,
|
|
3549
6734
|
bundleId: opts.bundleId,
|
|
6735
|
+
googleOauthClientId: opts.googleOauthClientId,
|
|
3550
6736
|
platform: opts.platform
|
|
3551
6737
|
}));
|
|
3552
6738
|
});
|
|
6739
|
+
projects.command("update <projectId>").description("Update mutable fields on a project").option("--name <name>", "Display name").option("--bundle-id <id>", "Bundle identifier (Apple Sign In audience)").option("--google-oauth-client-id <id>", "Google OAuth 2.0 client id (Google Sign In audience). Public identifier, not a secret.").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").option("--environment <env>", "Environment label: 'development' | 'production'").action(async (projectId, opts) => {
|
|
6740
|
+
await runAction(() => projectsUpdateCommand(projectId, {
|
|
6741
|
+
name: opts.name,
|
|
6742
|
+
bundleId: opts.bundleId,
|
|
6743
|
+
googleOauthClientId: opts.googleOauthClientId,
|
|
6744
|
+
platform: opts.platform,
|
|
6745
|
+
environment: opts.environment
|
|
6746
|
+
}));
|
|
6747
|
+
});
|
|
3553
6748
|
projects.command("show <projectId>").description("Show full project details as JSON").action(async (projectId) => {
|
|
3554
6749
|
await runAction(() => projectsShowCommand(projectId));
|
|
3555
6750
|
});
|
|
@@ -3593,7 +6788,7 @@ program.command("schema").description("Schema export commands").command("export"
|
|
|
3593
6788
|
format: opts.format ?? "json"
|
|
3594
6789
|
}));
|
|
3595
6790
|
});
|
|
3596
|
-
const functions = program.command("functions").description("Customer
|
|
6791
|
+
const functions = program.command("functions").description("Customer serverless functions deployed to the edge");
|
|
3597
6792
|
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
6793
|
await runAction(() => functionsDeployCommand(file, opts));
|
|
3599
6794
|
});
|
|
@@ -3606,8 +6801,11 @@ functions.command("delete <name>").description("Disable + remove a function from
|
|
|
3606
6801
|
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
6802
|
await runAction(() => functionsScheduleCommand(name, cron, opts));
|
|
3608
6803
|
});
|
|
3609
|
-
functions.command("dev <file>").description("Run
|
|
3610
|
-
await runAction(() => functionsDevCommand(file
|
|
6804
|
+
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) => {
|
|
6805
|
+
await runAction(() => functionsDevCommand(file, {
|
|
6806
|
+
port: opts.port,
|
|
6807
|
+
noWatch: opts.watch === false
|
|
6808
|
+
}));
|
|
3611
6809
|
});
|
|
3612
6810
|
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
6811
|
await runAction(() => functionsLogsCommand(name, {
|
|
@@ -3642,7 +6840,41 @@ secrets.command("list").description("List secret sync status for the current pro
|
|
|
3642
6840
|
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
6841
|
await runAction(() => secretsUnsetCommand(name, opts));
|
|
3644
6842
|
});
|
|
3645
|
-
const
|
|
6843
|
+
const billing = program.command("billing").description("Per-project subscription, headroom, and spend controls");
|
|
6844
|
+
billing.command("status").description("Show current tier, headroom on each metered axis, and projected overage").action(async () => {
|
|
6845
|
+
await runAction(billingStatusCommand);
|
|
6846
|
+
});
|
|
6847
|
+
billing.command("upgrade").description("Print the Stripe Checkout URL for the chosen tier (does not auto-open a browser)").requiredOption("--tier <tier>", "'pro' or 'scale'").option("--interval <interval>", "'month' (default) or 'year' (20% off)", "month").action(async (opts) => {
|
|
6848
|
+
if (opts.tier !== "pro" && opts.tier !== "scale") {
|
|
6849
|
+
console.log(" ✗ --tier must be \"pro\" or \"scale\"");
|
|
6850
|
+
process.exit(1);
|
|
6851
|
+
}
|
|
6852
|
+
if (opts.interval !== "month" && opts.interval !== "year") {
|
|
6853
|
+
console.log(" ✗ --interval must be \"month\" or \"year\"");
|
|
6854
|
+
process.exit(1);
|
|
6855
|
+
}
|
|
6856
|
+
await runAction(() => billingUpgradeCommand({
|
|
6857
|
+
tier: opts.tier,
|
|
6858
|
+
interval: opts.interval
|
|
6859
|
+
}));
|
|
6860
|
+
});
|
|
6861
|
+
billing.command("portal").description("Print the Stripe Customer Portal URL (card, cancel, invoice download)").action(async () => {
|
|
6862
|
+
await runAction(billingPortalCommand);
|
|
6863
|
+
});
|
|
6864
|
+
billing.command("set-ceiling <amount>").description("Cap the monthly bill at <amount> USD, or pass 'off' to remove the cap").action(async (amount) => {
|
|
6865
|
+
let ceiling;
|
|
6866
|
+
if (amount.toLowerCase() === "off" || amount === "") ceiling = null;
|
|
6867
|
+
else {
|
|
6868
|
+
const parsed = Number(amount);
|
|
6869
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1e5) {
|
|
6870
|
+
console.log(" ✗ amount must be a number between 0 and 100000, or \"off\"");
|
|
6871
|
+
process.exit(1);
|
|
6872
|
+
}
|
|
6873
|
+
ceiling = parsed;
|
|
6874
|
+
}
|
|
6875
|
+
await runAction(() => billingSetCeilingCommand({ ceiling }));
|
|
6876
|
+
});
|
|
6877
|
+
const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
|
|
3646
6878
|
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
6879
|
await runAction(() => collectionsCreateCommand(name, opts));
|
|
3648
6880
|
});
|
|
@@ -3681,7 +6913,7 @@ sites.command("archive <name>").description("Archive a site (DESTRUCTIVE — del
|
|
|
3681
6913
|
await runAction(() => sitesArchiveCommand(name, opts));
|
|
3682
6914
|
});
|
|
3683
6915
|
const sitesDomain = sites.command("domain").description("Manage custom hostnames per site");
|
|
3684
|
-
sitesDomain.command("add <hostname>").description("Attach a custom hostname (
|
|
6916
|
+
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
6917
|
await runAction(() => sitesDomainAddCommand(hostname, {
|
|
3686
6918
|
site: opts.site,
|
|
3687
6919
|
zoneId: opts.zoneId,
|
|
@@ -3692,7 +6924,7 @@ sitesDomain.command("add <hostname>").description("Attach a custom hostname (CF
|
|
|
3692
6924
|
sitesDomain.command("list <site>").description("List custom hostnames attached to a site").action(async (site) => {
|
|
3693
6925
|
await runAction(() => sitesDomainListCommand(site));
|
|
3694
6926
|
});
|
|
3695
|
-
sitesDomain.command("remove <hostname>").description("Detach a custom hostname (best-effort
|
|
6927
|
+
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
6928
|
await runAction(() => sitesDomainRemoveCommand(hostname, {
|
|
3697
6929
|
site: opts.site,
|
|
3698
6930
|
zoneId: opts.zoneId
|