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