@layers/amba 1.1.0 → 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 +1 -1
- package/dist/api-client.d.ts +33 -6
- 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/init.d.ts +24 -24
- package/dist/commands/projects.d.ts +8 -0
- package/dist/credentials.d.ts +259 -0
- package/dist/index.js +2418 -657
- package/dist/sandbox.d.ts +35 -37
- package/dist/skill-installer.d.ts +95 -0
- package/dist/skills/presets.d.ts +146 -0
- package/dist/skills.d.ts +239 -48
- package/package.json +4 -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,16 +1,55 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import pc from "picocolors";
|
|
4
|
-
import { access, chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { access, chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
6
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
|
-
import { createInterface } from "node:readline";
|
|
7
7
|
import { createServer } from "node:http";
|
|
8
|
-
import { homedir, tmpdir } from "node:os";
|
|
9
8
|
import open from "open";
|
|
10
9
|
import { createHash, randomBytes } from "node:crypto";
|
|
10
|
+
import { createInterface } from "node:readline";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
11
12
|
import { createWriteStream, watch } from "node:fs";
|
|
12
13
|
import { spawn } from "node:child_process";
|
|
13
14
|
import { build } from "esbuild";
|
|
15
|
+
//#region ../shared/dist/index.js
|
|
16
|
+
/**
|
|
17
|
+
* Shared email-shape validation.
|
|
18
|
+
*
|
|
19
|
+
* Lives in `@layers/amba-shared` so the API (validates inbound emails
|
|
20
|
+
* server-side before minting tokens / writing DB rows) and the CLI
|
|
21
|
+
* (validates locally before round-tripping `amba claim <email>`) share
|
|
22
|
+
* one canonical implementation. Previous drift between the two
|
|
23
|
+
* implementations caused a real BugBot finding: a >320-char email
|
|
24
|
+
* passed the CLI's loose regex but bounced server-side with
|
|
25
|
+
* `INVALID_INPUT` — confusing UX.
|
|
26
|
+
*
|
|
27
|
+
* Intentionally permissive: validates the structural shape ("no
|
|
28
|
+
* whitespace, has an `@`, has a dot in the domain part, ≤320 chars")
|
|
29
|
+
* rather than running the full RFC-5321 grammar. Most callers
|
|
30
|
+
* (hosted dashboard, Expo app form, CLI prompts) already validate
|
|
31
|
+
* client-side; anything weirder than this check will bounce at the
|
|
32
|
+
* upstream email provider anyway.
|
|
33
|
+
*
|
|
34
|
+
* The check exists so a 400 INVALID_INPUT surfaces before we mint a
|
|
35
|
+
* token / write a DB row / fire an outbound provider request — that's
|
|
36
|
+
* the load-bearing property, not "exactly RFC-compliant."
|
|
37
|
+
*
|
|
38
|
+
* The 320-char cap matches RFC 5321 §4.5.3.1.3 (path length, which
|
|
39
|
+
* includes the email + envelope wrappers). It's the conventional
|
|
40
|
+
* upper bound most validators converge on.
|
|
41
|
+
*/
|
|
42
|
+
function isPlausibleEmail(value) {
|
|
43
|
+
if (typeof value !== "string") return false;
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
if (trimmed.length === 0 || trimmed.length > 320) return false;
|
|
46
|
+
const at = trimmed.indexOf("@");
|
|
47
|
+
if (at <= 0 || at === trimmed.length - 1) return false;
|
|
48
|
+
if (/\s/.test(trimmed)) return false;
|
|
49
|
+
if (!trimmed.slice(at + 1).includes(".")) return false;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
14
53
|
//#region src/_internal/shared.ts
|
|
15
54
|
const DEFAULT_API_URL = "https://api.amba.dev";
|
|
16
55
|
const CONSOLE_URL = "https://app.amba.dev";
|
|
@@ -246,6 +285,16 @@ function setBearerOverride(token) {
|
|
|
246
285
|
bearerOverride = token === null || token.length === 0 ? null : token;
|
|
247
286
|
}
|
|
248
287
|
/**
|
|
288
|
+
* Read the current bearer override without consuming it. Returns
|
|
289
|
+
* `null` when no override is set. Used by commands that need to know
|
|
290
|
+
* "did the operator supply a PAT for this invocation?" — e.g. `init`
|
|
291
|
+
* branches on whether to bypass stored-creds and use the supplied
|
|
292
|
+
* token verbatim.
|
|
293
|
+
*/
|
|
294
|
+
function getBearerOverride() {
|
|
295
|
+
return bearerOverride;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
249
298
|
* Resolve the bearer token to send on the next admin API call.
|
|
250
299
|
*
|
|
251
300
|
* Returns the override (PAT or JWT supplied via flag/env) when set,
|
|
@@ -352,6 +401,14 @@ async function listProjects() {
|
|
|
352
401
|
async function createProject(input) {
|
|
353
402
|
return request("POST", "/projects", input);
|
|
354
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* PATCH /admin/projects/:projectId — update mutable fields. Server
|
|
406
|
+
* silently ignores unknown keys; we filter to the documented allow-list
|
|
407
|
+
* before sending so a typo at the CLI doesn't pass the wire silently.
|
|
408
|
+
*/
|
|
409
|
+
async function updateProject(projectId, patch) {
|
|
410
|
+
return request("PATCH", `/projects/${projectId}`, patch);
|
|
411
|
+
}
|
|
355
412
|
async function getProject(projectId) {
|
|
356
413
|
return request("GET", `/projects/${projectId}`);
|
|
357
414
|
}
|
|
@@ -621,193 +678,6 @@ async function validateApiKey(apiKey) {
|
|
|
621
678
|
};
|
|
622
679
|
}
|
|
623
680
|
//#endregion
|
|
624
|
-
//#region src/context-files.ts
|
|
625
|
-
/**
|
|
626
|
-
* Generate AMBA.md project context file for AI agents.
|
|
627
|
-
*/
|
|
628
|
-
function generateAmbaMarkdown(opts) {
|
|
629
|
-
const sdkPackage = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
|
|
630
|
-
const providerExample = opts.framework === "expo" ? `
|
|
631
|
-
### Client Setup
|
|
632
|
-
|
|
633
|
-
\`\`\`tsx
|
|
634
|
-
// app/_layout.tsx
|
|
635
|
-
import { useEffect } from 'react';
|
|
636
|
-
import { Slot } from 'expo-router';
|
|
637
|
-
import { Amba } from '@layers/amba-expo';
|
|
638
|
-
|
|
639
|
-
export default function RootLayout() {
|
|
640
|
-
useEffect(() => {
|
|
641
|
-
Amba.configure({
|
|
642
|
-
projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
|
|
643
|
-
apiKey: process.env.EXPO_PUBLIC_AMBA_API_KEY!,
|
|
644
|
-
});
|
|
645
|
-
}, []);
|
|
646
|
-
|
|
647
|
-
return <Slot />;
|
|
648
|
-
}
|
|
649
|
-
\`\`\`
|
|
650
|
-
|
|
651
|
-
### Using the Client
|
|
652
|
-
|
|
653
|
-
\`\`\`tsx
|
|
654
|
-
import { Amba } from '@layers/amba-expo';
|
|
655
|
-
|
|
656
|
-
export default function MyComponent() {
|
|
657
|
-
const onPress = async () => {
|
|
658
|
-
// Track an event
|
|
659
|
-
await Amba.events.track('lesson_completed', { lesson_id: '123' });
|
|
660
|
-
|
|
661
|
-
// Sign in with Apple (requires expo-apple-authentication)
|
|
662
|
-
await Amba.signInWithApple();
|
|
663
|
-
|
|
664
|
-
// Read remote config
|
|
665
|
-
const showBanner = await Amba.config.fetch();
|
|
666
|
-
|
|
667
|
-
// Email sign-in
|
|
668
|
-
await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
|
|
669
|
-
};
|
|
670
|
-
|
|
671
|
-
// ...
|
|
672
|
-
}
|
|
673
|
-
\`\`\`` : `
|
|
674
|
-
### Client Setup
|
|
675
|
-
|
|
676
|
-
\`\`\`typescript
|
|
677
|
-
import { Amba } from '${sdkPackage}';
|
|
678
|
-
|
|
679
|
-
await Amba.configure({
|
|
680
|
-
projectId: process.env.AMBA_PROJECT_ID!,
|
|
681
|
-
apiKey: process.env.AMBA_API_KEY!,
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
// Track an event
|
|
685
|
-
await Amba.events.track('page_viewed', { page: '/pricing' });
|
|
686
|
-
|
|
687
|
-
// Read remote config
|
|
688
|
-
const config = await Amba.config.fetch();
|
|
689
|
-
|
|
690
|
-
// Email sign-in
|
|
691
|
-
await Amba.auth.signInWithEmail('user@example.com', 'hunter2');
|
|
692
|
-
\`\`\``;
|
|
693
|
-
return `# Amba Project Context
|
|
694
|
-
|
|
695
|
-
> This file provides context about the Amba integration for AI coding agents.
|
|
696
|
-
|
|
697
|
-
## Project Info
|
|
698
|
-
|
|
699
|
-
| Key | Value |
|
|
700
|
-
|-----|-------|
|
|
701
|
-
| Project ID | \`${opts.projectId}\` |
|
|
702
|
-
| Project Name | ${opts.projectName} |
|
|
703
|
-
| Framework | ${opts.framework} |
|
|
704
|
-
| SDK | \`${sdkPackage}\` |
|
|
705
|
-
|
|
706
|
-
## Environment Variables
|
|
707
|
-
|
|
708
|
-
These are configured in \`.env.local\`:
|
|
709
|
-
|
|
710
|
-
- \`AMBA_PROJECT_ID\` — Your project identifier
|
|
711
|
-
- \`AMBA_API_KEY\` — Client API key (safe for client-side use)
|
|
712
|
-
- \`AMBA_API_URL\` — API endpoint (defaults to https://api.amba.dev)
|
|
713
|
-
|
|
714
|
-
## SDK Usage
|
|
715
|
-
${providerExample}
|
|
716
|
-
|
|
717
|
-
## Available Features
|
|
718
|
-
|
|
719
|
-
- **Push Notifications** — Send targeted push notifications to user segments
|
|
720
|
-
- **Remote Config** — Key-value configuration that updates without app releases
|
|
721
|
-
- **Segments** — Group users by behavior, properties, or entitlements
|
|
722
|
-
- **Streaks** — Track user engagement streaks (daily, weekly)
|
|
723
|
-
- **Content Libraries** — Scheduled content delivery (daily tips, weekly challenges)
|
|
724
|
-
- **Entitlements** — Subscription status via RevenueCat integration
|
|
725
|
-
- **Analytics** — DAU, MAU, retention, and custom event tracking
|
|
726
|
-
|
|
727
|
-
## API Reference
|
|
728
|
-
|
|
729
|
-
- Admin API: \`https://api.amba.dev/v1/admin\`
|
|
730
|
-
- Client API: \`https://api.amba.dev/v1/client\`
|
|
731
|
-
- Docs: \`https://docs.amba.dev\`
|
|
732
|
-
|
|
733
|
-
## CLI Commands
|
|
734
|
-
|
|
735
|
-
\`\`\`bash
|
|
736
|
-
amba status # Check project health
|
|
737
|
-
amba push test # Send a test push notification
|
|
738
|
-
amba config list # List remote config values
|
|
739
|
-
amba config set <key> <value> # Set a config value
|
|
740
|
-
\`\`\`
|
|
741
|
-
`;
|
|
742
|
-
}
|
|
743
|
-
/**
|
|
744
|
-
* Generate .cursor/rules/amba.mdc Cursor rules file.
|
|
745
|
-
*/
|
|
746
|
-
function generateCursorRules(opts) {
|
|
747
|
-
const sdk = opts.framework === "expo" ? "@layers/amba-expo" : opts.framework === "react-native" ? "@layers/amba-react-native" : "@layers/amba-web";
|
|
748
|
-
return `---
|
|
749
|
-
description: Rules for working with the Amba SDK in this project
|
|
750
|
-
globs: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
|
|
751
|
-
---
|
|
752
|
-
|
|
753
|
-
# Amba SDK Rules
|
|
754
|
-
|
|
755
|
-
## Project Setup
|
|
756
|
-
- Project ID: \`${opts.projectId}\`
|
|
757
|
-
- SDK: \`${sdk}\`
|
|
758
|
-
- API URL: \`https://api.amba.dev\`
|
|
759
|
-
|
|
760
|
-
## Environment Variables
|
|
761
|
-
- Always read Amba config from environment variables, never hardcode
|
|
762
|
-
- Use \`process.env.AMBA_PROJECT_ID\` and \`process.env.AMBA_API_KEY\`
|
|
763
|
-
- The .env.local file contains the project credentials
|
|
764
|
-
|
|
765
|
-
## SDK Patterns
|
|
766
|
-
${opts.framework === "expo" ? `- Import the \`Amba\` singleton from \`@layers/amba-expo\`
|
|
767
|
-
- Call \`Amba.init({ projectId, apiKey })\` once in the root layout (inside a \`useEffect\`)
|
|
768
|
-
- The Expo wrapper auto-wires AsyncStorage, push tokens, and Apple/Google sign-in
|
|
769
|
-
- Use \`Amba.signInWithApple()\` / \`Amba.signInWithGoogle()\` for social auth one-liners
|
|
770
|
-
- Call \`Amba.track()\` for engagement events, don't build custom analytics` : `- Initialize the Amba client once and export it as a singleton
|
|
771
|
-
- Use \`Amba.client.track()\` for all engagement events
|
|
772
|
-
- Use \`Amba.client.config.get()\` for remote configuration
|
|
773
|
-
- Use \`Amba.client.auth\` for sign-up / sign-in flows`}
|
|
774
|
-
|
|
775
|
-
## Push Notifications
|
|
776
|
-
- Register push tokens via the SDK \`registerPushToken()\` method
|
|
777
|
-
- Handle notification payloads using the SDK's notification listener
|
|
778
|
-
- Don't implement custom push token management
|
|
779
|
-
|
|
780
|
-
## Remote Config
|
|
781
|
-
- Use remote config for feature flags and dynamic values
|
|
782
|
-
- Always provide sensible defaults when reading config values
|
|
783
|
-
- Config values are cached — don't fetch on every render
|
|
784
|
-
|
|
785
|
-
## Streaks
|
|
786
|
-
- Streaks are server-managed; the SDK provides read-only access
|
|
787
|
-
- Use \`track()\` to record qualifying events — the server evaluates streaks
|
|
788
|
-
- Show streak state from \`streak.current()\`, don't calculate manually
|
|
789
|
-
|
|
790
|
-
## Best Practices
|
|
791
|
-
- Don't store Amba API keys in source code or commit them to git
|
|
792
|
-
- Use \`.env.local\` for local development credentials
|
|
793
|
-
- The client API key (prefixed \`amb_dev_ck_\` or \`amb_live_ck_\`) is safe for client-side use
|
|
794
|
-
- Server keys (prefixed \`amb_dev_sk_\` or \`amb_live_sk_\`) must stay server-side only
|
|
795
|
-
`;
|
|
796
|
-
}
|
|
797
|
-
/**
|
|
798
|
-
* Write both context files to the project directory.
|
|
799
|
-
*/
|
|
800
|
-
async function generateContextFiles(opts) {
|
|
801
|
-
const files = [];
|
|
802
|
-
await writeFile(join(opts.cwd, "AMBA.md"), generateAmbaMarkdown(opts), "utf-8");
|
|
803
|
-
files.push("AMBA.md");
|
|
804
|
-
const cursorDir = join(opts.cwd, ".cursor", "rules");
|
|
805
|
-
await mkdir(cursorDir, { recursive: true });
|
|
806
|
-
await writeFile(join(cursorDir, "amba.mdc"), generateCursorRules(opts), "utf-8");
|
|
807
|
-
files.push(".cursor/rules/amba.mdc");
|
|
808
|
-
return files;
|
|
809
|
-
}
|
|
810
|
-
//#endregion
|
|
811
681
|
//#region src/sandbox.ts
|
|
812
682
|
/**
|
|
813
683
|
* Headless agentic sandbox bootstrap.
|
|
@@ -934,60 +804,9 @@ async function performSandboxSignup(req, options = {}) {
|
|
|
934
804
|
api_url: apiUrl,
|
|
935
805
|
provisioning_status: data.project.provisioning_status,
|
|
936
806
|
verify_url: data.project.verify_url,
|
|
937
|
-
email: req.email
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
/**
|
|
941
|
-
* Write the PAT to `~/.amba/credentials.json` (chmod 0600) in a shape
|
|
942
|
-
* the existing `loadCredentials` reader recognises.
|
|
943
|
-
*
|
|
944
|
-
* `auth.ts` was built around browser-OAuth tokens (`access_token` +
|
|
945
|
-
* `refresh_token` + `expires_at`). PATs are long-lived and don't refresh
|
|
946
|
-
* — but the stored-creds reader only inspects `access_token`, so we
|
|
947
|
-
* write the PAT there and leave `refresh_token` empty + a far-future
|
|
948
|
-
* `expires_at` so the expiry guard never fires.
|
|
949
|
-
*
|
|
950
|
-
* Real-credential safety: if the file already exists AND its `source`
|
|
951
|
-
* is NOT `'sandbox-init'` AND `access_token` is non-empty, we treat it
|
|
952
|
-
* as a real OAuth/PAT session and back it up to
|
|
953
|
-
* `credentials.json.bak-<unix-ms>` before overwriting. The next
|
|
954
|
-
* `--sandbox` run reuses our own previous sandbox creds without
|
|
955
|
-
* back-up. This keeps the agentic flow idempotent while preventing a
|
|
956
|
-
* silent clobber of a developer's real account.
|
|
957
|
-
*/
|
|
958
|
-
async function writeSandboxCredentials(pat, options = {}) {
|
|
959
|
-
const dir = join(options.homeDir ?? homedir(), ".amba");
|
|
960
|
-
const path = join(dir, "credentials.json");
|
|
961
|
-
await mkdir(dir, { recursive: true });
|
|
962
|
-
let backedUpTo = null;
|
|
963
|
-
try {
|
|
964
|
-
const existingRaw = await readFile(path, "utf-8");
|
|
965
|
-
const existing = JSON.parse(existingRaw);
|
|
966
|
-
const hasToken = typeof existing.access_token === "string" && existing.access_token.length > 0;
|
|
967
|
-
const isSandboxOwned = existing.source === "sandbox-init";
|
|
968
|
-
if (hasToken && !isSandboxOwned) {
|
|
969
|
-
backedUpTo = `${path}.bak-${Date.now()}`;
|
|
970
|
-
await writeFile(backedUpTo, existingRaw, "utf-8");
|
|
971
|
-
try {
|
|
972
|
-
await chmod(backedUpTo, 384);
|
|
973
|
-
} catch {}
|
|
974
|
-
}
|
|
975
|
-
} catch (err) {
|
|
976
|
-
if (!isEnoent(err)) {}
|
|
977
|
-
}
|
|
978
|
-
const payload = {
|
|
979
|
-
access_token: pat,
|
|
980
|
-
refresh_token: "",
|
|
981
|
-
expires_at: (/* @__PURE__ */ new Date("2099-12-31T00:00:00.000Z")).toISOString(),
|
|
982
|
-
source: "sandbox-init"
|
|
983
|
-
};
|
|
984
|
-
await writeFile(path, JSON.stringify(payload, null, 2), "utf-8");
|
|
985
|
-
try {
|
|
986
|
-
await chmod(path, 384);
|
|
987
|
-
} catch {}
|
|
988
|
-
return {
|
|
989
|
-
path,
|
|
990
|
-
backedUpTo
|
|
807
|
+
email: req.email,
|
|
808
|
+
developer_id: data.developer?.id ?? "",
|
|
809
|
+
developer_name: data.developer?.name
|
|
991
810
|
};
|
|
992
811
|
}
|
|
993
812
|
/**
|
|
@@ -996,23 +815,30 @@ async function writeSandboxCredentials(pat, options = {}) {
|
|
|
996
815
|
* Mirrors the `init` interactive flow exactly so the existing env-read
|
|
997
816
|
* conventions in the SDKs and CLI commands keep working. The merge
|
|
998
817
|
* logic: if the file already exists and contains an `AMBA_PROJECT_ID`
|
|
999
|
-
* line we replace the
|
|
818
|
+
* line we replace the Amba lines in place; otherwise we append a
|
|
1000
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).
|
|
1001
825
|
*/
|
|
1002
|
-
async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl) {
|
|
826
|
+
async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl, serverKey) {
|
|
1003
827
|
const envPath = join(cwd, ".env.local");
|
|
1004
|
-
const
|
|
828
|
+
const stanzaLines = [
|
|
1005
829
|
"# Amba SDK configuration (sandbox tier)",
|
|
1006
830
|
`AMBA_PROJECT_ID=${projectId}`,
|
|
1007
|
-
`AMBA_CLIENT_KEY=${clientKey}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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");
|
|
1011
837
|
let existing = "";
|
|
1012
838
|
try {
|
|
1013
839
|
existing = await readFile(envPath, "utf-8");
|
|
1014
840
|
} catch (err) {
|
|
1015
|
-
if (!isEnoent(err)) throw err;
|
|
841
|
+
if (!isEnoent$1(err)) throw err;
|
|
1016
842
|
}
|
|
1017
843
|
if (existing.length === 0) {
|
|
1018
844
|
await writeFile(envPath, stanza, "utf-8");
|
|
@@ -1030,6 +856,14 @@ async function writeSandboxEnvLocal(cwd, projectId, clientKey, apiUrl) {
|
|
|
1030
856
|
} else if (hadApiKey) updated = updated.replace(/^AMBA_API_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
|
|
1031
857
|
else if (hadClientKey) updated = updated.replace(/^AMBA_CLIENT_KEY=.*/m, () => `AMBA_CLIENT_KEY=${clientKey}`);
|
|
1032
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
|
+
}
|
|
1033
867
|
if (!/^AMBA_API_URL=/m.test(updated)) updated += (updated.endsWith("\n") ? "" : "\n") + `AMBA_API_URL=${apiUrl}\n`;
|
|
1034
868
|
await writeFile(envPath, updated, "utf-8");
|
|
1035
869
|
return envPath;
|
|
@@ -1069,8 +903,7 @@ without a credit card or email verification.
|
|
|
1069
903
|
|
|
1070
904
|
The credentials live in **\`.env.local\`** (\`AMBA_PROJECT_ID\`,
|
|
1071
905
|
\`AMBA_CLIENT_KEY\`, \`AMBA_API_URL\`) — gitignored by convention. Your
|
|
1072
|
-
Personal Access Token is stored in \`~/.amba/credentials.json
|
|
1073
|
-
written into every MCP client config we could detect.
|
|
906
|
+
Personal Access Token is stored in \`~/.amba/credentials.json\`.
|
|
1074
907
|
|
|
1075
908
|
## SDK quickstart
|
|
1076
909
|
|
|
@@ -1095,10 +928,11 @@ await Amba.events.track('app_opened');
|
|
|
1095
928
|
|
|
1096
929
|
## Upgrade past sandbox
|
|
1097
930
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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.
|
|
1102
936
|
|
|
1103
937
|
## Useful commands
|
|
1104
938
|
|
|
@@ -1129,42 +963,6 @@ function buildAmbaMcpEntry(pat) {
|
|
|
1129
963
|
};
|
|
1130
964
|
}
|
|
1131
965
|
/**
|
|
1132
|
-
* Map an MCP config file path back to its client family. Returns null
|
|
1133
|
-
* for paths that don't match any known config location — defensive
|
|
1134
|
-
* against future additions to `mcpClientTargets`.
|
|
1135
|
-
*
|
|
1136
|
-
* The match is on path tail rather than full equality so the cwd /
|
|
1137
|
-
* homedir-injected variants both classify correctly. We deliberately
|
|
1138
|
-
* accept both global and project-local Claude Code paths
|
|
1139
|
-
* (`.claude.json` and `.mcp.json`) as 'claude-code'.
|
|
1140
|
-
*/
|
|
1141
|
-
function classifyMcpPath(path) {
|
|
1142
|
-
if (path.endsWith(".claude.json") || path.endsWith(".mcp.json")) return "claude-code";
|
|
1143
|
-
if (path.includes(`/.cursor/`) || path.includes(`\\.cursor\\`)) return "cursor";
|
|
1144
|
-
if (path.includes("/windsurf/mcp_config.json") || path.includes("\\windsurf\\mcp_config.json")) return "windsurf";
|
|
1145
|
-
return null;
|
|
1146
|
-
}
|
|
1147
|
-
/**
|
|
1148
|
-
* Reduce a list of written-config paths to the set of unique client
|
|
1149
|
-
* families they belong to. Order: claude-code, cursor, windsurf (so
|
|
1150
|
-
* the done-message renders consistently). Skips unclassified paths
|
|
1151
|
-
* silently.
|
|
1152
|
-
*/
|
|
1153
|
-
function clientKindsFromPaths(paths) {
|
|
1154
|
-
const present = /* @__PURE__ */ new Set();
|
|
1155
|
-
for (const p of paths) {
|
|
1156
|
-
const kind = classifyMcpPath(p);
|
|
1157
|
-
if (kind) present.add(kind);
|
|
1158
|
-
}
|
|
1159
|
-
const ordered = [];
|
|
1160
|
-
for (const k of [
|
|
1161
|
-
"claude-code",
|
|
1162
|
-
"cursor",
|
|
1163
|
-
"windsurf"
|
|
1164
|
-
]) if (present.has(k)) ordered.push(k);
|
|
1165
|
-
return ordered;
|
|
1166
|
-
}
|
|
1167
|
-
/**
|
|
1168
966
|
* The list of MCP client config files we probe. Order matters only for
|
|
1169
967
|
* the printed report.
|
|
1170
968
|
*
|
|
@@ -1231,7 +1029,7 @@ async function mergeMcpConfigFile(target, pat) {
|
|
|
1231
1029
|
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) existing = parsed;
|
|
1232
1030
|
else throw new Error(`Existing config at ${target.path} is not a JSON object`);
|
|
1233
1031
|
} catch (err) {
|
|
1234
|
-
if (isEnoent(err)) {
|
|
1032
|
+
if (isEnoent$1(err)) {
|
|
1235
1033
|
if (!target.scaffoldIfMissing) return {
|
|
1236
1034
|
path: null,
|
|
1237
1035
|
backedUpTo: null
|
|
@@ -1300,7 +1098,7 @@ async function fileExists$1(path) {
|
|
|
1300
1098
|
return false;
|
|
1301
1099
|
}
|
|
1302
1100
|
}
|
|
1303
|
-
function isEnoent(err) {
|
|
1101
|
+
function isEnoent$1(err) {
|
|
1304
1102
|
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
1305
1103
|
}
|
|
1306
1104
|
/**
|
|
@@ -1313,27 +1111,1260 @@ function formatManualMcpSnippet(pat) {
|
|
|
1313
1111
|
return JSON.stringify({ mcpServers: { amba: buildAmbaMcpEntry(pat) } }, null, 2);
|
|
1314
1112
|
}
|
|
1315
1113
|
//#endregion
|
|
1316
|
-
//#region
|
|
1114
|
+
//#region src/credentials.ts
|
|
1317
1115
|
/**
|
|
1318
|
-
*
|
|
1116
|
+
* Two-scope credential model for `amba init`.
|
|
1319
1117
|
*
|
|
1320
|
-
*
|
|
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`).
|
|
1321
1121
|
*
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
* body wrapped in fumadocs frontmatter.
|
|
1326
|
-
* 2. The MCP resource `amba://prompts/expo-build` registered by
|
|
1327
|
-
* `registerAllResources()` in `./index.ts` and exposed by the
|
|
1328
|
-
* hosted MCP server at `mcp.amba.dev`.
|
|
1329
|
-
* 3. The inlined snapshot baked into the `/amba-build` Claude Code
|
|
1330
|
-
* skill by `amba init --sandbox` (see `packages/cli/src/skills.ts`).
|
|
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.
|
|
1331
1125
|
*
|
|
1332
|
-
*
|
|
1333
|
-
*
|
|
1334
|
-
*
|
|
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.
|
|
1335
1132
|
*
|
|
1336
|
-
*
|
|
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;
|
|
1337
2368
|
* it renders on docs.amba.dev). Re-run the drift test. The test will
|
|
1338
2369
|
* fail with a diff. Apply the same diff here. The two are kept in
|
|
1339
2370
|
* sync by hand because the MDX must be statically parseable for
|
|
@@ -1344,8 +2375,8 @@ function formatManualMcpSnippet(pat) {
|
|
|
1344
2375
|
* so it renders identically as `.md` (the MCP / skill consumers) and
|
|
1345
2376
|
* as `.mdx` (the docs site).
|
|
1346
2377
|
*/
|
|
1347
|
-
const EXPO_BUILD_PROMPT_MD = `> **Last reviewed:** 2026-05-
|
|
1348
|
-
> at [docs.amba.dev/
|
|
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).
|
|
1349
2380
|
> If you're reading an inlined snapshot from your
|
|
1350
2381
|
> \`.claude/skills/amba-build/SKILL.md\`, check the URL above for updates.
|
|
1351
2382
|
|
|
@@ -1361,7 +2392,7 @@ The CLI handles signup, project provisioning, env-file writes, and MCP
|
|
|
1361
2392
|
client config wiring in one command:
|
|
1362
2393
|
|
|
1363
2394
|
\`\`\`bash
|
|
1364
|
-
npx @layers/amba init
|
|
2395
|
+
npx -y @layers/amba init
|
|
1365
2396
|
\`\`\`
|
|
1366
2397
|
|
|
1367
2398
|
That's the entire setup. The CLI:
|
|
@@ -1374,14 +2405,17 @@ That's the entire setup. The CLI:
|
|
|
1374
2405
|
4. Writes \`AMBA.md\` (project-scoped context for the agent).
|
|
1375
2406
|
5. Auto-wires \`mcpServers.amba\` into every MCP client config it
|
|
1376
2407
|
detects on disk — Claude Code, Cursor, Windsurf.
|
|
1377
|
-
6.
|
|
2408
|
+
6. Verifies the PAT against the API and confirms it's good.
|
|
1378
2409
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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.
|
|
1382
2416
|
|
|
1383
2417
|
If you have the \`/amba-build\` skill installed (via
|
|
1384
|
-
\`npx @layers/amba init
|
|
2418
|
+
\`npx -y @layers/amba init\`), invoke it directly:
|
|
1385
2419
|
|
|
1386
2420
|
\`\`\`
|
|
1387
2421
|
/amba-build <DESIGN_HASH>
|
|
@@ -1401,11 +2435,13 @@ DX cascade is fixed.
|
|
|
1401
2435
|
- **React Native bundle size** — the React Native SDK adds ~4 MB to
|
|
1402
2436
|
the JS bundle today. Functional, just heavier than the long-term
|
|
1403
2437
|
goal. Tracked separately.
|
|
1404
|
-
- **Sandbox MAU cap (
|
|
2438
|
+
- **Sandbox MAU cap (100)** — the agent-mode sandbox tier caps at 100
|
|
1405
2439
|
monthly active users. If you blow through it during testing, call
|
|
1406
2440
|
\`amba_users_reset_sandbox\` to clear the counter — that tool exists
|
|
1407
|
-
specifically for this. Upgrade to the Free tier (
|
|
1408
|
-
|
|
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.
|
|
1409
2445
|
|
|
1410
2446
|
## How to read the design
|
|
1411
2447
|
|
|
@@ -1537,6 +2573,12 @@ gate.
|
|
|
1537
2573
|
\`expo export --platform ios\`, and \`expo export --platform android\`
|
|
1538
2574
|
must all succeed. If any one fails, the build fails. No
|
|
1539
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.)
|
|
1540
2582
|
|
|
1541
2583
|
## Verification gate
|
|
1542
2584
|
|
|
@@ -1618,33 +2660,47 @@ If \`BUILD_REPORT.md\` is missing any required section, or
|
|
|
1618
2660
|
//#endregion
|
|
1619
2661
|
//#region src/skills.ts
|
|
1620
2662
|
/**
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
1625
|
-
*
|
|
1626
|
-
*
|
|
1627
|
-
*
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
1632
|
-
*
|
|
1633
|
-
*
|
|
1634
|
-
*
|
|
1635
|
-
*
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
1638
|
-
*
|
|
1639
|
-
*
|
|
1640
|
-
*
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
1643
|
-
*
|
|
1644
|
-
*
|
|
1645
|
-
*
|
|
1646
|
-
*
|
|
1647
|
-
*
|
|
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.
|
|
1648
2704
|
*/
|
|
1649
2705
|
/**
|
|
1650
2706
|
* Build the contents of `.claude/skills/amba-build/SKILL.md`.
|
|
@@ -1652,17 +2708,6 @@ If \`BUILD_REPORT.md\` is missing any required section, or
|
|
|
1652
2708
|
* Exported as a pure function so the unit tests can assert structural
|
|
1653
2709
|
* properties (frontmatter, fetcher block, inlined snapshot fence)
|
|
1654
2710
|
* without round-tripping through the filesystem.
|
|
1655
|
-
*
|
|
1656
|
-
* Structure:
|
|
1657
|
-
* 1. YAML frontmatter — `description` so Claude Code's skill
|
|
1658
|
-
* indexer picks it up.
|
|
1659
|
-
* 2. Skill body — invocation instructions, fetcher one-liner,
|
|
1660
|
-
* fallback rule.
|
|
1661
|
-
* 3. Inlined snapshot — fenced code block containing the
|
|
1662
|
-
* EXPO_BUILD_PROMPT_MD body verbatim. The snapshot is bounded
|
|
1663
|
-
* by a marker comment so a future `amba update-skills` command
|
|
1664
|
-
* can find and refresh just the inlined region without
|
|
1665
|
-
* clobbering user customizations above it.
|
|
1666
2711
|
*/
|
|
1667
2712
|
function buildAmbaBuildSkillContent() {
|
|
1668
2713
|
return `---
|
|
@@ -1672,7 +2717,7 @@ description: Canonical /goal prompt for building a full Expo app with Amba as th
|
|
|
1672
2717
|
# /amba-build
|
|
1673
2718
|
|
|
1674
2719
|
When invoked, fetch the canonical prompt from
|
|
1675
|
-
\`https://docs.amba.dev/
|
|
2720
|
+
\`https://docs.amba.dev/prompts/expo-build.md\` and use it as the
|
|
1676
2721
|
\`/goal\` directive for building a full Expo app with Amba as the only
|
|
1677
2722
|
backend. The user supplies a design hash (URL or description) as the
|
|
1678
2723
|
argument; substitute it for every \`<DESIGN_HASH>\` placeholder in the
|
|
@@ -1692,7 +2737,7 @@ Replace \`<DESIGN_HASH>\` with:
|
|
|
1692
2737
|
## Fetcher
|
|
1693
2738
|
|
|
1694
2739
|
\`\`\`bash
|
|
1695
|
-
curl -sf https://docs.amba.dev/
|
|
2740
|
+
curl -sf https://docs.amba.dev/prompts/expo-build.md
|
|
1696
2741
|
\`\`\`
|
|
1697
2742
|
|
|
1698
2743
|
If \`curl\` fails (404, 5xx, network error, no internet), fall back to
|
|
@@ -1715,17 +2760,8 @@ ${EXPO_BUILD_PROMPT_MD}<!-- AMBA-BUILD-PROMPT-END -->
|
|
|
1715
2760
|
}
|
|
1716
2761
|
/**
|
|
1717
2762
|
* Write `.claude/skills/amba-build/SKILL.md` into the target project.
|
|
1718
|
-
*
|
|
1719
|
-
*
|
|
1720
|
-
* time `amba init --sandbox` runs (so the inlined snapshot stays
|
|
1721
|
-
* fresh). Anything the user customized above the snapshot markers
|
|
1722
|
-
* would be lost on a re-init; that's an accepted trade-off for the
|
|
1723
|
-
* agentic single-command flow.
|
|
1724
|
-
*
|
|
1725
|
-
* If a future need for "preserve user edits across re-init" surfaces,
|
|
1726
|
-
* the right shape is a separate `amba update-skills` command that
|
|
1727
|
-
* surgically rewrites the inlined-snapshot region only — leaving the
|
|
1728
|
-
* surrounding text untouched. Out of scope for DX-16.
|
|
2763
|
+
* Always overwrites — the inlined snapshot is meant to be regenerated
|
|
2764
|
+
* on each `amba init --sandbox` run.
|
|
1729
2765
|
*/
|
|
1730
2766
|
async function writeAmbaBuildSkill(options = {}) {
|
|
1731
2767
|
const skillDir = join(options.baseDir ?? process.cwd(), ".claude", "skills", "amba-build");
|
|
@@ -1734,6 +2770,334 @@ async function writeAmbaBuildSkill(options = {}) {
|
|
|
1734
2770
|
await writeFile(skillPath, buildAmbaBuildSkillContent(), "utf-8");
|
|
1735
2771
|
return { path: skillPath };
|
|
1736
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
|
|
2952
|
+
|
|
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.
|
|
2954
|
+
|
|
2955
|
+
## Bootstrap
|
|
2956
|
+
|
|
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.
|
|
2958
|
+
|
|
2959
|
+
## Playbook
|
|
2960
|
+
|
|
2961
|
+
When the user asks you to wire Amba into this codebase, run this journey end-to-end:
|
|
2962
|
+
|
|
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.
|
|
2968
|
+
|
|
2969
|
+
## Surfaces
|
|
2970
|
+
|
|
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\`.
|
|
2998
|
+
`;
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
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`.
|
|
3006
|
+
*/
|
|
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
|
+
};
|
|
3100
|
+
}
|
|
1737
3101
|
//#endregion
|
|
1738
3102
|
//#region src/commands/init.ts
|
|
1739
3103
|
function prompt(question) {
|
|
@@ -1748,6 +3112,85 @@ function prompt(question) {
|
|
|
1748
3112
|
});
|
|
1749
3113
|
});
|
|
1750
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
|
+
}
|
|
1751
3194
|
async function fileExists(path) {
|
|
1752
3195
|
try {
|
|
1753
3196
|
await access(path);
|
|
@@ -1847,7 +3290,8 @@ Docs: https://docs.amba.dev
|
|
|
1847
3290
|
}
|
|
1848
3291
|
async function initCommand(options = {}) {
|
|
1849
3292
|
const cwd = process.cwd();
|
|
1850
|
-
|
|
3293
|
+
const isNonTTY = process.stdin.isTTY !== true;
|
|
3294
|
+
if (options.sandbox === true || options.json === true || isNonTTY) {
|
|
1851
3295
|
const result = await runSandboxInit(cwd, {
|
|
1852
3296
|
sandboxEmail: options.sandboxEmail,
|
|
1853
3297
|
noMcpConfig: options.noMcpConfig,
|
|
@@ -1860,226 +3304,298 @@ async function initCommand(options = {}) {
|
|
|
1860
3304
|
return;
|
|
1861
3305
|
}
|
|
1862
3306
|
const environment = options.env ?? "development";
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
flagToken: void 0,
|
|
1871
|
-
envToken: process.env["AMBA_PAT"]
|
|
1872
|
-
}) !== null || process.argv.includes("--token") && process.argv.length > 2;
|
|
1873
|
-
let needsAuth = true;
|
|
1874
|
-
if (headlessActive) {
|
|
1875
|
-
console.log(pc.green(" ✓") + " Headless auth — using PAT from --token / AMBA_PAT");
|
|
1876
|
-
console.log(pc.dim(" (browser flow skipped; no credentials written to disk)"));
|
|
1877
|
-
console.log();
|
|
1878
|
-
needsAuth = false;
|
|
1879
|
-
} else try {
|
|
1880
|
-
if (!isTokenExpired(await loadCredentials())) {
|
|
1881
|
-
console.log(pc.green(" ✓") + " Already authenticated");
|
|
1882
|
-
console.log();
|
|
1883
|
-
needsAuth = false;
|
|
1884
|
-
}
|
|
1885
|
-
} catch {}
|
|
1886
|
-
if (needsAuth) {
|
|
1887
|
-
const creds = await browserAuthFlow();
|
|
1888
|
-
console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
|
|
1889
|
-
await storeCredentials(creds);
|
|
1890
|
-
console.log(pc.green(" ✓") + " Credentials saved to ~/.amba/credentials.json");
|
|
1891
|
-
console.log();
|
|
1892
|
-
} else {
|
|
1893
|
-
console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
|
|
1894
|
-
if (headlessActive) console.log(pc.green(" ✓") + " (skipped — PAT supplied)");
|
|
1895
|
-
else console.log(pc.green(" ✓") + " Using existing credentials");
|
|
1896
|
-
console.log();
|
|
1897
|
-
}
|
|
1898
|
-
console.log(pc.bold(" Step 3/7 ") + pc.dim("Select project"));
|
|
1899
|
-
console.log();
|
|
1900
|
-
let projectId;
|
|
1901
|
-
let projectName;
|
|
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");
|
|
1902
3314
|
try {
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
if (
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
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;
|
|
1924
3401
|
}
|
|
3402
|
+
} else {
|
|
3403
|
+
const name = await prompt(` Project name (default: ${defaultProjectName}): `);
|
|
3404
|
+
const finalName = name.length > 0 ? name : defaultProjectName;
|
|
1925
3405
|
projectId = (await createProject({
|
|
1926
|
-
name,
|
|
3406
|
+
name: finalName,
|
|
1927
3407
|
environment
|
|
1928
3408
|
})).data.id;
|
|
1929
|
-
projectName =
|
|
1930
|
-
console.log(pc.green(" ✓") + ` Created: ${projectName} ${pc.dim(`(${environment})`)}`);
|
|
3409
|
+
projectName = finalName;
|
|
1931
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;
|
|
1932
3421
|
} else {
|
|
1933
|
-
const
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
}
|
|
1938
|
-
projectId = (await createProject({ name })).data.id;
|
|
1939
|
-
projectName = name;
|
|
1940
|
-
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;
|
|
1941
3426
|
}
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
const
|
|
1945
|
-
|
|
1946
|
-
|
|
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."));
|
|
1947
3481
|
process.exit(1);
|
|
1948
3482
|
}
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
console.log();
|
|
1954
|
-
console.log(pc.bold(" Step 4/7 ") + pc.dim("Generate API keys"));
|
|
1955
|
-
const keyRes = await createApiKey(projectId, "client", "development");
|
|
1956
|
-
const apiKey = keyRes.data.key;
|
|
1957
|
-
console.log(pc.green(" ✓") + " Development client key created");
|
|
1958
|
-
console.log(pc.dim(` ${keyRes.data.key_prefix}...`));
|
|
1959
|
-
console.log();
|
|
1960
|
-
console.log(pc.bold(" Step 5/7 ") + pc.dim("Write environment file"));
|
|
1961
|
-
const envPath = join(cwd, ".env.local");
|
|
1962
|
-
const envLines = [
|
|
1963
|
-
"# Amba SDK Configuration",
|
|
1964
|
-
`AMBA_PROJECT_ID=${projectId}`,
|
|
1965
|
-
`AMBA_API_KEY=${apiKey}`,
|
|
1966
|
-
`AMBA_API_URL=https://api.amba.dev`,
|
|
1967
|
-
""
|
|
1968
|
-
];
|
|
1969
|
-
if (await fileExists(envPath)) {
|
|
1970
|
-
const existing = await readFile(envPath, "utf-8");
|
|
1971
|
-
if (existing.includes("AMBA_PROJECT_ID")) {
|
|
1972
|
-
console.log(pc.yellow(" !") + " .env.local already contains Amba config — updating");
|
|
1973
|
-
let updated = existing;
|
|
1974
|
-
updated = updated.replace(/AMBA_PROJECT_ID=.*/, `AMBA_PROJECT_ID=${projectId}`);
|
|
1975
|
-
updated = updated.replace(/AMBA_API_KEY=.*/, `AMBA_API_KEY=${apiKey}`);
|
|
1976
|
-
updated = updated.replace(/AMBA_API_URL=.*/, `AMBA_API_URL=https://api.amba.dev`);
|
|
1977
|
-
await writeFile(envPath, updated, "utf-8");
|
|
1978
|
-
} else await writeFile(envPath, existing + (existing.endsWith("\n") ? "\n" : "\n\n") + envLines.join("\n"), "utf-8");
|
|
1979
|
-
} else await writeFile(envPath, envLines.join("\n"), "utf-8");
|
|
1980
|
-
console.log(pc.green(" ✓") + " .env.local written");
|
|
1981
|
-
console.log();
|
|
1982
|
-
console.log(pc.bold(" Step 6/7 ") + pc.dim("Detect framework"));
|
|
1983
|
-
const framework = await detectFramework(cwd);
|
|
1984
|
-
const sdkPkg = getSdkPackage(framework);
|
|
1985
|
-
if (framework !== "unknown") console.log(pc.green(" ✓") + ` Detected: ${pc.bold(framework)}`);
|
|
1986
|
-
else console.log(pc.yellow(" !") + " Could not detect framework");
|
|
1987
|
-
let installCmd = `npm install ${sdkPkg}`;
|
|
1988
|
-
if (await fileExists(join(cwd, "bun.lockb"))) installCmd = `bun add ${sdkPkg}`;
|
|
1989
|
-
else if (await fileExists(join(cwd, "pnpm-lock.yaml"))) installCmd = `pnpm add ${sdkPkg}`;
|
|
1990
|
-
else if (await fileExists(join(cwd, "yarn.lock"))) installCmd = `yarn add ${sdkPkg}`;
|
|
1991
|
-
console.log(pc.dim(` Install SDK: ${installCmd}`));
|
|
1992
|
-
console.log();
|
|
1993
|
-
console.log(pc.bold(" Step 7/7 ") + pc.dim("Generate context files"));
|
|
1994
|
-
const generatedFiles = await generateContextFiles({
|
|
1995
|
-
projectId,
|
|
1996
|
-
projectName,
|
|
1997
|
-
apiKey,
|
|
1998
|
-
framework,
|
|
1999
|
-
cwd
|
|
2000
|
-
});
|
|
2001
|
-
for (const file of generatedFiles) console.log(pc.green(" ✓") + ` ${file}`);
|
|
2002
|
-
if (options.withExample) {
|
|
2003
|
-
const exampleFiles = await writeExampleScaffold(cwd, framework, projectName);
|
|
2004
|
-
if (exampleFiles.length > 0) for (const file of exampleFiles) console.log(pc.green(" ✓") + ` ${file} ` + pc.dim("(example)"));
|
|
2005
|
-
else console.log(pc.dim(" -") + " example files already present — skipping");
|
|
2006
|
-
}
|
|
2007
|
-
console.log();
|
|
2008
|
-
console.log(pc.dim(" ─────────────────────────────────"));
|
|
2009
|
-
console.log();
|
|
2010
|
-
console.log(pc.bold(pc.green(" ✓ Project initialized!")));
|
|
2011
|
-
console.log();
|
|
2012
|
-
console.log(" Quick start:");
|
|
2013
|
-
console.log();
|
|
2014
|
-
console.log(pc.dim(" 1.") + ` Install the SDK`);
|
|
2015
|
-
console.log(` ${pc.cyan(installCmd)}`);
|
|
2016
|
-
console.log();
|
|
2017
|
-
console.log(pc.dim(" 2.") + ` Add the provider to your app`);
|
|
2018
|
-
if (framework === "expo") console.log(pc.dim(` See AMBA.md for Amba.init() setup`));
|
|
2019
|
-
else if (framework === "react-native") console.log(pc.dim(` See AMBA.md for client initialization`));
|
|
2020
|
-
else console.log(pc.dim(` See AMBA.md for client initialization`));
|
|
2021
|
-
console.log();
|
|
2022
|
-
console.log(pc.dim(" 3.") + ` Test the integration`);
|
|
2023
|
-
console.log(` ${pc.cyan("amba status")}`);
|
|
2024
|
-
console.log();
|
|
2025
|
-
console.log(pc.dim(" 4.") + ` Send a test notification`);
|
|
2026
|
-
console.log(` ${pc.cyan("amba push test")}`);
|
|
2027
|
-
console.log();
|
|
2028
|
-
console.log(` Docs: ${pc.underline("https://docs.amba.dev")}`);
|
|
2029
|
-
console.log();
|
|
2030
|
-
}
|
|
2031
|
-
async function runSandboxInit(cwd, options) {
|
|
2032
|
-
if (!options.json) {
|
|
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);
|
|
2033
3487
|
console.log();
|
|
2034
|
-
console.log(pc.
|
|
2035
|
-
console.log(pc.dim("
|
|
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);
|
|
2036
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();
|
|
2037
3508
|
}
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
|
2041
3533
|
});
|
|
2042
|
-
|
|
3534
|
+
setBearerOverride(identity.credentials.pat);
|
|
2043
3535
|
const framework = await detectFramework(cwd);
|
|
2044
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);
|
|
2045
3543
|
const ambaMdPath = await writeSandboxAmbaMd(cwd, {
|
|
2046
|
-
projectId:
|
|
2047
|
-
email:
|
|
2048
|
-
verifyUrl:
|
|
3544
|
+
projectId: project.credentials.project_id,
|
|
3545
|
+
email: identity.credentials.email,
|
|
3546
|
+
verifyUrl: identity.firstProject?.verify_url ?? null,
|
|
2049
3547
|
sdkPackage,
|
|
2050
3548
|
framework,
|
|
2051
|
-
apiUrl:
|
|
3549
|
+
apiUrl: project.credentials.api_url
|
|
2052
3550
|
});
|
|
2053
|
-
const warn = options.json ? (msg) => process.stderr.write(msg + "\n") : (msg) => console.warn(msg);
|
|
2054
3551
|
let mcpConfigsWritten = [];
|
|
2055
|
-
if (!options.noMcpConfig) mcpConfigsWritten = await writeAllMcpConfigs(cwd,
|
|
3552
|
+
if (!options.noMcpConfig) mcpConfigsWritten = await writeAllMcpConfigs(cwd, identity.credentials.pat, {
|
|
2056
3553
|
homeDir: options.homeDir,
|
|
2057
3554
|
warn
|
|
2058
3555
|
});
|
|
2059
|
-
const credentialsResult = await writeSandboxCredentials(signup.pat, { homeDir: options.homeDir });
|
|
2060
3556
|
let skillPath = null;
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
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
|
+
}));
|
|
2065
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.");
|
|
2066
3581
|
return {
|
|
2067
|
-
email:
|
|
2068
|
-
projectId:
|
|
2069
|
-
pat:
|
|
2070
|
-
patPreview: `${
|
|
2071
|
-
clientKey:
|
|
2072
|
-
apiUrl:
|
|
2073
|
-
credentialsPath:
|
|
2074
|
-
credentialsBackedUpTo:
|
|
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,
|
|
2075
3590
|
envLocalPath,
|
|
2076
3591
|
ambaMdPath,
|
|
2077
3592
|
mcpConfigsWritten,
|
|
2078
3593
|
sdkPackage,
|
|
2079
3594
|
framework,
|
|
2080
|
-
verifyUrl:
|
|
2081
|
-
provisioningStatus:
|
|
2082
|
-
skillPath
|
|
3595
|
+
verifyUrl: identity.firstProject?.verify_url ?? null,
|
|
3596
|
+
provisioningStatus: identity.firstProject?.provisioning_status ?? "active",
|
|
3597
|
+
skillPath,
|
|
3598
|
+
setupTargets
|
|
2083
3599
|
};
|
|
2084
3600
|
}
|
|
2085
3601
|
function sandboxResultToJson(r) {
|
|
@@ -2102,83 +3618,83 @@ function sandboxResultToJson(r) {
|
|
|
2102
3618
|
backed_up_to: m.backedUpTo
|
|
2103
3619
|
})),
|
|
2104
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
|
+
})),
|
|
2105
3626
|
verify_url: r.verifyUrl,
|
|
2106
3627
|
provisioning_status: r.provisioningStatus,
|
|
2107
|
-
next_steps: ["
|
|
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
|
+
}
|
|
2108
3634
|
};
|
|
2109
3635
|
}
|
|
2110
3636
|
/**
|
|
2111
3637
|
* Build the plaintext (no ANSI) success-output block printed at the
|
|
2112
|
-
* end of `amba init --sandbox`. Pure function — exported
|
|
2113
|
-
* vitest cases that assert on per-line content. The CLI wraps
|
|
2114
|
-
*
|
|
2115
|
-
*
|
|
2116
|
-
*
|
|
2117
|
-
*
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2120
|
-
*
|
|
2121
|
-
*
|
|
2122
|
-
*
|
|
2123
|
-
*
|
|
2124
|
-
*
|
|
2125
|
-
*
|
|
2126
|
-
*
|
|
2127
|
-
*
|
|
2128
|
-
*
|
|
2129
|
-
*
|
|
2130
|
-
*
|
|
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.
|
|
2131
3661
|
*/
|
|
2132
3662
|
function buildSandboxNextStepsLines(r) {
|
|
2133
3663
|
const lines = [];
|
|
2134
|
-
lines.push(`✓
|
|
2135
|
-
lines.push(
|
|
2136
|
-
lines.push(
|
|
2137
|
-
if (r.
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
if (r.
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
|
|
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) {
|
|
2148
3679
|
lines.push("");
|
|
3680
|
+
lines.push(` Paste into your MCP client config:`);
|
|
2149
3681
|
for (const snippetLine of formatManualMcpSnippet(r.pat).split("\n")) lines.push(` ${snippetLine}`);
|
|
2150
|
-
lines.push("");
|
|
2151
3682
|
}
|
|
2152
|
-
lines.push("");
|
|
2153
|
-
lines.push("Next:");
|
|
2154
|
-
lines.push(` → npm install ${r.sdkPackage}`);
|
|
2155
|
-
lines.push(` → Initialize the SDK in your app entry: Amba.configure({ projectId: process.env.AMBA_PROJECT_ID, clientKey: process.env.AMBA_CLIENT_KEY })`);
|
|
2156
|
-
lines.push("");
|
|
2157
|
-
lines.push("Done. Restart your MCP client so it loads the Amba MCP server:");
|
|
2158
|
-
if (clientKinds.length === 0) lines.push(" • Quit your MCP client (Cmd+Q on macOS) and reopen it.");
|
|
2159
|
-
else for (const kind of clientKinds) lines.push(` • ${restartHintForClient(kind)}`);
|
|
2160
|
-
lines.push("");
|
|
2161
|
-
lines.push("After the restart, re-ask the original question — Amba's MCP toolset will be available.");
|
|
2162
|
-
if (r.skillPath) lines.push("Tip: paste `/amba-build <design-url>` to scaffold a full Expo app with Amba as the backend.");
|
|
2163
|
-
lines.push("");
|
|
2164
|
-
lines.push(`Sandbox limits: 100 MAU, 10 MB DB. Verify ${r.email} in the console to upgrade to Free.`);
|
|
2165
|
-
if (r.verifyUrl) lines.push(`Verify URL: ${r.verifyUrl}`);
|
|
2166
3683
|
return lines;
|
|
2167
3684
|
}
|
|
2168
|
-
function restartHintForClient(kind) {
|
|
2169
|
-
switch (kind) {
|
|
2170
|
-
case "claude-code": return "Claude Code: Cmd+Q, then reopen";
|
|
2171
|
-
case "cursor": return "Cursor: Cmd+Q, then reopen";
|
|
2172
|
-
case "windsurf": return "Windsurf: quit + relaunch";
|
|
2173
|
-
}
|
|
2174
|
-
}
|
|
2175
3685
|
function printSandboxNextSteps(r) {
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
else if (line.startsWith("
|
|
2180
|
-
else if (line.startsWith("
|
|
2181
|
-
|
|
3686
|
+
const lines = buildSandboxNextStepsLines(r);
|
|
3687
|
+
console.log();
|
|
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);
|
|
2182
3698
|
console.log();
|
|
2183
3699
|
}
|
|
2184
3700
|
//#endregion
|
|
@@ -2551,7 +4067,7 @@ function confirm(question) {
|
|
|
2551
4067
|
});
|
|
2552
4068
|
});
|
|
2553
4069
|
}
|
|
2554
|
-
function handleError(err) {
|
|
4070
|
+
function handleError$1(err) {
|
|
2555
4071
|
if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
|
|
2556
4072
|
else console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
2557
4073
|
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
@@ -2601,7 +4117,7 @@ async function projectsListCommand() {
|
|
|
2601
4117
|
console.log(pc.dim(` ${projects.length} project${projects.length === 1 ? "" : "s"}`));
|
|
2602
4118
|
console.log();
|
|
2603
4119
|
} catch (err) {
|
|
2604
|
-
handleError(err);
|
|
4120
|
+
handleError$1(err);
|
|
2605
4121
|
}
|
|
2606
4122
|
}
|
|
2607
4123
|
async function projectsCreateCommand(input) {
|
|
@@ -2627,6 +4143,7 @@ async function projectsCreateCommand(input) {
|
|
|
2627
4143
|
const res = await createProject({
|
|
2628
4144
|
name: input.name,
|
|
2629
4145
|
bundle_id: input.bundleId,
|
|
4146
|
+
google_oauth_client_id: input.googleOauthClientId,
|
|
2630
4147
|
platform: input.platform,
|
|
2631
4148
|
environment
|
|
2632
4149
|
});
|
|
@@ -2637,7 +4154,7 @@ async function projectsCreateCommand(input) {
|
|
|
2637
4154
|
try {
|
|
2638
4155
|
const s = (await getProvisioningStatus(id)).data;
|
|
2639
4156
|
console.log(pc.dim(` Status: ${s.status}`));
|
|
2640
|
-
if (s.
|
|
4157
|
+
if (s.region) console.log(pc.dim(` Region: ${s.region}`));
|
|
2641
4158
|
} catch {
|
|
2642
4159
|
console.log(pc.dim(" (Provisioning runs asynchronously.)"));
|
|
2643
4160
|
}
|
|
@@ -2646,7 +4163,33 @@ async function projectsCreateCommand(input) {
|
|
|
2646
4163
|
console.log(pc.dim(" Next: ") + pc.cyan(`amba projects show ${id}`));
|
|
2647
4164
|
console.log();
|
|
2648
4165
|
} catch (err) {
|
|
2649
|
-
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);
|
|
2650
4193
|
}
|
|
2651
4194
|
}
|
|
2652
4195
|
async function projectsShowCommand(projectId) {
|
|
@@ -2664,7 +4207,7 @@ async function projectsShowCommand(projectId) {
|
|
|
2664
4207
|
console.log();
|
|
2665
4208
|
process.exit(1);
|
|
2666
4209
|
}
|
|
2667
|
-
handleError(err);
|
|
4210
|
+
handleError$1(err);
|
|
2668
4211
|
}
|
|
2669
4212
|
}
|
|
2670
4213
|
async function projectsDeleteCommand(projectId, opts = {}) {
|
|
@@ -2689,7 +4232,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
|
|
|
2689
4232
|
console.log();
|
|
2690
4233
|
process.exit(1);
|
|
2691
4234
|
}
|
|
2692
|
-
handleError(err);
|
|
4235
|
+
handleError$1(err);
|
|
2693
4236
|
}
|
|
2694
4237
|
}
|
|
2695
4238
|
//#endregion
|
|
@@ -2993,14 +4536,14 @@ async function dbMigrateCommand(opts = {}) {
|
|
|
2993
4536
|
console.log(pc.dim(" Running tenant migrations..."));
|
|
2994
4537
|
console.log();
|
|
2995
4538
|
try {
|
|
2996
|
-
const
|
|
2997
|
-
console.log(pc.green(" ✓") + " Reprovision
|
|
2998
|
-
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}`));
|
|
2999
4542
|
console.log();
|
|
3000
4543
|
try {
|
|
3001
4544
|
const status = await getProvisioningStatus(projectId);
|
|
3002
4545
|
console.log(pc.dim(` Status: ${status.data.status}`));
|
|
3003
|
-
if (status.data.
|
|
4546
|
+
if (status.data.region) console.log(pc.dim(` Region: ${status.data.region}`));
|
|
3004
4547
|
} catch {}
|
|
3005
4548
|
console.log();
|
|
3006
4549
|
console.log(pc.dim(" Check again with: ") + pc.cyan(`amba projects show ${projectId}`));
|
|
@@ -3410,36 +4953,44 @@ function parseEnv(content) {
|
|
|
3410
4953
|
/**
|
|
3411
4954
|
* Customer-function bundling for `amba functions deploy`.
|
|
3412
4955
|
*
|
|
3413
|
-
* Uses esbuild
|
|
3414
|
-
*
|
|
3415
|
-
*
|
|
3416
|
-
* 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.
|
|
3417
4959
|
*
|
|
3418
4960
|
* Two checks gate the bundle before upload:
|
|
3419
4961
|
* 1. Pre-upload size check against `BUNDLE_MAX_SIZE_BYTES` (8 MB
|
|
3420
4962
|
* default — the platform's 10 MB compressed cap minus 2 MB
|
|
3421
4963
|
* headroom) with a clear error pointing at the externalization
|
|
3422
4964
|
* config.
|
|
3423
|
-
* 2. Bundle-shape report — the CLI prints what's
|
|
3424
|
-
*
|
|
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.
|
|
3425
4976
|
*/
|
|
3426
4977
|
/**
|
|
3427
|
-
* Modules
|
|
3428
|
-
*
|
|
3429
|
-
*
|
|
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.
|
|
3430
4987
|
*/
|
|
3431
|
-
const RUNTIME_STDLIB_EXTERNALS = [
|
|
3432
|
-
"@layers/amba-functions",
|
|
3433
|
-
"@layers/amba-api-middleware",
|
|
3434
|
-
"@anthropic-ai/sdk",
|
|
3435
|
-
"postgres",
|
|
3436
|
-
"zod"
|
|
3437
|
-
];
|
|
4988
|
+
const RUNTIME_STDLIB_EXTERNALS = [];
|
|
3438
4989
|
var BundleSizeError = class extends Error {
|
|
3439
4990
|
sizeBytes;
|
|
3440
4991
|
maxBytes;
|
|
3441
4992
|
constructor(sizeBytes, maxBytes) {
|
|
3442
|
-
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.)`);
|
|
3443
4994
|
this.sizeBytes = sizeBytes;
|
|
3444
4995
|
this.maxBytes = maxBytes;
|
|
3445
4996
|
this.name = "BundleSizeError";
|
|
@@ -3496,8 +5047,10 @@ function printBundleReport(bundle) {
|
|
|
3496
5047
|
console.log(pc.dim(" Bundle:"));
|
|
3497
5048
|
console.log(pc.dim(" size: ") + `${formatBytes$1(bundle.compressedSize)} compressed ` + pc.dim(`(${formatBytes$1(bundle.uncompressedSize)} raw)`));
|
|
3498
5049
|
console.log(pc.dim(" sha: ") + bundle.sha256.slice(0, 16) + pc.dim("…"));
|
|
3499
|
-
|
|
3500
|
-
|
|
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
|
+
}
|
|
3501
5054
|
console.log();
|
|
3502
5055
|
}
|
|
3503
5056
|
async function gzipSizeOf(bytes) {
|
|
@@ -4148,7 +5701,6 @@ async function aiProvidersAddCommand(provider, options) {
|
|
|
4148
5701
|
});
|
|
4149
5702
|
console.log(pc.green(" ✓") + ` Registered ${provider}`);
|
|
4150
5703
|
if (res.data.api_key_preview) console.log(pc.dim(` key preview: ${res.data.api_key_preview}`));
|
|
4151
|
-
console.log(pc.dim(` secret_name: ${res.data.api_key_secret_name ?? "(unset)"}`));
|
|
4152
5704
|
console.log();
|
|
4153
5705
|
}
|
|
4154
5706
|
async function aiProvidersListCommand() {
|
|
@@ -4160,7 +5712,7 @@ async function aiProvidersListCommand() {
|
|
|
4160
5712
|
console.log();
|
|
4161
5713
|
return;
|
|
4162
5714
|
}
|
|
4163
|
-
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}`) : ""));
|
|
4164
5716
|
console.log();
|
|
4165
5717
|
}
|
|
4166
5718
|
async function aiProvidersDeleteCommand(provider) {
|
|
@@ -4256,6 +5808,168 @@ function renderStatus(status) {
|
|
|
4256
5808
|
}
|
|
4257
5809
|
}
|
|
4258
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
|
|
4259
5973
|
//#region src/commands/collections.ts
|
|
4260
5974
|
/**
|
|
4261
5975
|
* `amba collections ...` — thin shells over the admin collection routes.
|
|
@@ -4982,6 +6696,9 @@ program.command("init").description("Initialize Amba in the current project (min
|
|
|
4982
6696
|
json: opts.json
|
|
4983
6697
|
}));
|
|
4984
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
|
+
});
|
|
4985
6702
|
program.command("login").description("Authenticate with Amba").action(async () => {
|
|
4986
6703
|
await runAction(loginCommand);
|
|
4987
6704
|
});
|
|
@@ -5010,14 +6727,24 @@ const projects = program.command("projects").description("Project management com
|
|
|
5010
6727
|
projects.command("list").description("List all projects in the authenticated developer account").action(async () => {
|
|
5011
6728
|
await runAction(projectsListCommand);
|
|
5012
6729
|
});
|
|
5013
|
-
projects.command("create").description("Create a new project").requiredOption("--name <name>", "Project name").option("--env <env>", "Environment hint (informational; new projects default to the 'development' environment)").option("--bundle-id <id>", "Bundle identifier (iOS/Android)").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").action(async (opts) => {
|
|
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) => {
|
|
5014
6731
|
await runAction(() => projectsCreateCommand({
|
|
5015
6732
|
name: opts.name,
|
|
5016
6733
|
env: opts.env,
|
|
5017
6734
|
bundleId: opts.bundleId,
|
|
6735
|
+
googleOauthClientId: opts.googleOauthClientId,
|
|
5018
6736
|
platform: opts.platform
|
|
5019
6737
|
}));
|
|
5020
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
|
+
});
|
|
5021
6748
|
projects.command("show <projectId>").description("Show full project details as JSON").action(async (projectId) => {
|
|
5022
6749
|
await runAction(() => projectsShowCommand(projectId));
|
|
5023
6750
|
});
|
|
@@ -5113,6 +6840,40 @@ secrets.command("list").description("List secret sync status for the current pro
|
|
|
5113
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) => {
|
|
5114
6841
|
await runAction(() => secretsUnsetCommand(name, opts));
|
|
5115
6842
|
});
|
|
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
|
+
});
|
|
5116
6877
|
const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
|
|
5117
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) => {
|
|
5118
6879
|
await runAction(() => collectionsCreateCommand(name, opts));
|