@layers/amba 0.1.1
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/LICENSE +190 -0
- package/README.md +56 -0
- package/dist/_internal/codegen.d.ts +29 -0
- package/dist/_internal/shared.d.ts +42 -0
- package/dist/api-client.d.ts +671 -0
- package/dist/auth.d.ts +69 -0
- package/dist/bundle.d.ts +69 -0
- package/dist/commands/ai.d.ts +35 -0
- package/dist/commands/analytics.d.ts +14 -0
- package/dist/commands/collections.d.ts +48 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/db.d.ts +4 -0
- package/dist/commands/functions-logs.d.ts +30 -0
- package/dist/commands/functions.d.ts +79 -0
- package/dist/commands/init.d.ts +12 -0
- package/dist/commands/login.d.ts +1 -0
- package/dist/commands/logs.d.ts +16 -0
- package/dist/commands/projects.d.ts +11 -0
- package/dist/commands/push.d.ts +1 -0
- package/dist/commands/schema.d.ts +7 -0
- package/dist/commands/secrets.d.ts +29 -0
- package/dist/commands/seed.d.ts +6 -0
- package/dist/commands/sites.d.ts +84 -0
- package/dist/commands/status.d.ts +4 -0
- package/dist/commands/types.d.ts +14 -0
- package/dist/context-files.d.ts +12 -0
- package/dist/env.d.ts +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3716 -0
- package/dist/project-config.d.ts +16 -0
- package/package.json +57 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3716 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { access, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import open from "open";
|
|
10
|
+
import { createWriteStream } from "node:fs";
|
|
11
|
+
import { build } from "esbuild";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
//#region src/_internal/shared.ts
|
|
14
|
+
const DEFAULT_API_URL = "https://api.amba.dev";
|
|
15
|
+
const CONSOLE_URL = "https://app.amba.dev";
|
|
16
|
+
const RESERVED_COLLECTION_PREFIXES = [
|
|
17
|
+
"_amba_",
|
|
18
|
+
"pg_",
|
|
19
|
+
"coll_amba_",
|
|
20
|
+
"events_",
|
|
21
|
+
"media_",
|
|
22
|
+
"streak_",
|
|
23
|
+
"xp_",
|
|
24
|
+
"achievement_",
|
|
25
|
+
"challenge_",
|
|
26
|
+
"currency_",
|
|
27
|
+
"feed_",
|
|
28
|
+
"friend_",
|
|
29
|
+
"group_",
|
|
30
|
+
"inventory_",
|
|
31
|
+
"leaderboard_",
|
|
32
|
+
"league_",
|
|
33
|
+
"messaging_",
|
|
34
|
+
"moderation_",
|
|
35
|
+
"onboarding_",
|
|
36
|
+
"referral_",
|
|
37
|
+
"review_",
|
|
38
|
+
"role_",
|
|
39
|
+
"session_",
|
|
40
|
+
"store_"
|
|
41
|
+
];
|
|
42
|
+
const RESERVED_COLLECTION_EXACT_NAMES = [
|
|
43
|
+
"app_users",
|
|
44
|
+
"magic_link_tokens",
|
|
45
|
+
"remote_config",
|
|
46
|
+
"remote_configs",
|
|
47
|
+
"engagement_events",
|
|
48
|
+
"schema_migrations",
|
|
49
|
+
"segment_memberships",
|
|
50
|
+
"segments",
|
|
51
|
+
"config_versions",
|
|
52
|
+
"push_tokens",
|
|
53
|
+
"push_campaigns",
|
|
54
|
+
"push_deliveries",
|
|
55
|
+
"user_streaks",
|
|
56
|
+
"streak_definitions",
|
|
57
|
+
"streak_events",
|
|
58
|
+
"user_entitlements",
|
|
59
|
+
"app_user_sessions",
|
|
60
|
+
"content_items",
|
|
61
|
+
"content_libraries",
|
|
62
|
+
"content_schedules"
|
|
63
|
+
];
|
|
64
|
+
const VALID_COLLECTION_NAME_RE = /^[a-z][a-z0-9_]*$/;
|
|
65
|
+
const MAX_COLLECTION_NAME_LENGTH = 50;
|
|
66
|
+
/** Return why a collection name is reserved/invalid, or `null` if acceptable. */
|
|
67
|
+
function getReservationReason(name) {
|
|
68
|
+
if (typeof name !== "string" || name.length === 0) return "Collection name must be a non-empty string";
|
|
69
|
+
if (name.length > MAX_COLLECTION_NAME_LENGTH) return `Collection name must be at most ${MAX_COLLECTION_NAME_LENGTH} characters`;
|
|
70
|
+
for (const prefix of RESERVED_COLLECTION_PREFIXES) if (name.startsWith(prefix)) return `Collection name starts with reserved prefix "${prefix}"`;
|
|
71
|
+
for (const exact of RESERVED_COLLECTION_EXACT_NAMES) if (name === exact) return `Collection name "${exact}" is reserved by an existing platform tenant table`;
|
|
72
|
+
if (!VALID_COLLECTION_NAME_RE.test(name)) return "Collection name must match /^[a-z][a-z0-9_]*$/ (lowercase ASCII, digits, underscore; must start with a letter)";
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const RESERVED_BINDING_PREFIXES = ["AMBA_", "EDGE_"];
|
|
76
|
+
const RESERVED_BINDING_EXACT_NAMES = [
|
|
77
|
+
"STORAGE",
|
|
78
|
+
"HYPERDRIVE",
|
|
79
|
+
"EDGE_DB_PROXY"
|
|
80
|
+
];
|
|
81
|
+
const VALID_BINDING_NAME_RE = /^[A-Z][A-Z0-9_]*$/;
|
|
82
|
+
const MAX_BINDING_NAME_LENGTH = 64;
|
|
83
|
+
/** Return why a binding name is reserved/invalid, or `null` if acceptable. */
|
|
84
|
+
function getBindingReservationReason(name) {
|
|
85
|
+
if (typeof name !== "string" || name.length === 0) return "Binding name must be a non-empty string";
|
|
86
|
+
if (name.length > MAX_BINDING_NAME_LENGTH) return `Binding name must be at most ${MAX_BINDING_NAME_LENGTH} characters`;
|
|
87
|
+
for (const prefix of RESERVED_BINDING_PREFIXES) if (name.startsWith(prefix)) return `Binding name starts with reserved prefix "${prefix}" (platform namespace)`;
|
|
88
|
+
for (const exact of RESERVED_BINDING_EXACT_NAMES) if (name === exact) return `Binding name "${exact}" is reserved by a platform binding`;
|
|
89
|
+
if (!VALID_BINDING_NAME_RE.test(name)) return "Binding name must match /^[A-Z][A-Z0-9_]*$/ (uppercase ASCII, digits, underscore; must start with a letter)";
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const RATE_LIMIT_MAX_CAP = 1e5;
|
|
93
|
+
const RATE_LIMIT_MIN_WINDOW_MS = 1e3;
|
|
94
|
+
const DURATION_RE = /^(\d+)(s|m|h)$/;
|
|
95
|
+
const VALID_KEY_KINDS = new Set(["user_id", "ip"]);
|
|
96
|
+
/** Convert `60s` / `5m` / `1h` → ms. `null` if unparseable. */
|
|
97
|
+
function parseDurationToMs(window) {
|
|
98
|
+
const match = DURATION_RE.exec(window);
|
|
99
|
+
if (!match) return null;
|
|
100
|
+
const n = Number.parseInt(match[1], 10);
|
|
101
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
102
|
+
switch (match[2]) {
|
|
103
|
+
case "s": return n * 1e3;
|
|
104
|
+
case "m": return n * 60 * 1e3;
|
|
105
|
+
case "h": return n * 60 * 60 * 1e3;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/** Validate shape + values. Returns the typed config OR `{ error }`. */
|
|
110
|
+
function validateRateLimitConfig(input) {
|
|
111
|
+
if (input === null || input === void 0) return { error: "rate_limit config is null or undefined" };
|
|
112
|
+
if (typeof input !== "object" || Array.isArray(input)) return { error: "rate_limit must be a JSON object" };
|
|
113
|
+
const obj = input;
|
|
114
|
+
if (typeof obj["window"] !== "string") return { error: "rate_limit.window must be a duration string (e.g. \"60s\", \"5m\", \"1h\")" };
|
|
115
|
+
const ms = parseDurationToMs(obj["window"]);
|
|
116
|
+
if (ms === null) return { error: `rate_limit.window "${obj["window"]}" is not a valid duration (expected /^\\d+(s|m|h)$/)` };
|
|
117
|
+
if (ms < 1e3) return { error: `rate_limit.window must be at least ${RATE_LIMIT_MIN_WINDOW_MS}ms` };
|
|
118
|
+
if (ms > 36e5) return { error: `rate_limit.window must be at most 1h` };
|
|
119
|
+
if (typeof obj["max"] !== "number" || !Number.isInteger(obj["max"]) || obj["max"] <= 0) return { error: "rate_limit.max must be a positive integer" };
|
|
120
|
+
if (obj["max"] > 1e5) return { error: `rate_limit.max must be at most ${RATE_LIMIT_MAX_CAP}` };
|
|
121
|
+
if (typeof obj["key"] !== "string" || !VALID_KEY_KINDS.has(obj["key"])) return { error: `rate_limit.key must be one of ${[...VALID_KEY_KINDS].join(" | ")}` };
|
|
122
|
+
return {
|
|
123
|
+
window: obj["window"],
|
|
124
|
+
max: obj["max"],
|
|
125
|
+
key: obj["key"]
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/auth.ts
|
|
130
|
+
const AMBA_DIR = join(homedir(), ".amba");
|
|
131
|
+
const CREDENTIALS_PATH = join(AMBA_DIR, "credentials.json");
|
|
132
|
+
/**
|
|
133
|
+
* Shape check for a PAT. The platform mints PATs as `amb_dpat_` plus 32
|
|
134
|
+
* characters from the base64url alphabet (`[A-Za-z0-9_-]`) per
|
|
135
|
+
* RFC 4648 §5.
|
|
136
|
+
*/
|
|
137
|
+
function isPatShape(token) {
|
|
138
|
+
return /^amb_dpat_[A-Za-z0-9_-]{32}$/.test(token);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Start a temporary local HTTP server, open the browser for OAuth,
|
|
142
|
+
* and wait for the redirect callback carrying the token.
|
|
143
|
+
*/
|
|
144
|
+
async function browserAuthFlow() {
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const server = createServer((req, res) => {
|
|
147
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
148
|
+
if (url.pathname !== "/callback") {
|
|
149
|
+
res.writeHead(404);
|
|
150
|
+
res.end("Not found");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const accessToken = url.searchParams.get("access_token");
|
|
154
|
+
const refreshToken = url.searchParams.get("refresh_token");
|
|
155
|
+
const expiresAt = url.searchParams.get("expires_at");
|
|
156
|
+
if (!accessToken || !refreshToken || !expiresAt) {
|
|
157
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
158
|
+
res.end("<html><body><h2>Authentication failed</h2><p>Missing token parameters. Please try again.</p></body></html>");
|
|
159
|
+
server.close();
|
|
160
|
+
reject(/* @__PURE__ */ new Error("Missing token parameters in callback"));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
164
|
+
res.end("<html><body style=\"font-family:system-ui;text-align:center;padding:3rem\"><h2>Authenticated!</h2><p>You can close this window and return to the terminal.</p></body></html>");
|
|
165
|
+
const creds = {
|
|
166
|
+
access_token: accessToken,
|
|
167
|
+
refresh_token: refreshToken,
|
|
168
|
+
expires_at: expiresAt
|
|
169
|
+
};
|
|
170
|
+
server.close();
|
|
171
|
+
resolve(creds);
|
|
172
|
+
});
|
|
173
|
+
server.listen(0, "127.0.0.1", () => {
|
|
174
|
+
const addr = server.address();
|
|
175
|
+
if (!addr || typeof addr === "string") {
|
|
176
|
+
server.close();
|
|
177
|
+
reject(/* @__PURE__ */ new Error("Failed to start local auth server"));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const authUrl = `${CONSOLE_URL}/cli-login?port=${addr.port}`;
|
|
181
|
+
console.log(pc.dim(` Opening browser to authenticate...`));
|
|
182
|
+
console.log(pc.dim(` ${authUrl}`));
|
|
183
|
+
console.log();
|
|
184
|
+
open(authUrl).catch(() => {
|
|
185
|
+
console.log(pc.yellow(` Could not open browser automatically.`));
|
|
186
|
+
console.log(pc.yellow(` Please open this URL manually:`));
|
|
187
|
+
console.log(` ${pc.underline(authUrl)}`);
|
|
188
|
+
});
|
|
189
|
+
setTimeout(() => {
|
|
190
|
+
server.close();
|
|
191
|
+
reject(/* @__PURE__ */ new Error("Authentication timed out. Please try again."));
|
|
192
|
+
}, 12e4);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Store credentials to ~/.amba/credentials.json
|
|
198
|
+
*/
|
|
199
|
+
async function storeCredentials(creds) {
|
|
200
|
+
await mkdir(AMBA_DIR, { recursive: true });
|
|
201
|
+
await writeFile(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), "utf-8");
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Load stored credentials. Throws if not found.
|
|
205
|
+
*/
|
|
206
|
+
async function loadCredentials() {
|
|
207
|
+
try {
|
|
208
|
+
const raw = await readFile(CREDENTIALS_PATH, "utf-8");
|
|
209
|
+
const parsed = JSON.parse(raw);
|
|
210
|
+
if (typeof parsed !== "object" || parsed === null || !("access_token" in parsed) || !("refresh_token" in parsed) || !("expires_at" in parsed)) throw new Error("Invalid credentials format");
|
|
211
|
+
const creds = parsed;
|
|
212
|
+
if (!creds.access_token || typeof creds.access_token !== "string") throw new Error("Missing or invalid access_token in credentials");
|
|
213
|
+
return creds;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") throw new Error(`Credentials not found. Run ${pc.bold("amba login")} to authenticate first, or pass ${pc.bold("--token <pat>")} / set ${pc.bold("AMBA_PAT")} for headless invocations.`);
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Check if the access token is expired (with 60s buffer).
|
|
221
|
+
*/
|
|
222
|
+
function isTokenExpired(creds) {
|
|
223
|
+
if (!creds.expires_at) return false;
|
|
224
|
+
const expiresAt = new Date(creds.expires_at).getTime();
|
|
225
|
+
return Date.now() > expiresAt - 6e4;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Remove stored credentials.
|
|
229
|
+
*/
|
|
230
|
+
async function clearCredentials() {
|
|
231
|
+
try {
|
|
232
|
+
await rm(CREDENTIALS_PATH, { force: true });
|
|
233
|
+
} catch {}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Process-scoped override for the bearer token used on admin API
|
|
237
|
+
* calls. Set by the commander.js `preAction` hook in `index.ts`
|
|
238
|
+
* before any subcommand runs — non-empty here means "skip stored
|
|
239
|
+
* creds, use this PAT/JWT verbatim." Cleared on test teardown via
|
|
240
|
+
* `__resetTokenOverride` to keep test cases isolated.
|
|
241
|
+
*/
|
|
242
|
+
let bearerOverride = null;
|
|
243
|
+
/**
|
|
244
|
+
* Set the process-scoped bearer override. Called by the global
|
|
245
|
+
* `preAction` hook with the resolved value (flag → env → null).
|
|
246
|
+
* `null` clears any prior override.
|
|
247
|
+
*/
|
|
248
|
+
function setBearerOverride(token) {
|
|
249
|
+
bearerOverride = token === null || token.length === 0 ? null : token;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Resolve the bearer token to send on the next admin API call.
|
|
253
|
+
*
|
|
254
|
+
* Returns the override (PAT or JWT supplied via flag/env) when set,
|
|
255
|
+
* otherwise loads + expiry-checks the stored access token. Throws
|
|
256
|
+
* with an actionable error message in either failure path.
|
|
257
|
+
*/
|
|
258
|
+
async function resolveBearerToken() {
|
|
259
|
+
if (bearerOverride !== null) {
|
|
260
|
+
if (isPatShape(bearerOverride)) return bearerOverride;
|
|
261
|
+
return bearerOverride;
|
|
262
|
+
}
|
|
263
|
+
const creds = await loadCredentials();
|
|
264
|
+
if (isTokenExpired(creds)) throw new Error(`Session expired. Run ${pc.bold("amba login")} to re-authenticate, or pass ${pc.bold("--token <pat>")} / set ${pc.bold("AMBA_PAT")} for headless invocations.`);
|
|
265
|
+
return creds.access_token;
|
|
266
|
+
}
|
|
267
|
+
function resolveTokenSource(input) {
|
|
268
|
+
const flag = input.flagToken?.trim();
|
|
269
|
+
if (flag) return flag;
|
|
270
|
+
const env = input.envToken?.trim();
|
|
271
|
+
if (env) return env;
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
//#endregion
|
|
275
|
+
//#region src/api-client.ts
|
|
276
|
+
/**
|
|
277
|
+
* Override the API base via the `AMBA_API_URL` environment variable.
|
|
278
|
+
*/
|
|
279
|
+
function getApiRoot() {
|
|
280
|
+
const override = process.env["AMBA_API_URL"];
|
|
281
|
+
return override && override.length > 0 ? override : DEFAULT_API_URL;
|
|
282
|
+
}
|
|
283
|
+
function getAdminBaseUrl() {
|
|
284
|
+
return `${getApiRoot()}/v1/admin`;
|
|
285
|
+
}
|
|
286
|
+
var ApiClientError = class extends Error {
|
|
287
|
+
statusCode;
|
|
288
|
+
errorCode;
|
|
289
|
+
constructor(message, statusCode, errorCode) {
|
|
290
|
+
super(message);
|
|
291
|
+
this.statusCode = statusCode;
|
|
292
|
+
this.errorCode = errorCode;
|
|
293
|
+
this.name = "ApiClientError";
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Make an authenticated request to the Amba admin API.
|
|
298
|
+
*
|
|
299
|
+
* The bearer token comes from `resolveBearerToken()` which honors the
|
|
300
|
+
* `--token <pat>` / `AMBA_PAT` headless-auth path before falling back
|
|
301
|
+
* to stored credentials at `~/.amba/credentials.json`. See
|
|
302
|
+
* `auth.ts:resolveBearerToken` for the full precedence rules.
|
|
303
|
+
*
|
|
304
|
+
* `body` is JSON-serialized when an object/array. Pass a `FormData`
|
|
305
|
+
* instance to send a multipart upload (e.g. `functions deploy` ships
|
|
306
|
+
* the bundled script + metadata blob as multipart) — fetch handles
|
|
307
|
+
* the boundary string + Content-Type itself in that case, so we
|
|
308
|
+
* deliberately do NOT set our default `application/json` header.
|
|
309
|
+
*/
|
|
310
|
+
async function request(method, path, body) {
|
|
311
|
+
const token = await resolveBearerToken();
|
|
312
|
+
const url = `${getAdminBaseUrl()}${path}`;
|
|
313
|
+
const isMultipart = typeof FormData !== "undefined" && body instanceof FormData;
|
|
314
|
+
const headers = {
|
|
315
|
+
Authorization: `Bearer ${token}`,
|
|
316
|
+
"User-Agent": "amba-cli/0.1.1"
|
|
317
|
+
};
|
|
318
|
+
if (!isMultipart) headers["Content-Type"] = "application/json";
|
|
319
|
+
let wireBody;
|
|
320
|
+
if (body === void 0) wireBody = void 0;
|
|
321
|
+
else if (isMultipart) wireBody = body;
|
|
322
|
+
else wireBody = JSON.stringify(body);
|
|
323
|
+
const res = await fetch(url, {
|
|
324
|
+
method,
|
|
325
|
+
headers,
|
|
326
|
+
body: wireBody
|
|
327
|
+
});
|
|
328
|
+
if (!res.ok) {
|
|
329
|
+
let errorMessage = `API request failed: ${res.status} ${res.statusText}`;
|
|
330
|
+
let errorCode;
|
|
331
|
+
try {
|
|
332
|
+
const errorBody = await res.json();
|
|
333
|
+
if (errorBody.error?.message) {
|
|
334
|
+
errorMessage = errorBody.error.message;
|
|
335
|
+
errorCode = errorBody.error.code;
|
|
336
|
+
}
|
|
337
|
+
} catch {}
|
|
338
|
+
throw new ApiClientError(errorMessage, res.status, errorCode);
|
|
339
|
+
}
|
|
340
|
+
return await res.json();
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Generic typed GET against the admin API. Wraps `request` so callers
|
|
344
|
+
* outside this module (e.g. the codegen-engine adapter in
|
|
345
|
+
* `commands/types.ts`) don't need to reach into the private helper.
|
|
346
|
+
*
|
|
347
|
+
* Path is relative to `/admin` — leading slash required (`/projects/X`).
|
|
348
|
+
*/
|
|
349
|
+
async function adminGet(path) {
|
|
350
|
+
return request("GET", path);
|
|
351
|
+
}
|
|
352
|
+
async function listProjects() {
|
|
353
|
+
return request("GET", "/projects");
|
|
354
|
+
}
|
|
355
|
+
async function createProject(input) {
|
|
356
|
+
return request("POST", "/projects", input);
|
|
357
|
+
}
|
|
358
|
+
async function getProject(projectId) {
|
|
359
|
+
return request("GET", `/projects/${projectId}`);
|
|
360
|
+
}
|
|
361
|
+
async function deleteProject(projectId) {
|
|
362
|
+
return request("DELETE", `/projects/${projectId}`);
|
|
363
|
+
}
|
|
364
|
+
async function reprovisionProject(projectId) {
|
|
365
|
+
return request("POST", `/projects/${projectId}/reprovision`);
|
|
366
|
+
}
|
|
367
|
+
async function getProvisioningStatus(projectId) {
|
|
368
|
+
return request("GET", `/projects/${projectId}/provisioning-status`);
|
|
369
|
+
}
|
|
370
|
+
async function createApiKey(projectId, keyType, environment) {
|
|
371
|
+
return request("POST", `/projects/${projectId}/api-keys`, {
|
|
372
|
+
key_type: keyType,
|
|
373
|
+
environment
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
async function sendTestPush(projectId, input) {
|
|
377
|
+
return request("POST", `/projects/${projectId}/push/test`, input);
|
|
378
|
+
}
|
|
379
|
+
async function listConfig(projectId) {
|
|
380
|
+
return request("GET", `/projects/${projectId}/config`);
|
|
381
|
+
}
|
|
382
|
+
async function setConfig(projectId, input) {
|
|
383
|
+
return request("PUT", `/projects/${projectId}/config/${input.key}`, input);
|
|
384
|
+
}
|
|
385
|
+
async function listIntegrations(projectId) {
|
|
386
|
+
return request("GET", `/projects/${projectId}/integrations`);
|
|
387
|
+
}
|
|
388
|
+
async function listUsers(projectId, query = {}) {
|
|
389
|
+
const params = new URLSearchParams();
|
|
390
|
+
if (query.limit !== void 0) params.set("limit", String(query.limit));
|
|
391
|
+
const qs = params.toString();
|
|
392
|
+
return request("GET", `/projects/${projectId}/users${qs ? `?${qs}` : ""}`);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Fetch a page of project-wide engagement events. Most-recent-first; when
|
|
396
|
+
* `next_cursor` is non-null the caller should pass it back as `cursor` to
|
|
397
|
+
* continue paging.
|
|
398
|
+
*/
|
|
399
|
+
async function listProjectEvents(projectId, query = {}) {
|
|
400
|
+
const params = new URLSearchParams();
|
|
401
|
+
if (query.since) params.set("since", query.since);
|
|
402
|
+
if (query.until) params.set("until", query.until);
|
|
403
|
+
if (query.eventName) params.set("event_name", query.eventName);
|
|
404
|
+
if (query.userId) params.set("user_id", query.userId);
|
|
405
|
+
if (query.limit !== void 0) params.set("limit", String(query.limit));
|
|
406
|
+
if (query.cursor) params.set("cursor", query.cursor);
|
|
407
|
+
const qs = params.toString();
|
|
408
|
+
return request("GET", `/projects/${projectId}/events${qs ? `?${qs}` : ""}`);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Aggregate event counts in a time range. `groupBy` of `event_name` returns
|
|
412
|
+
* the per-event-name bucket list ordered by count desc.
|
|
413
|
+
*/
|
|
414
|
+
async function getEventsCount(projectId, query) {
|
|
415
|
+
const params = new URLSearchParams();
|
|
416
|
+
params.set("since", query.since);
|
|
417
|
+
if (query.until) params.set("until", query.until);
|
|
418
|
+
if (query.eventName) params.set("event_name", query.eventName);
|
|
419
|
+
if (query.groupBy) params.set("group_by", query.groupBy);
|
|
420
|
+
return request("GET", `/projects/${projectId}/events/count?${params.toString()}`);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Stream `/users/export` as line-delimited text. Yields chunks as the
|
|
424
|
+
* server emits them. The endpoint always emits CSV (or NDJSON) so we don't
|
|
425
|
+
* try to parse — callers either tee to disk or split lines themselves.
|
|
426
|
+
*/
|
|
427
|
+
async function streamUsersExport(projectId, query = {}) {
|
|
428
|
+
const token = await resolveBearerToken();
|
|
429
|
+
const params = new URLSearchParams();
|
|
430
|
+
if (query.format) params.set("format", query.format);
|
|
431
|
+
if (query.since) params.set("since", query.since);
|
|
432
|
+
const qs = params.toString();
|
|
433
|
+
const url = `${getAdminBaseUrl()}/projects/${projectId}/users/export${qs ? `?${qs}` : ""}`;
|
|
434
|
+
const res = await fetch(url, {
|
|
435
|
+
method: "GET",
|
|
436
|
+
headers: {
|
|
437
|
+
Authorization: `Bearer ${token}`,
|
|
438
|
+
"User-Agent": "amba-cli/0.1.1"
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
if (!res.ok) {
|
|
442
|
+
let errorMessage = `API request failed: ${res.status} ${res.statusText}`;
|
|
443
|
+
let errorCode;
|
|
444
|
+
try {
|
|
445
|
+
const errorBody = await res.json();
|
|
446
|
+
if (errorBody.error?.message) {
|
|
447
|
+
errorMessage = errorBody.error.message;
|
|
448
|
+
errorCode = errorBody.error.code;
|
|
449
|
+
}
|
|
450
|
+
} catch {}
|
|
451
|
+
throw new ApiClientError(errorMessage, res.status, errorCode);
|
|
452
|
+
}
|
|
453
|
+
return res;
|
|
454
|
+
}
|
|
455
|
+
async function listSegments(projectId) {
|
|
456
|
+
return request("GET", `/projects/${projectId}/segments`);
|
|
457
|
+
}
|
|
458
|
+
async function createSegment(projectId, input) {
|
|
459
|
+
return request("POST", `/projects/${projectId}/segments`, input);
|
|
460
|
+
}
|
|
461
|
+
async function createAchievement(projectId, input) {
|
|
462
|
+
return request("POST", `/projects/${projectId}/achievements`, input);
|
|
463
|
+
}
|
|
464
|
+
async function createContentLibrary(projectId, input) {
|
|
465
|
+
return request("POST", `/projects/${projectId}/content/libraries`, input);
|
|
466
|
+
}
|
|
467
|
+
async function addContentItems(projectId, libraryId, input) {
|
|
468
|
+
return request("POST", `/projects/${projectId}/content/libraries/${libraryId}/bulk`, input);
|
|
469
|
+
}
|
|
470
|
+
async function createXpRule(projectId, input) {
|
|
471
|
+
return request("POST", `/projects/${projectId}/xp`, input);
|
|
472
|
+
}
|
|
473
|
+
async function deployFunctionViaApi(projectId, input) {
|
|
474
|
+
const fd = new FormData();
|
|
475
|
+
fd.set("script", new Blob([input.bundleCode], { type: "application/javascript+module" }), `${input.name}.js`);
|
|
476
|
+
const metadata = {
|
|
477
|
+
name: input.name,
|
|
478
|
+
...input.rate_limit !== void 0 ? { rate_limit: input.rate_limit } : {}
|
|
479
|
+
};
|
|
480
|
+
fd.set("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }), "metadata.json");
|
|
481
|
+
return request("POST", `/projects/${projectId}/functions/deploy`, fd);
|
|
482
|
+
}
|
|
483
|
+
async function listFunctionDeployments(projectId, options = {}) {
|
|
484
|
+
return request("GET", `/projects/${projectId}/functions/deployments${options.activeOnly ? "?active=1" : ""}`);
|
|
485
|
+
}
|
|
486
|
+
async function scheduleFunction(projectId, input) {
|
|
487
|
+
return request("POST", `/projects/${projectId}/functions/schedules`, input);
|
|
488
|
+
}
|
|
489
|
+
async function getFunctionLogs(projectId, functionName, options = {}) {
|
|
490
|
+
const params = new URLSearchParams();
|
|
491
|
+
if (options.since) params.set("since", options.since);
|
|
492
|
+
if (options.until) params.set("until", options.until);
|
|
493
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
494
|
+
const qs = params.toString();
|
|
495
|
+
return request("GET", `/projects/${projectId}/functions/${functionName}/logs${qs ? `?${qs}` : ""}`);
|
|
496
|
+
}
|
|
497
|
+
async function upsertQueueBinding(projectId, input) {
|
|
498
|
+
return request("PUT", `/projects/${projectId}/queue/bindings`, input);
|
|
499
|
+
}
|
|
500
|
+
async function listQueueBindings(projectId) {
|
|
501
|
+
return request("GET", `/projects/${projectId}/queue/bindings`);
|
|
502
|
+
}
|
|
503
|
+
async function deleteQueueBinding(projectId, queueName) {
|
|
504
|
+
return request("DELETE", `/projects/${projectId}/queue/bindings/${queueName}?confirm=${encodeURIComponent(queueName)}`);
|
|
505
|
+
}
|
|
506
|
+
async function setSecretViaApi(projectId, input) {
|
|
507
|
+
return request("POST", `/projects/${projectId}/secrets`, input);
|
|
508
|
+
}
|
|
509
|
+
async function listSecretsViaApi(projectId) {
|
|
510
|
+
return request("GET", `/projects/${projectId}/secrets`);
|
|
511
|
+
}
|
|
512
|
+
async function deleteSecretViaApi(projectId, name, options) {
|
|
513
|
+
const qs = new URLSearchParams({ function: options.function });
|
|
514
|
+
return request("DELETE", `/projects/${projectId}/secrets/${encodeURIComponent(name)}?${qs.toString()}`);
|
|
515
|
+
}
|
|
516
|
+
async function createCollection(projectId, input) {
|
|
517
|
+
return request("POST", `/projects/${projectId}/collections`, input);
|
|
518
|
+
}
|
|
519
|
+
async function listCollections$1(projectId, options = {}) {
|
|
520
|
+
const params = new URLSearchParams();
|
|
521
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
522
|
+
if (options.offset !== void 0) params.set("offset", String(options.offset));
|
|
523
|
+
const qs = params.toString();
|
|
524
|
+
return request("GET", `/projects/${projectId}/collections${qs ? `?${qs}` : ""}`);
|
|
525
|
+
}
|
|
526
|
+
async function alterCollection(projectId, name, patch, options = {}) {
|
|
527
|
+
return request("PATCH", `/projects/${projectId}/collections/${name}${options.confirm ? `?confirm=${encodeURIComponent(options.confirm)}` : ""}`, patch);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Drop a collection. Requires `confirm` matching the collection name —
|
|
531
|
+
* data's safety guard against typos. CLI surfaces this as `--confirm <name>`.
|
|
532
|
+
*/
|
|
533
|
+
async function dropCollection(projectId, name, confirm) {
|
|
534
|
+
return request("DELETE", `/projects/${projectId}/collections/${name}?confirm=${encodeURIComponent(confirm)}`);
|
|
535
|
+
}
|
|
536
|
+
async function registerAiProvider(projectId, input) {
|
|
537
|
+
return request("POST", `/projects/${projectId}/ai/providers`, input);
|
|
538
|
+
}
|
|
539
|
+
async function listAiProviders(projectId) {
|
|
540
|
+
return request("GET", `/projects/${projectId}/ai/providers`);
|
|
541
|
+
}
|
|
542
|
+
async function deleteAiProvider(projectId, name) {
|
|
543
|
+
return request("DELETE", `/projects/${projectId}/ai/providers/${encodeURIComponent(name)}`);
|
|
544
|
+
}
|
|
545
|
+
async function createSite(projectId, input) {
|
|
546
|
+
return request("POST", `/projects/${projectId}/sites`, input);
|
|
547
|
+
}
|
|
548
|
+
async function listSites(projectId) {
|
|
549
|
+
return request("GET", `/projects/${projectId}/sites`);
|
|
550
|
+
}
|
|
551
|
+
async function describeSite(projectId, name) {
|
|
552
|
+
return request("GET", `/projects/${projectId}/sites/${encodeURIComponent(name)}`);
|
|
553
|
+
}
|
|
554
|
+
async function deploySiteViaApi(projectId, siteName, body) {
|
|
555
|
+
return request("POST", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/deployments`, body);
|
|
556
|
+
}
|
|
557
|
+
async function updateSite(projectId, name, patch) {
|
|
558
|
+
return request("PATCH", `/projects/${projectId}/sites/${encodeURIComponent(name)}`, patch);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Add a custom domain to a site. The server-side proxy registers the
|
|
562
|
+
* custom hostname, persists the resulting `cf_hostname_id`, and returns
|
|
563
|
+
* the CNAME target the customer should point their DNS at.
|
|
564
|
+
*/
|
|
565
|
+
async function addSiteDomainViaApi(projectId, siteName, hostname) {
|
|
566
|
+
return request("POST", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/domains`, { hostname });
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Remove a custom domain from a site. Idempotent — re-running on an
|
|
570
|
+
* already-removed hostname returns 200 with `deleted: true`. Backend
|
|
571
|
+
* 404s are treated as success at the proxy layer.
|
|
572
|
+
*/
|
|
573
|
+
async function removeSiteDomainViaApi(projectId, siteName, hostname) {
|
|
574
|
+
return request("DELETE", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/domains/${encodeURIComponent(hostname)}`);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Roll a live CF Pages deployment back to a prior `deployment_id`. CF's
|
|
578
|
+
* rollback creates a NEW deployment that serves the prior bundle (git-
|
|
579
|
+
* revert semantics, not git-reset), so the response shape mirrors
|
|
580
|
+
* `DeploySiteResult` and the new `deployment_id` is what's now live.
|
|
581
|
+
*/
|
|
582
|
+
async function rollbackSiteViaApi(projectId, siteName, deploymentId) {
|
|
583
|
+
return request("POST", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/rollback`, { deployment_id: deploymentId });
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Delete a site entirely. Cascade: removes attached custom hostnames,
|
|
587
|
+
* tears down the CDN project, and soft-deletes the site row. Partial
|
|
588
|
+
* failures surface as `503 CASCADE_PARTIAL_FAILURE` with the `cascade`
|
|
589
|
+
* field telling the CLI which steps completed.
|
|
590
|
+
*/
|
|
591
|
+
async function deleteSiteViaApi(projectId, siteName, options) {
|
|
592
|
+
return request("DELETE", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}?confirm=${encodeURIComponent(options.confirm)}`);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Delete a function entirely. Cascade: removes the deployed script
|
|
596
|
+
* (backend 404 treated as success), then marks every historical
|
|
597
|
+
* deployment row as `status='disabled'` to keep history intact for
|
|
598
|
+
* audit. Customer Workers stop responding immediately on cascade
|
|
599
|
+
* step 1.
|
|
600
|
+
*/
|
|
601
|
+
async function deleteFunctionViaApi(projectId, functionName, options) {
|
|
602
|
+
return request("DELETE", `/projects/${projectId}/functions/${encodeURIComponent(functionName)}?confirm=${encodeURIComponent(options.confirm)}`);
|
|
603
|
+
}
|
|
604
|
+
async function listSiteDomains(projectId, siteName) {
|
|
605
|
+
return request("GET", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/domains`);
|
|
606
|
+
}
|
|
607
|
+
async function validateApiKey(apiKey) {
|
|
608
|
+
const res = await fetch(`${getApiRoot()}/v1/auth/validate`, {
|
|
609
|
+
method: "POST",
|
|
610
|
+
headers: {
|
|
611
|
+
"Content-Type": "application/json",
|
|
612
|
+
"User-Agent": "amba-cli/0.1.1"
|
|
613
|
+
},
|
|
614
|
+
body: JSON.stringify({ api_key: apiKey })
|
|
615
|
+
});
|
|
616
|
+
if (!res.ok) return {
|
|
617
|
+
valid: false,
|
|
618
|
+
error: `HTTP ${res.status}`
|
|
619
|
+
};
|
|
620
|
+
const { valid: _valid, ...rest } = (await res.json()).data;
|
|
621
|
+
return {
|
|
622
|
+
valid: true,
|
|
623
|
+
...rest
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region src/context-files.ts
|
|
628
|
+
/**
|
|
629
|
+
* Generate AMBA.md project context file for AI agents.
|
|
630
|
+
*/
|
|
631
|
+
function generateAmbaMarkdown(opts) {
|
|
632
|
+
const sdkPackage = opts.framework === "expo" ? "@layers/amba-expo" : "@layers/amba-client";
|
|
633
|
+
const providerExample = opts.framework === "expo" ? `
|
|
634
|
+
### Client Setup
|
|
635
|
+
|
|
636
|
+
\`\`\`tsx
|
|
637
|
+
// app/_layout.tsx
|
|
638
|
+
import { useEffect } from 'react';
|
|
639
|
+
import { Slot } from 'expo-router';
|
|
640
|
+
import { Amba } from '@layers/amba-expo';
|
|
641
|
+
|
|
642
|
+
export default function RootLayout() {
|
|
643
|
+
useEffect(() => {
|
|
644
|
+
Amba.init({
|
|
645
|
+
projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
|
|
646
|
+
apiKey: process.env.EXPO_PUBLIC_AMBA_API_KEY!,
|
|
647
|
+
});
|
|
648
|
+
}, []);
|
|
649
|
+
|
|
650
|
+
return <Slot />;
|
|
651
|
+
}
|
|
652
|
+
\`\`\`
|
|
653
|
+
|
|
654
|
+
### Using the Client
|
|
655
|
+
|
|
656
|
+
\`\`\`tsx
|
|
657
|
+
import { Amba } from '@layers/amba-expo';
|
|
658
|
+
|
|
659
|
+
export default function MyComponent() {
|
|
660
|
+
const onPress = async () => {
|
|
661
|
+
// Track an event
|
|
662
|
+
await Amba.track('lesson_completed', { lesson_id: '123' });
|
|
663
|
+
|
|
664
|
+
// Sign in with Apple (requires expo-apple-authentication)
|
|
665
|
+
await Amba.signInWithApple();
|
|
666
|
+
|
|
667
|
+
// Read remote config
|
|
668
|
+
const showBanner = Amba.configModule.get('show_promo_banner');
|
|
669
|
+
|
|
670
|
+
// Read current streaks
|
|
671
|
+
const streaks = await Amba.streaks.getAll();
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
// ...
|
|
675
|
+
}
|
|
676
|
+
\`\`\`` : `
|
|
677
|
+
### Client Setup
|
|
678
|
+
|
|
679
|
+
\`\`\`typescript
|
|
680
|
+
import { Amba } from '@layers/amba-client';
|
|
681
|
+
|
|
682
|
+
Amba.configure({
|
|
683
|
+
projectId: process.env.AMBA_PROJECT_ID!,
|
|
684
|
+
apiKey: process.env.AMBA_API_KEY!,
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
await Amba.client.init();
|
|
688
|
+
|
|
689
|
+
// Track an event
|
|
690
|
+
await Amba.client.track('page_viewed', { page: '/pricing' });
|
|
691
|
+
|
|
692
|
+
// Get remote config
|
|
693
|
+
const config = Amba.client.config.get('feature_flags');
|
|
694
|
+
|
|
695
|
+
// Auth
|
|
696
|
+
await Amba.client.auth.signUpWithEmail('user@example.com', 'hunter2');
|
|
697
|
+
\`\`\``;
|
|
698
|
+
return `# Amba Project Context
|
|
699
|
+
|
|
700
|
+
> This file provides context about the Amba integration for AI coding agents.
|
|
701
|
+
|
|
702
|
+
## Project Info
|
|
703
|
+
|
|
704
|
+
| Key | Value |
|
|
705
|
+
|-----|-------|
|
|
706
|
+
| Project ID | \`${opts.projectId}\` |
|
|
707
|
+
| Project Name | ${opts.projectName} |
|
|
708
|
+
| Framework | ${opts.framework} |
|
|
709
|
+
| SDK | \`${sdkPackage}\` |
|
|
710
|
+
|
|
711
|
+
## Environment Variables
|
|
712
|
+
|
|
713
|
+
These are configured in \`.env.local\`:
|
|
714
|
+
|
|
715
|
+
- \`AMBA_PROJECT_ID\` — Your project identifier
|
|
716
|
+
- \`AMBA_API_KEY\` — Client API key (safe for client-side use)
|
|
717
|
+
- \`AMBA_API_URL\` — API endpoint (defaults to https://api.amba.dev)
|
|
718
|
+
|
|
719
|
+
## SDK Usage
|
|
720
|
+
${providerExample}
|
|
721
|
+
|
|
722
|
+
## Available Features
|
|
723
|
+
|
|
724
|
+
- **Push Notifications** — Send targeted push notifications to user segments
|
|
725
|
+
- **Remote Config** — Key-value configuration that updates without app releases
|
|
726
|
+
- **Segments** — Group users by behavior, properties, or entitlements
|
|
727
|
+
- **Streaks** — Track user engagement streaks (daily, weekly)
|
|
728
|
+
- **Content Libraries** — Scheduled content delivery (daily tips, weekly challenges)
|
|
729
|
+
- **Entitlements** — Subscription status via RevenueCat integration
|
|
730
|
+
- **Analytics** — DAU, MAU, retention, and custom event tracking
|
|
731
|
+
|
|
732
|
+
## API Reference
|
|
733
|
+
|
|
734
|
+
- Admin API: \`https://api.amba.dev/v1/admin\`
|
|
735
|
+
- Client API: \`https://api.amba.dev/v1/client\`
|
|
736
|
+
- Docs: \`https://docs.amba.dev\`
|
|
737
|
+
|
|
738
|
+
## CLI Commands
|
|
739
|
+
|
|
740
|
+
\`\`\`bash
|
|
741
|
+
amba status # Check project health
|
|
742
|
+
amba push test # Send a test push notification
|
|
743
|
+
amba config list # List remote config values
|
|
744
|
+
amba config set <key> <value> # Set a config value
|
|
745
|
+
\`\`\`
|
|
746
|
+
`;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Generate .cursor/rules/amba.mdc Cursor rules file.
|
|
750
|
+
*/
|
|
751
|
+
function generateCursorRules(opts) {
|
|
752
|
+
const sdk = opts.framework === "expo" ? "@layers/amba-expo" : "@layers/amba-client";
|
|
753
|
+
return `---
|
|
754
|
+
description: Rules for working with the Amba SDK in this project
|
|
755
|
+
globs: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]
|
|
756
|
+
---
|
|
757
|
+
|
|
758
|
+
# Amba SDK Rules
|
|
759
|
+
|
|
760
|
+
## Project Setup
|
|
761
|
+
- Project ID: \`${opts.projectId}\`
|
|
762
|
+
- SDK: \`${sdk}\`
|
|
763
|
+
- API URL: \`https://api.amba.dev\`
|
|
764
|
+
|
|
765
|
+
## Environment Variables
|
|
766
|
+
- Always read Amba config from environment variables, never hardcode
|
|
767
|
+
- Use \`process.env.AMBA_PROJECT_ID\` and \`process.env.AMBA_API_KEY\`
|
|
768
|
+
- The .env.local file contains the project credentials
|
|
769
|
+
|
|
770
|
+
## SDK Patterns
|
|
771
|
+
${opts.framework === "expo" ? `- Import the \`Amba\` singleton from \`@layers/amba-expo\`
|
|
772
|
+
- Call \`Amba.init({ projectId, apiKey })\` once in the root layout (inside a \`useEffect\`)
|
|
773
|
+
- The Expo wrapper auto-wires AsyncStorage, push tokens, and Apple/Google sign-in
|
|
774
|
+
- Use \`Amba.signInWithApple()\` / \`Amba.signInWithGoogle()\` for social auth one-liners
|
|
775
|
+
- Call \`Amba.track()\` for engagement events, don't build custom analytics` : `- Initialize the Amba client once and export it as a singleton
|
|
776
|
+
- Use \`Amba.client.track()\` for all engagement events
|
|
777
|
+
- Use \`Amba.client.config.get()\` for remote configuration
|
|
778
|
+
- Use \`Amba.client.auth\` for sign-up / sign-in flows`}
|
|
779
|
+
|
|
780
|
+
## Push Notifications
|
|
781
|
+
- Register push tokens via the SDK \`registerPushToken()\` method
|
|
782
|
+
- Handle notification payloads using the SDK's notification listener
|
|
783
|
+
- Don't implement custom push token management
|
|
784
|
+
|
|
785
|
+
## Remote Config
|
|
786
|
+
- Use remote config for feature flags and dynamic values
|
|
787
|
+
- Always provide sensible defaults when reading config values
|
|
788
|
+
- Config values are cached — don't fetch on every render
|
|
789
|
+
|
|
790
|
+
## Streaks
|
|
791
|
+
- Streaks are server-managed; the SDK provides read-only access
|
|
792
|
+
- Use \`track()\` to record qualifying events — the server evaluates streaks
|
|
793
|
+
- Show streak state from \`streak.current()\`, don't calculate manually
|
|
794
|
+
|
|
795
|
+
## Best Practices
|
|
796
|
+
- Don't store Amba API keys in source code or commit them to git
|
|
797
|
+
- Use \`.env.local\` for local development credentials
|
|
798
|
+
- The client API key (prefixed \`amb_dev_ck_\` or \`amb_live_ck_\`) is safe for client-side use
|
|
799
|
+
- Server keys (prefixed \`amb_dev_sk_\` or \`amb_live_sk_\`) must stay server-side only
|
|
800
|
+
`;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Write both context files to the project directory.
|
|
804
|
+
*/
|
|
805
|
+
async function generateContextFiles(opts) {
|
|
806
|
+
const files = [];
|
|
807
|
+
await writeFile(join(opts.cwd, "AMBA.md"), generateAmbaMarkdown(opts), "utf-8");
|
|
808
|
+
files.push("AMBA.md");
|
|
809
|
+
const cursorDir = join(opts.cwd, ".cursor", "rules");
|
|
810
|
+
await mkdir(cursorDir, { recursive: true });
|
|
811
|
+
await writeFile(join(cursorDir, "amba.mdc"), generateCursorRules(opts), "utf-8");
|
|
812
|
+
files.push(".cursor/rules/amba.mdc");
|
|
813
|
+
return files;
|
|
814
|
+
}
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region src/commands/init.ts
|
|
817
|
+
function prompt(question) {
|
|
818
|
+
const rl = createInterface({
|
|
819
|
+
input: process.stdin,
|
|
820
|
+
output: process.stdout
|
|
821
|
+
});
|
|
822
|
+
return new Promise((resolve) => {
|
|
823
|
+
rl.question(question, (answer) => {
|
|
824
|
+
rl.close();
|
|
825
|
+
resolve(answer.trim());
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
async function fileExists(path) {
|
|
830
|
+
try {
|
|
831
|
+
await access(path);
|
|
832
|
+
return true;
|
|
833
|
+
} catch {
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
async function detectFramework(cwd) {
|
|
838
|
+
try {
|
|
839
|
+
const raw = await readFile(join(cwd, "package.json"), "utf-8");
|
|
840
|
+
const pkg = JSON.parse(raw);
|
|
841
|
+
const deps = {
|
|
842
|
+
...typeof pkg.dependencies === "object" && pkg.dependencies !== null ? pkg.dependencies : {},
|
|
843
|
+
...typeof pkg.devDependencies === "object" && pkg.devDependencies !== null ? pkg.devDependencies : {}
|
|
844
|
+
};
|
|
845
|
+
if ("expo" in deps) return "expo";
|
|
846
|
+
if ("react-native" in deps) return "react-native";
|
|
847
|
+
if ("react" in deps || "next" in deps || "vue" in deps || "svelte" in deps) return "web";
|
|
848
|
+
return "unknown";
|
|
849
|
+
} catch {
|
|
850
|
+
return "unknown";
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
function getSdkPackage(framework) {
|
|
854
|
+
switch (framework) {
|
|
855
|
+
case "expo": return "@layers/amba-expo";
|
|
856
|
+
case "react-native": return "@layers/amba-client";
|
|
857
|
+
case "web": return "@layers/amba-client";
|
|
858
|
+
case "unknown": return "@layers/amba-client";
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
async function writeExampleScaffold(cwd, framework, projectName) {
|
|
862
|
+
const written = [];
|
|
863
|
+
const appTsxPath = join(cwd, "amba-example.tsx");
|
|
864
|
+
if (!await fileExists(appTsxPath)) {
|
|
865
|
+
await writeFile(appTsxPath, framework === "expo" ? `/**
|
|
866
|
+
* Amba example — ${projectName}
|
|
867
|
+
*
|
|
868
|
+
* Drop this into your Expo app (e.g. app/_layout.tsx) to initialize Amba
|
|
869
|
+
* once at startup. Requires: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID
|
|
870
|
+
* process.env.EXPO_PUBLIC_AMBA_API_KEY
|
|
871
|
+
*/
|
|
872
|
+
import { useEffect } from 'react';
|
|
873
|
+
import { Amba } from '@layers/amba-expo';
|
|
874
|
+
|
|
875
|
+
export function AmbaExample(): null {
|
|
876
|
+
useEffect(() => {
|
|
877
|
+
void Amba.init({
|
|
878
|
+
projectId: process.env.EXPO_PUBLIC_AMBA_PROJECT_ID!,
|
|
879
|
+
apiKey: process.env.EXPO_PUBLIC_AMBA_API_KEY!,
|
|
880
|
+
});
|
|
881
|
+
}, []);
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
` : `/**
|
|
885
|
+
* Amba example — ${projectName}
|
|
886
|
+
*
|
|
887
|
+
* Call Amba.init() once at application startup (after env is loaded).
|
|
888
|
+
* Requires AMBA_PROJECT_ID + AMBA_API_KEY in your environment.
|
|
889
|
+
*/
|
|
890
|
+
import { Amba } from '@layers/amba-client';
|
|
891
|
+
|
|
892
|
+
export async function initAmba(): Promise<void> {
|
|
893
|
+
await Amba.init({
|
|
894
|
+
projectId: process.env.AMBA_PROJECT_ID!,
|
|
895
|
+
apiKey: process.env.AMBA_API_KEY!,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
`, "utf-8");
|
|
899
|
+
written.push("amba-example.tsx");
|
|
900
|
+
}
|
|
901
|
+
const readmeSnippetPath = join(cwd, "AMBA_QUICKSTART.md");
|
|
902
|
+
if (!await fileExists(readmeSnippetPath)) {
|
|
903
|
+
await writeFile(readmeSnippetPath, `# Amba quickstart — ${projectName}
|
|
904
|
+
|
|
905
|
+
1. Install the SDK (see \`AMBA.md\` for the exact command for your package manager).
|
|
906
|
+
2. Copy \`amba-example.tsx\` into your app and call it once at startup.
|
|
907
|
+
3. Verify the integration:
|
|
908
|
+
|
|
909
|
+
\`\`\`bash
|
|
910
|
+
amba status --detailed
|
|
911
|
+
amba push test
|
|
912
|
+
\`\`\`
|
|
913
|
+
|
|
914
|
+
4. Seed sample data (optional):
|
|
915
|
+
|
|
916
|
+
\`\`\`bash
|
|
917
|
+
amba seed --preset=starter
|
|
918
|
+
\`\`\`
|
|
919
|
+
|
|
920
|
+
Docs: https://docs.amba.dev
|
|
921
|
+
`, "utf-8");
|
|
922
|
+
written.push("AMBA_QUICKSTART.md");
|
|
923
|
+
}
|
|
924
|
+
return written;
|
|
925
|
+
}
|
|
926
|
+
async function initCommand(options = {}) {
|
|
927
|
+
const cwd = process.cwd();
|
|
928
|
+
const environment = options.env ?? "development";
|
|
929
|
+
console.log();
|
|
930
|
+
console.log(pc.bold(" amba init"));
|
|
931
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
932
|
+
console.log();
|
|
933
|
+
console.log(pc.bold(" Step 1/7 ") + pc.dim("Authenticate"));
|
|
934
|
+
console.log();
|
|
935
|
+
const headlessActive = resolveTokenSource({
|
|
936
|
+
flagToken: void 0,
|
|
937
|
+
envToken: process.env["AMBA_PAT"]
|
|
938
|
+
}) !== null || process.argv.includes("--token") && process.argv.length > 2;
|
|
939
|
+
let needsAuth = true;
|
|
940
|
+
if (headlessActive) {
|
|
941
|
+
console.log(pc.green(" ✓") + " Headless auth — using PAT from --token / AMBA_PAT");
|
|
942
|
+
console.log(pc.dim(" (browser flow skipped; no credentials written to disk)"));
|
|
943
|
+
console.log();
|
|
944
|
+
needsAuth = false;
|
|
945
|
+
} else try {
|
|
946
|
+
if (!isTokenExpired(await loadCredentials())) {
|
|
947
|
+
console.log(pc.green(" ✓") + " Already authenticated");
|
|
948
|
+
console.log();
|
|
949
|
+
needsAuth = false;
|
|
950
|
+
}
|
|
951
|
+
} catch {}
|
|
952
|
+
if (needsAuth) {
|
|
953
|
+
const creds = await browserAuthFlow();
|
|
954
|
+
console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
|
|
955
|
+
await storeCredentials(creds);
|
|
956
|
+
console.log(pc.green(" ✓") + " Credentials saved to ~/.amba/credentials.json");
|
|
957
|
+
console.log();
|
|
958
|
+
} else {
|
|
959
|
+
console.log(pc.bold(" Step 2/7 ") + pc.dim("Store credentials"));
|
|
960
|
+
if (headlessActive) console.log(pc.green(" ✓") + " (skipped — PAT supplied)");
|
|
961
|
+
else console.log(pc.green(" ✓") + " Using existing credentials");
|
|
962
|
+
console.log();
|
|
963
|
+
}
|
|
964
|
+
console.log(pc.bold(" Step 3/7 ") + pc.dim("Select project"));
|
|
965
|
+
console.log();
|
|
966
|
+
let projectId;
|
|
967
|
+
let projectName;
|
|
968
|
+
try {
|
|
969
|
+
const projects = (await listProjects()).data;
|
|
970
|
+
if (projects.length > 0) {
|
|
971
|
+
console.log(" Existing projects:");
|
|
972
|
+
projects.forEach((p, i) => {
|
|
973
|
+
console.log(pc.dim(` ${i + 1}.`) + ` ${p.name} ` + pc.dim(`(${p.id})`));
|
|
974
|
+
});
|
|
975
|
+
console.log(pc.dim(` ${projects.length + 1}.`) + " Create new project");
|
|
976
|
+
console.log();
|
|
977
|
+
const choice = await prompt(` Select project (1-${projects.length + 1}): `);
|
|
978
|
+
const choiceNum = parseInt(choice, 10);
|
|
979
|
+
if (choiceNum > 0 && choiceNum <= projects.length) {
|
|
980
|
+
const selected = projects[choiceNum - 1];
|
|
981
|
+
if (!selected) throw new Error("Invalid selection");
|
|
982
|
+
projectId = selected.id;
|
|
983
|
+
projectName = selected.name;
|
|
984
|
+
console.log(pc.green(" ✓") + ` Selected: ${projectName}`);
|
|
985
|
+
} else {
|
|
986
|
+
const name = await prompt(" Project name: ");
|
|
987
|
+
if (!name) {
|
|
988
|
+
console.log(pc.red(" ✗") + " Project name is required");
|
|
989
|
+
process.exit(1);
|
|
990
|
+
}
|
|
991
|
+
projectId = (await createProject({
|
|
992
|
+
name,
|
|
993
|
+
environment
|
|
994
|
+
})).data.id;
|
|
995
|
+
projectName = name;
|
|
996
|
+
console.log(pc.green(" ✓") + ` Created: ${projectName} ${pc.dim(`(${environment})`)}`);
|
|
997
|
+
}
|
|
998
|
+
} else {
|
|
999
|
+
const name = await prompt(" Project name: ");
|
|
1000
|
+
if (!name) {
|
|
1001
|
+
console.log(pc.red(" ✗") + " Project name is required");
|
|
1002
|
+
process.exit(1);
|
|
1003
|
+
}
|
|
1004
|
+
projectId = (await createProject({ name })).data.id;
|
|
1005
|
+
projectName = name;
|
|
1006
|
+
console.log(pc.green(" ✓") + ` Created: ${projectName}`);
|
|
1007
|
+
}
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
if (err instanceof Error && err.message.includes("authenticate")) throw err;
|
|
1010
|
+
const name = await prompt(" Project name: ");
|
|
1011
|
+
if (!name) {
|
|
1012
|
+
console.log(pc.red(" ✗") + " Project name is required");
|
|
1013
|
+
process.exit(1);
|
|
1014
|
+
}
|
|
1015
|
+
projectId = (await createProject({ name })).data.id;
|
|
1016
|
+
projectName = name;
|
|
1017
|
+
console.log(pc.green(" ✓") + ` Created: ${projectName}`);
|
|
1018
|
+
}
|
|
1019
|
+
console.log();
|
|
1020
|
+
console.log(pc.bold(" Step 4/7 ") + pc.dim("Generate API keys"));
|
|
1021
|
+
const keyRes = await createApiKey(projectId, "client", "development");
|
|
1022
|
+
const apiKey = keyRes.data.key;
|
|
1023
|
+
console.log(pc.green(" ✓") + " Development client key created");
|
|
1024
|
+
console.log(pc.dim(` ${keyRes.data.key_prefix}...`));
|
|
1025
|
+
console.log();
|
|
1026
|
+
console.log(pc.bold(" Step 5/7 ") + pc.dim("Write environment file"));
|
|
1027
|
+
const envPath = join(cwd, ".env.local");
|
|
1028
|
+
const envLines = [
|
|
1029
|
+
"# Amba SDK Configuration",
|
|
1030
|
+
`AMBA_PROJECT_ID=${projectId}`,
|
|
1031
|
+
`AMBA_API_KEY=${apiKey}`,
|
|
1032
|
+
`AMBA_API_URL=https://api.amba.dev`,
|
|
1033
|
+
""
|
|
1034
|
+
];
|
|
1035
|
+
if (await fileExists(envPath)) {
|
|
1036
|
+
const existing = await readFile(envPath, "utf-8");
|
|
1037
|
+
if (existing.includes("AMBA_PROJECT_ID")) {
|
|
1038
|
+
console.log(pc.yellow(" !") + " .env.local already contains Amba config — updating");
|
|
1039
|
+
let updated = existing;
|
|
1040
|
+
updated = updated.replace(/AMBA_PROJECT_ID=.*/, `AMBA_PROJECT_ID=${projectId}`);
|
|
1041
|
+
updated = updated.replace(/AMBA_API_KEY=.*/, `AMBA_API_KEY=${apiKey}`);
|
|
1042
|
+
updated = updated.replace(/AMBA_API_URL=.*/, `AMBA_API_URL=https://api.amba.dev`);
|
|
1043
|
+
await writeFile(envPath, updated, "utf-8");
|
|
1044
|
+
} else await writeFile(envPath, existing + (existing.endsWith("\n") ? "\n" : "\n\n") + envLines.join("\n"), "utf-8");
|
|
1045
|
+
} else await writeFile(envPath, envLines.join("\n"), "utf-8");
|
|
1046
|
+
console.log(pc.green(" ✓") + " .env.local written");
|
|
1047
|
+
console.log();
|
|
1048
|
+
console.log(pc.bold(" Step 6/7 ") + pc.dim("Detect framework"));
|
|
1049
|
+
const framework = await detectFramework(cwd);
|
|
1050
|
+
const sdkPkg = getSdkPackage(framework);
|
|
1051
|
+
if (framework !== "unknown") console.log(pc.green(" ✓") + ` Detected: ${pc.bold(framework)}`);
|
|
1052
|
+
else console.log(pc.yellow(" !") + " Could not detect framework");
|
|
1053
|
+
let installCmd = `npm install ${sdkPkg}`;
|
|
1054
|
+
if (await fileExists(join(cwd, "bun.lockb"))) installCmd = `bun add ${sdkPkg}`;
|
|
1055
|
+
else if (await fileExists(join(cwd, "pnpm-lock.yaml"))) installCmd = `pnpm add ${sdkPkg}`;
|
|
1056
|
+
else if (await fileExists(join(cwd, "yarn.lock"))) installCmd = `yarn add ${sdkPkg}`;
|
|
1057
|
+
console.log(pc.dim(` Install SDK: ${installCmd}`));
|
|
1058
|
+
console.log();
|
|
1059
|
+
console.log(pc.bold(" Step 7/7 ") + pc.dim("Generate context files"));
|
|
1060
|
+
const generatedFiles = await generateContextFiles({
|
|
1061
|
+
projectId,
|
|
1062
|
+
projectName,
|
|
1063
|
+
apiKey,
|
|
1064
|
+
framework,
|
|
1065
|
+
cwd
|
|
1066
|
+
});
|
|
1067
|
+
for (const file of generatedFiles) console.log(pc.green(" ✓") + ` ${file}`);
|
|
1068
|
+
if (options.withExample) {
|
|
1069
|
+
const exampleFiles = await writeExampleScaffold(cwd, framework, projectName);
|
|
1070
|
+
if (exampleFiles.length > 0) for (const file of exampleFiles) console.log(pc.green(" ✓") + ` ${file} ` + pc.dim("(example)"));
|
|
1071
|
+
else console.log(pc.dim(" -") + " example files already present — skipping");
|
|
1072
|
+
}
|
|
1073
|
+
console.log();
|
|
1074
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1075
|
+
console.log();
|
|
1076
|
+
console.log(pc.bold(pc.green(" ✓ Project initialized!")));
|
|
1077
|
+
console.log();
|
|
1078
|
+
console.log(" Quick start:");
|
|
1079
|
+
console.log();
|
|
1080
|
+
console.log(pc.dim(" 1.") + ` Install the SDK`);
|
|
1081
|
+
console.log(` ${pc.cyan(installCmd)}`);
|
|
1082
|
+
console.log();
|
|
1083
|
+
console.log(pc.dim(" 2.") + ` Add the provider to your app`);
|
|
1084
|
+
if (framework === "expo") console.log(pc.dim(` See AMBA.md for Amba.init() setup`));
|
|
1085
|
+
else if (framework === "react-native") console.log(pc.dim(` See AMBA.md for client initialization`));
|
|
1086
|
+
else console.log(pc.dim(` See AMBA.md for client initialization`));
|
|
1087
|
+
console.log();
|
|
1088
|
+
console.log(pc.dim(" 3.") + ` Test the integration`);
|
|
1089
|
+
console.log(` ${pc.cyan("amba status")}`);
|
|
1090
|
+
console.log();
|
|
1091
|
+
console.log(pc.dim(" 4.") + ` Send a test notification`);
|
|
1092
|
+
console.log(` ${pc.cyan("amba push test")}`);
|
|
1093
|
+
console.log();
|
|
1094
|
+
console.log(` Docs: ${pc.underline("https://docs.amba.dev")}`);
|
|
1095
|
+
console.log();
|
|
1096
|
+
}
|
|
1097
|
+
//#endregion
|
|
1098
|
+
//#region src/commands/login.ts
|
|
1099
|
+
async function loginCommand() {
|
|
1100
|
+
console.log();
|
|
1101
|
+
console.log(pc.bold(" amba login"));
|
|
1102
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1103
|
+
console.log();
|
|
1104
|
+
try {
|
|
1105
|
+
await storeCredentials(await browserAuthFlow());
|
|
1106
|
+
console.log(pc.green(" ✓") + " Authenticated successfully");
|
|
1107
|
+
console.log(pc.dim(" Credentials saved to ~/.amba/credentials.json"));
|
|
1108
|
+
console.log();
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
1111
|
+
console.log(pc.red(" ✗") + ` Authentication failed: ${message}`);
|
|
1112
|
+
console.log();
|
|
1113
|
+
process.exit(1);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
//#endregion
|
|
1117
|
+
//#region src/commands/status.ts
|
|
1118
|
+
async function readEnvFile(cwd) {
|
|
1119
|
+
const config = {
|
|
1120
|
+
projectId: void 0,
|
|
1121
|
+
apiKey: void 0,
|
|
1122
|
+
apiUrl: void 0
|
|
1123
|
+
};
|
|
1124
|
+
for (const filename of [".env.local", ".env"]) try {
|
|
1125
|
+
const raw = await readFile(join(cwd, filename), "utf-8");
|
|
1126
|
+
for (const line of raw.split("\n")) {
|
|
1127
|
+
const trimmed = line.trim();
|
|
1128
|
+
if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
|
|
1129
|
+
const eqIndex = trimmed.indexOf("=");
|
|
1130
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
1131
|
+
const value = trimmed.slice(eqIndex + 1).trim();
|
|
1132
|
+
if (key === "AMBA_PROJECT_ID") config.projectId = value;
|
|
1133
|
+
if (key === "AMBA_API_KEY") config.apiKey = value;
|
|
1134
|
+
if (key === "AMBA_API_URL") config.apiUrl = value;
|
|
1135
|
+
}
|
|
1136
|
+
break;
|
|
1137
|
+
} catch {
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
return config;
|
|
1141
|
+
}
|
|
1142
|
+
async function statusCommand(opts = {}) {
|
|
1143
|
+
const cwd = process.cwd();
|
|
1144
|
+
console.log();
|
|
1145
|
+
console.log(pc.bold(" amba status"));
|
|
1146
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1147
|
+
console.log();
|
|
1148
|
+
console.log(pc.bold(" Authentication"));
|
|
1149
|
+
try {
|
|
1150
|
+
if (isTokenExpired(await loadCredentials())) console.log(pc.yellow(" ! Session expired") + pc.dim(" — run `amba login`"));
|
|
1151
|
+
else console.log(pc.green(" ✓ Logged in"));
|
|
1152
|
+
} catch {
|
|
1153
|
+
console.log(pc.red(" ✗ Not authenticated") + pc.dim(" — run `amba login`"));
|
|
1154
|
+
}
|
|
1155
|
+
console.log();
|
|
1156
|
+
console.log(pc.bold(" Local Configuration"));
|
|
1157
|
+
const env = await readEnvFile(cwd);
|
|
1158
|
+
if (!env.projectId && !env.apiKey) {
|
|
1159
|
+
console.log(pc.red(" ✗ No Amba config found") + pc.dim(" — run `amba init`"));
|
|
1160
|
+
console.log();
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (env.projectId) console.log(pc.green(" ✓") + ` Project ID: ${pc.dim(env.projectId)}`);
|
|
1164
|
+
else console.log(pc.red(" ✗") + " AMBA_PROJECT_ID not set");
|
|
1165
|
+
if (env.apiKey) {
|
|
1166
|
+
const prefix = env.apiKey.length > 16 ? env.apiKey.slice(0, 16) + "..." : env.apiKey;
|
|
1167
|
+
console.log(pc.green(" ✓") + ` API Key: ${pc.dim(prefix)}`);
|
|
1168
|
+
} else console.log(pc.red(" ✗") + " AMBA_API_KEY not set");
|
|
1169
|
+
if (env.apiUrl) console.log(pc.green(" ✓") + ` API URL: ${pc.dim(env.apiUrl)}`);
|
|
1170
|
+
console.log();
|
|
1171
|
+
if (env.apiKey) {
|
|
1172
|
+
console.log(pc.bold(" API Key Validation"));
|
|
1173
|
+
try {
|
|
1174
|
+
const result = await validateApiKey(env.apiKey);
|
|
1175
|
+
if (result.valid) {
|
|
1176
|
+
console.log(pc.green(" ✓") + " API key is valid");
|
|
1177
|
+
console.log(pc.dim(` Environment: ${result.environment}`));
|
|
1178
|
+
} else console.log(pc.red(" ✗") + ` API key is invalid: ${result.error}`);
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
if (err instanceof ApiClientError) console.log(pc.red(" ✗") + ` Validation failed: ${err.message}`);
|
|
1181
|
+
else console.log(pc.yellow(" !") + " Could not reach API to validate key");
|
|
1182
|
+
}
|
|
1183
|
+
console.log();
|
|
1184
|
+
}
|
|
1185
|
+
if (env.projectId) {
|
|
1186
|
+
console.log(pc.bold(" Project Details"));
|
|
1187
|
+
try {
|
|
1188
|
+
const p = (await getProject(env.projectId)).data;
|
|
1189
|
+
console.log(` Name: ${p.name}`);
|
|
1190
|
+
console.log(` Platform: ${p.platform}`);
|
|
1191
|
+
console.log(` Environment: ${p.environment}`);
|
|
1192
|
+
if (p.bundle_id) console.log(` Bundle ID: ${p.bundle_id}`);
|
|
1193
|
+
console.log(` Created: ${pc.dim(p.created_at)}`);
|
|
1194
|
+
} catch (err) {
|
|
1195
|
+
if (err instanceof ApiClientError && err.statusCode === 404) console.log(pc.red(" ✗") + " Project not found");
|
|
1196
|
+
else if (err instanceof Error && err.message.includes("authenticate")) console.log(pc.yellow(" !") + " Login required to fetch project details");
|
|
1197
|
+
else console.log(pc.yellow(" !") + " Could not fetch project details");
|
|
1198
|
+
}
|
|
1199
|
+
console.log();
|
|
1200
|
+
}
|
|
1201
|
+
console.log(pc.bold(" Context Files"));
|
|
1202
|
+
for (const file of ["AMBA.md", ".cursor/rules/amba.mdc"]) try {
|
|
1203
|
+
await readFile(join(cwd, file), "utf-8");
|
|
1204
|
+
console.log(pc.green(" ✓") + ` ${file}`);
|
|
1205
|
+
} catch {
|
|
1206
|
+
console.log(pc.dim(" -") + ` ${file} ` + pc.dim("(not generated — run `amba init`)"));
|
|
1207
|
+
}
|
|
1208
|
+
console.log();
|
|
1209
|
+
if (opts.detailed && env.projectId) {
|
|
1210
|
+
const projectId = env.projectId;
|
|
1211
|
+
console.log(pc.bold(" Integrations"));
|
|
1212
|
+
try {
|
|
1213
|
+
const integrations = (await listIntegrations(projectId)).data;
|
|
1214
|
+
const expected = [
|
|
1215
|
+
"apns",
|
|
1216
|
+
"fcm",
|
|
1217
|
+
"revenuecat",
|
|
1218
|
+
"superwall"
|
|
1219
|
+
];
|
|
1220
|
+
const byProvider = /* @__PURE__ */ new Map();
|
|
1221
|
+
for (const integ of integrations) byProvider.set(String(integ.provider).toLowerCase(), integ);
|
|
1222
|
+
for (const provider of expected) {
|
|
1223
|
+
const integ = byProvider.get(provider);
|
|
1224
|
+
if (!integ) {
|
|
1225
|
+
console.log(pc.dim(" -") + ` ${provider.toUpperCase()} ` + pc.dim("(not configured)"));
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
const enabled = integ.enabled !== false && integ.status !== "disabled";
|
|
1229
|
+
const icon = enabled ? pc.green(" ✓") : pc.yellow(" !");
|
|
1230
|
+
const label = provider.toUpperCase();
|
|
1231
|
+
const statusLabel = integ.status ?? (enabled ? "active" : "inactive");
|
|
1232
|
+
console.log(`${icon} ${label} ` + pc.dim(`(${statusLabel})`));
|
|
1233
|
+
}
|
|
1234
|
+
} catch (err) {
|
|
1235
|
+
if (err instanceof ApiClientError) console.log(pc.yellow(" !") + ` Could not load integrations: ${err.message}`);
|
|
1236
|
+
else console.log(pc.yellow(" !") + " Could not load integrations");
|
|
1237
|
+
}
|
|
1238
|
+
console.log();
|
|
1239
|
+
console.log(pc.bold(" Metrics (last 24h)"));
|
|
1240
|
+
try {
|
|
1241
|
+
const users = await listUsers(projectId, { limit: 1 });
|
|
1242
|
+
const userCount = typeof users.total === "number" ? users.total : users.data.length;
|
|
1243
|
+
console.log(pc.dim(" Users: ") + String(userCount));
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
const msg = err instanceof Error ? err.message : "unknown error";
|
|
1246
|
+
console.log(pc.yellow(" Users: ") + pc.dim(`(unavailable — ${msg})`));
|
|
1247
|
+
}
|
|
1248
|
+
try {
|
|
1249
|
+
const segs = await listSegments(projectId);
|
|
1250
|
+
const activeCount = segs.data.filter((s) => s.is_active !== false).length;
|
|
1251
|
+
console.log(pc.dim(" Segments: ") + `${activeCount} active / ${segs.data.length} total`);
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
const msg = err instanceof Error ? err.message : "unknown error";
|
|
1254
|
+
console.log(pc.yellow(" Segments: ") + pc.dim(`(unavailable — ${msg})`));
|
|
1255
|
+
}
|
|
1256
|
+
const since24h = (/* @__PURE__ */ new Date(Date.now() - 1440 * 60 * 1e3)).toISOString();
|
|
1257
|
+
let eventTotal = null;
|
|
1258
|
+
try {
|
|
1259
|
+
eventTotal = (await getEventsCount(projectId, { since: since24h })).data.total;
|
|
1260
|
+
console.log(pc.dim(" Events: ") + String(eventTotal));
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
if (err instanceof ApiClientError && err.statusCode === 404) console.log(pc.dim(" Events: ") + pc.dim("(unavailable — endpoint not deployed)"));
|
|
1263
|
+
else {
|
|
1264
|
+
const msg = err instanceof Error ? err.message : "unknown error";
|
|
1265
|
+
console.log(pc.yellow(" Events: ") + pc.dim(`(unavailable — ${msg})`));
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
if (eventTotal !== null && eventTotal > 0) try {
|
|
1269
|
+
const top = ((await getEventsCount(projectId, {
|
|
1270
|
+
since: since24h,
|
|
1271
|
+
groupBy: "event_name"
|
|
1272
|
+
})).data.buckets ?? []).slice(0, 5);
|
|
1273
|
+
if (top.length > 0) {
|
|
1274
|
+
console.log(pc.dim(" Top events (24h):"));
|
|
1275
|
+
const widest = top.reduce((m, b) => Math.max(m, b.key.length), 0);
|
|
1276
|
+
for (const b of top) console.log(pc.dim(" ") + b.key.padEnd(widest) + " " + pc.dim(String(b.count)));
|
|
1277
|
+
}
|
|
1278
|
+
} catch (err) {
|
|
1279
|
+
if (err instanceof ApiClientError && err.statusCode === 404) {} else {
|
|
1280
|
+
const msg = err instanceof Error ? err.message : "unknown error";
|
|
1281
|
+
console.log(pc.yellow(" Top events: ") + pc.dim(`(unavailable — ${msg})`));
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
console.log();
|
|
1285
|
+
} else if (opts.detailed && !env.projectId) {
|
|
1286
|
+
console.log(pc.yellow(" !") + " --detailed requires AMBA_PROJECT_ID in .env.local");
|
|
1287
|
+
console.log();
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
//#endregion
|
|
1291
|
+
//#region src/commands/push.ts
|
|
1292
|
+
async function getProjectId$1(cwd) {
|
|
1293
|
+
for (const filename of [".env.local", ".env"]) try {
|
|
1294
|
+
const raw = await readFile(join(cwd, filename), "utf-8");
|
|
1295
|
+
for (const line of raw.split("\n")) {
|
|
1296
|
+
const trimmed = line.trim();
|
|
1297
|
+
if (trimmed.startsWith("AMBA_PROJECT_ID=")) return trimmed.slice(16).trim();
|
|
1298
|
+
}
|
|
1299
|
+
} catch {
|
|
1300
|
+
continue;
|
|
1301
|
+
}
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
async function pushTestCommand() {
|
|
1305
|
+
const cwd = process.cwd();
|
|
1306
|
+
console.log();
|
|
1307
|
+
console.log(pc.bold(" amba push test"));
|
|
1308
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1309
|
+
console.log();
|
|
1310
|
+
const projectId = await getProjectId$1(cwd);
|
|
1311
|
+
if (!projectId) {
|
|
1312
|
+
console.log(pc.red(" ✗") + " AMBA_PROJECT_ID not found in .env.local");
|
|
1313
|
+
console.log(pc.dim(" Run `amba init` to set up your project"));
|
|
1314
|
+
console.log();
|
|
1315
|
+
process.exit(1);
|
|
1316
|
+
}
|
|
1317
|
+
console.log(pc.dim(" Sending test push notification..."));
|
|
1318
|
+
console.log();
|
|
1319
|
+
try {
|
|
1320
|
+
const result = await sendTestPush(projectId, {
|
|
1321
|
+
title: "Amba Test",
|
|
1322
|
+
body: "If you see this, push notifications are working!",
|
|
1323
|
+
data: { source: "cli-test" }
|
|
1324
|
+
});
|
|
1325
|
+
console.log(pc.green(" ✓") + ` ${result.data.message}`);
|
|
1326
|
+
console.log(pc.dim(` Sent to ${result.data.sent} device(s)`));
|
|
1327
|
+
console.log();
|
|
1328
|
+
} catch (err) {
|
|
1329
|
+
if (err instanceof ApiClientError) if (err.statusCode === 404) {
|
|
1330
|
+
console.log(pc.red(" ✗") + " Project not found");
|
|
1331
|
+
console.log(pc.dim(" Check your AMBA_PROJECT_ID in .env.local"));
|
|
1332
|
+
} else if (err.statusCode === 422) {
|
|
1333
|
+
console.log(pc.yellow(" !") + " No push tokens registered yet");
|
|
1334
|
+
console.log(pc.dim(" Install the SDK in your app and register a push token first"));
|
|
1335
|
+
} else console.log(pc.red(" ✗") + ` Failed: ${err.message}`);
|
|
1336
|
+
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
1337
|
+
console.log();
|
|
1338
|
+
process.exit(1);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
//#endregion
|
|
1342
|
+
//#region src/commands/config.ts
|
|
1343
|
+
async function getProjectId(cwd) {
|
|
1344
|
+
for (const filename of [".env.local", ".env"]) try {
|
|
1345
|
+
const raw = await readFile(join(cwd, filename), "utf-8");
|
|
1346
|
+
for (const line of raw.split("\n")) {
|
|
1347
|
+
const trimmed = line.trim();
|
|
1348
|
+
if (trimmed.startsWith("AMBA_PROJECT_ID=")) return trimmed.slice(16).trim();
|
|
1349
|
+
}
|
|
1350
|
+
} catch {
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
function requireProjectId$1(projectId) {
|
|
1356
|
+
if (!projectId) {
|
|
1357
|
+
console.log(pc.red(" ✗") + " AMBA_PROJECT_ID not found in .env.local");
|
|
1358
|
+
console.log(pc.dim(" Run `amba init` to set up your project"));
|
|
1359
|
+
console.log();
|
|
1360
|
+
process.exit(1);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
function formatValue(value) {
|
|
1364
|
+
if (typeof value === "string") return value;
|
|
1365
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
1366
|
+
return JSON.stringify(value);
|
|
1367
|
+
}
|
|
1368
|
+
function inferValueType(raw) {
|
|
1369
|
+
if (raw === "true") return {
|
|
1370
|
+
value: true,
|
|
1371
|
+
value_type: "boolean"
|
|
1372
|
+
};
|
|
1373
|
+
if (raw === "false") return {
|
|
1374
|
+
value: false,
|
|
1375
|
+
value_type: "boolean"
|
|
1376
|
+
};
|
|
1377
|
+
const num = Number(raw);
|
|
1378
|
+
if (!isNaN(num) && raw.trim() !== "") return {
|
|
1379
|
+
value: num,
|
|
1380
|
+
value_type: "number"
|
|
1381
|
+
};
|
|
1382
|
+
if (raw.startsWith("{") && raw.endsWith("}") || raw.startsWith("[") && raw.endsWith("]")) try {
|
|
1383
|
+
return {
|
|
1384
|
+
value: JSON.parse(raw),
|
|
1385
|
+
value_type: "json"
|
|
1386
|
+
};
|
|
1387
|
+
} catch {}
|
|
1388
|
+
return {
|
|
1389
|
+
value: raw,
|
|
1390
|
+
value_type: "string"
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
async function configListCommand() {
|
|
1394
|
+
const cwd = process.cwd();
|
|
1395
|
+
console.log();
|
|
1396
|
+
console.log(pc.bold(" amba config list"));
|
|
1397
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1398
|
+
console.log();
|
|
1399
|
+
const projectId = await getProjectId(cwd);
|
|
1400
|
+
requireProjectId$1(projectId);
|
|
1401
|
+
try {
|
|
1402
|
+
const configs = (await listConfig(projectId)).data;
|
|
1403
|
+
if (configs.length === 0) {
|
|
1404
|
+
console.log(pc.dim(" No config values set"));
|
|
1405
|
+
console.log(pc.dim(" Use `amba config set <key> <value>` to add one"));
|
|
1406
|
+
console.log();
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const keyWidth = Math.max(5, ...configs.map((c) => c.key.length));
|
|
1410
|
+
const typeWidth = Math.max(4, ...configs.map((c) => c.value_type.length));
|
|
1411
|
+
console.log(pc.dim(" ") + pc.bold("Key".padEnd(keyWidth + 2)) + pc.bold("Type".padEnd(typeWidth + 2)) + pc.bold("Value"));
|
|
1412
|
+
console.log(pc.dim(" " + "─".repeat(keyWidth + typeWidth + 30)));
|
|
1413
|
+
for (const config of configs) {
|
|
1414
|
+
const valueStr = formatValue(config.value);
|
|
1415
|
+
const truncated = valueStr.length > 50 ? valueStr.slice(0, 47) + "..." : valueStr;
|
|
1416
|
+
console.log(" " + config.key.padEnd(keyWidth + 2) + pc.dim(config.value_type.padEnd(typeWidth + 2)) + truncated);
|
|
1417
|
+
}
|
|
1418
|
+
console.log();
|
|
1419
|
+
console.log(pc.dim(` ${configs.length} config value${configs.length === 1 ? "" : "s"}`));
|
|
1420
|
+
console.log();
|
|
1421
|
+
} catch (err) {
|
|
1422
|
+
if (err instanceof ApiClientError) console.log(pc.red(" ✗") + ` Failed: ${err.message}`);
|
|
1423
|
+
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
1424
|
+
console.log();
|
|
1425
|
+
process.exit(1);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
async function configSetCommand(key, rawValue) {
|
|
1429
|
+
const cwd = process.cwd();
|
|
1430
|
+
console.log();
|
|
1431
|
+
console.log(pc.bold(" amba config set"));
|
|
1432
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1433
|
+
console.log();
|
|
1434
|
+
const projectId = await getProjectId(cwd);
|
|
1435
|
+
requireProjectId$1(projectId);
|
|
1436
|
+
const { value, value_type } = inferValueType(rawValue);
|
|
1437
|
+
try {
|
|
1438
|
+
const result = await setConfig(projectId, {
|
|
1439
|
+
key,
|
|
1440
|
+
value,
|
|
1441
|
+
value_type
|
|
1442
|
+
});
|
|
1443
|
+
console.log(pc.green(" ✓") + ` Set ${pc.bold(key)} = ${formatValue(result.data.value)}`);
|
|
1444
|
+
console.log(pc.dim(` Type: ${result.data.value_type}`));
|
|
1445
|
+
console.log();
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
if (err instanceof ApiClientError) console.log(pc.red(" ✗") + ` Failed: ${err.message}`);
|
|
1448
|
+
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
1449
|
+
console.log();
|
|
1450
|
+
process.exit(1);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
//#endregion
|
|
1454
|
+
//#region src/commands/projects.ts
|
|
1455
|
+
function confirm(question) {
|
|
1456
|
+
const rl = createInterface({
|
|
1457
|
+
input: process.stdin,
|
|
1458
|
+
output: process.stdout
|
|
1459
|
+
});
|
|
1460
|
+
return new Promise((resolve) => {
|
|
1461
|
+
rl.question(question, (answer) => {
|
|
1462
|
+
rl.close();
|
|
1463
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
1464
|
+
});
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
function handleError(err) {
|
|
1468
|
+
if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
|
|
1469
|
+
else console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
1470
|
+
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
1471
|
+
else console.log(pc.red(" ✗") + " Unknown error");
|
|
1472
|
+
console.log();
|
|
1473
|
+
process.exit(1);
|
|
1474
|
+
}
|
|
1475
|
+
function shortDate(iso) {
|
|
1476
|
+
if (!iso) return "—";
|
|
1477
|
+
const d = new Date(iso);
|
|
1478
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
1479
|
+
return d.toISOString().slice(0, 10);
|
|
1480
|
+
}
|
|
1481
|
+
async function projectsListCommand() {
|
|
1482
|
+
console.log();
|
|
1483
|
+
console.log(pc.bold(" amba projects list"));
|
|
1484
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1485
|
+
console.log();
|
|
1486
|
+
try {
|
|
1487
|
+
const projects = (await listProjects()).data;
|
|
1488
|
+
if (projects.length === 0) {
|
|
1489
|
+
console.log(pc.dim(" No projects."));
|
|
1490
|
+
console.log(pc.dim(" Create one with ") + pc.cyan("amba projects create --name <name>"));
|
|
1491
|
+
console.log();
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
const rows = projects.map((p) => [
|
|
1495
|
+
p.id,
|
|
1496
|
+
p.name,
|
|
1497
|
+
p.environment ?? "—",
|
|
1498
|
+
p.status ?? "active",
|
|
1499
|
+
shortDate(p.created_at)
|
|
1500
|
+
]);
|
|
1501
|
+
const headers = [
|
|
1502
|
+
"Id",
|
|
1503
|
+
"Name",
|
|
1504
|
+
"Env",
|
|
1505
|
+
"Status",
|
|
1506
|
+
"Created"
|
|
1507
|
+
];
|
|
1508
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => String(r[i] ?? "").length)));
|
|
1509
|
+
const pad = (cells) => cells.map((cell, i) => String(cell).padEnd((widths[i] ?? 0) + 2)).join("");
|
|
1510
|
+
console.log(" " + pc.bold(pad(headers)));
|
|
1511
|
+
console.log(" " + pc.dim("─".repeat(widths.reduce((a, b) => a + b + 2, 0))));
|
|
1512
|
+
for (const row of rows) console.log(" " + pad(row));
|
|
1513
|
+
console.log();
|
|
1514
|
+
console.log(pc.dim(` ${projects.length} project${projects.length === 1 ? "" : "s"}`));
|
|
1515
|
+
console.log();
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
handleError(err);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
async function projectsCreateCommand(input) {
|
|
1521
|
+
console.log();
|
|
1522
|
+
console.log(pc.bold(" amba projects create"));
|
|
1523
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1524
|
+
console.log();
|
|
1525
|
+
if (!input.name) {
|
|
1526
|
+
console.log(pc.red(" ✗") + " --name is required");
|
|
1527
|
+
console.log();
|
|
1528
|
+
process.exit(1);
|
|
1529
|
+
}
|
|
1530
|
+
let environment;
|
|
1531
|
+
if (input.env) if (input.env === "development" || input.env === "dev") environment = "development";
|
|
1532
|
+
else if (input.env === "production" || input.env === "prod") environment = "production";
|
|
1533
|
+
else {
|
|
1534
|
+
console.log(pc.red(" ✗") + ` --env must be 'development' or 'production' (got '${input.env}').`);
|
|
1535
|
+
console.log();
|
|
1536
|
+
process.exit(1);
|
|
1537
|
+
}
|
|
1538
|
+
try {
|
|
1539
|
+
console.log(pc.dim(" Provisioning project..."));
|
|
1540
|
+
const res = await createProject({
|
|
1541
|
+
name: input.name,
|
|
1542
|
+
bundle_id: input.bundleId,
|
|
1543
|
+
platform: input.platform,
|
|
1544
|
+
environment
|
|
1545
|
+
});
|
|
1546
|
+
const id = res.data.id;
|
|
1547
|
+
console.log(pc.green(" ✓") + ` Created: ${pc.bold(res.data.name)} ${pc.dim(`(${id})`)}`);
|
|
1548
|
+
console.log();
|
|
1549
|
+
console.log(pc.dim(" Checking provisioning status..."));
|
|
1550
|
+
try {
|
|
1551
|
+
const s = (await getProvisioningStatus(id)).data;
|
|
1552
|
+
console.log(pc.dim(` Status: ${s.status}`));
|
|
1553
|
+
if (s.errorMessage) console.log(pc.yellow(" !") + ` ${s.errorMessage}`);
|
|
1554
|
+
} catch {
|
|
1555
|
+
console.log(pc.dim(" (Provisioning runs asynchronously.)"));
|
|
1556
|
+
}
|
|
1557
|
+
if (environment) console.log(pc.dim(` Environment: ${environment}`));
|
|
1558
|
+
console.log();
|
|
1559
|
+
console.log(pc.dim(" Next: ") + pc.cyan(`amba projects show ${id}`));
|
|
1560
|
+
console.log();
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
handleError(err);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
async function projectsShowCommand(projectId) {
|
|
1566
|
+
console.log();
|
|
1567
|
+
console.log(pc.bold(` amba projects show ${projectId}`));
|
|
1568
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1569
|
+
console.log();
|
|
1570
|
+
try {
|
|
1571
|
+
const res = await getProject(projectId);
|
|
1572
|
+
console.log(JSON.stringify(res.data, null, 2));
|
|
1573
|
+
console.log();
|
|
1574
|
+
} catch (err) {
|
|
1575
|
+
if (err instanceof ApiClientError && err.statusCode === 404) {
|
|
1576
|
+
console.log(pc.red(" ✗") + ` Project not found: ${projectId}`);
|
|
1577
|
+
console.log();
|
|
1578
|
+
process.exit(1);
|
|
1579
|
+
}
|
|
1580
|
+
handleError(err);
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
async function projectsDeleteCommand(projectId, opts = {}) {
|
|
1584
|
+
console.log();
|
|
1585
|
+
console.log(pc.bold(` amba projects delete ${projectId}`));
|
|
1586
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1587
|
+
console.log();
|
|
1588
|
+
if (!opts.yes) {
|
|
1589
|
+
if (!await confirm(pc.yellow(` Delete project ${projectId}? This is irreversible. (y/N) `))) {
|
|
1590
|
+
console.log(pc.dim(" Aborted."));
|
|
1591
|
+
console.log();
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
try {
|
|
1596
|
+
await deleteProject(projectId);
|
|
1597
|
+
console.log(pc.green(" ✓") + ` Deleted ${projectId}`);
|
|
1598
|
+
console.log();
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
if (err instanceof ApiClientError && err.statusCode === 404) {
|
|
1601
|
+
console.log(pc.red(" ✗") + ` Project not found: ${projectId}`);
|
|
1602
|
+
console.log();
|
|
1603
|
+
process.exit(1);
|
|
1604
|
+
}
|
|
1605
|
+
handleError(err);
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region src/env.ts
|
|
1610
|
+
/**
|
|
1611
|
+
* Read `AMBA_PROJECT_ID` from `.env.local` / `.env` in the given directory.
|
|
1612
|
+
* Returns null if not found.
|
|
1613
|
+
*/
|
|
1614
|
+
async function getProjectIdFromEnv(cwd) {
|
|
1615
|
+
for (const filename of [".env.local", ".env"]) try {
|
|
1616
|
+
const raw = await readFile(join(cwd, filename), "utf-8");
|
|
1617
|
+
for (const line of raw.split("\n")) {
|
|
1618
|
+
const trimmed = line.trim();
|
|
1619
|
+
if (trimmed.startsWith("AMBA_PROJECT_ID=")) return trimmed.slice(16).trim();
|
|
1620
|
+
}
|
|
1621
|
+
} catch {
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
return null;
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Resolve a project id in priority order:
|
|
1628
|
+
* 1. Explicit arg (e.g. `--project <id>`)
|
|
1629
|
+
* 2. `.env.local` / `.env` in cwd
|
|
1630
|
+
*
|
|
1631
|
+
* Prints a helpful error message and exits 1 if no id is found.
|
|
1632
|
+
*/
|
|
1633
|
+
async function requireProjectId(cwd, explicit) {
|
|
1634
|
+
if (explicit) return explicit;
|
|
1635
|
+
const fromEnv = await getProjectIdFromEnv(cwd);
|
|
1636
|
+
if (fromEnv) return fromEnv;
|
|
1637
|
+
console.log();
|
|
1638
|
+
console.log(pc.red(" ✗") + " No project id");
|
|
1639
|
+
console.log(pc.dim(" Pass ") + pc.cyan("--project <id>") + pc.dim(" or run ") + pc.cyan("amba init") + pc.dim(" in this directory."));
|
|
1640
|
+
console.log();
|
|
1641
|
+
process.exit(1);
|
|
1642
|
+
}
|
|
1643
|
+
//#endregion
|
|
1644
|
+
//#region src/commands/logs.ts
|
|
1645
|
+
const DEFAULT_TAIL_LIMIT = 100;
|
|
1646
|
+
const DEFAULT_POLL_INTERVAL_MS = 2e3;
|
|
1647
|
+
function compactProperties(props) {
|
|
1648
|
+
if (!props || typeof props !== "object") return "";
|
|
1649
|
+
if (Object.keys(props).length === 0) return "";
|
|
1650
|
+
return JSON.stringify(props);
|
|
1651
|
+
}
|
|
1652
|
+
function formatEvent(ev) {
|
|
1653
|
+
const ts = ev.occurred_at;
|
|
1654
|
+
const user = ev.app_user_id;
|
|
1655
|
+
const props = compactProperties(ev.properties);
|
|
1656
|
+
const tail = props ? ` ${pc.dim(props)}` : "";
|
|
1657
|
+
return `${pc.dim(ts)} ${pc.cyan(ev.event_name)} ${pc.dim(`user=${user}`)}${tail}`;
|
|
1658
|
+
}
|
|
1659
|
+
function emitEvent(ev, json) {
|
|
1660
|
+
if (json) process.stdout.write(JSON.stringify(ev) + "\n");
|
|
1661
|
+
else console.log(formatEvent(ev));
|
|
1662
|
+
}
|
|
1663
|
+
function delay(ms, signal) {
|
|
1664
|
+
return new Promise((resolve) => {
|
|
1665
|
+
if (signal?.aborted) {
|
|
1666
|
+
resolve();
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
const timer = setTimeout(resolve, ms);
|
|
1670
|
+
if (signal) {
|
|
1671
|
+
const onAbort = () => {
|
|
1672
|
+
clearTimeout(timer);
|
|
1673
|
+
signal.removeEventListener("abort", onAbort);
|
|
1674
|
+
resolve();
|
|
1675
|
+
};
|
|
1676
|
+
signal.addEventListener("abort", onAbort);
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
async function logsTailCommand(opts = {}) {
|
|
1681
|
+
const projectId = await requireProjectId(process.cwd(), opts.project);
|
|
1682
|
+
const json = opts.json ?? false;
|
|
1683
|
+
const limit = opts.limit ?? DEFAULT_TAIL_LIMIT;
|
|
1684
|
+
const since = opts.since ?? (/* @__PURE__ */ new Date(Date.now() - 1440 * 60 * 1e3)).toISOString();
|
|
1685
|
+
if (!json) {
|
|
1686
|
+
console.log();
|
|
1687
|
+
console.log(pc.bold(" amba logs tail"));
|
|
1688
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1689
|
+
console.log();
|
|
1690
|
+
console.log(pc.dim(` Project: ${projectId}`));
|
|
1691
|
+
console.log(pc.dim(` Since: ${since}`));
|
|
1692
|
+
if (opts.eventName) console.log(pc.dim(` Event: ${opts.eventName}`));
|
|
1693
|
+
if (opts.userId) console.log(pc.dim(` User: ${opts.userId}`));
|
|
1694
|
+
console.log();
|
|
1695
|
+
}
|
|
1696
|
+
let page;
|
|
1697
|
+
try {
|
|
1698
|
+
page = await listProjectEvents(projectId, {
|
|
1699
|
+
since,
|
|
1700
|
+
eventName: opts.eventName,
|
|
1701
|
+
userId: opts.userId,
|
|
1702
|
+
limit
|
|
1703
|
+
});
|
|
1704
|
+
} catch (err) {
|
|
1705
|
+
if (err instanceof ApiClientError) console.error(pc.red(" ✗") + ` ${err.message}`);
|
|
1706
|
+
else if (err instanceof Error) console.error(pc.red(" ✗") + ` ${err.message}`);
|
|
1707
|
+
else console.error(pc.red(" ✗") + " Failed to fetch events");
|
|
1708
|
+
process.exit(1);
|
|
1709
|
+
}
|
|
1710
|
+
const initial = [...page.data].reverse();
|
|
1711
|
+
if (initial.length === 0 && !json) console.log(pc.dim(" (no events in window)"));
|
|
1712
|
+
else for (const ev of initial) emitEvent(ev, json);
|
|
1713
|
+
if (!opts.follow) {
|
|
1714
|
+
if (!json) console.log();
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
let lastTs = initial.length > 0 ? initial[initial.length - 1].occurred_at : since;
|
|
1718
|
+
const seenIds = new Set(initial.map((e) => e.id));
|
|
1719
|
+
const pollMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
1720
|
+
const maxIters = opts.maxFollowIterations ?? Infinity;
|
|
1721
|
+
const ac = new AbortController();
|
|
1722
|
+
let sigintHandler = null;
|
|
1723
|
+
if (!opts.signal) {
|
|
1724
|
+
sigintHandler = () => {
|
|
1725
|
+
ac.abort();
|
|
1726
|
+
};
|
|
1727
|
+
process.once("SIGINT", sigintHandler);
|
|
1728
|
+
}
|
|
1729
|
+
const followSignal = opts.signal ?? ac.signal;
|
|
1730
|
+
let iters = 0;
|
|
1731
|
+
while (!followSignal.aborted && iters < maxIters) {
|
|
1732
|
+
await delay(pollMs, followSignal);
|
|
1733
|
+
if (followSignal.aborted) break;
|
|
1734
|
+
iters++;
|
|
1735
|
+
let newPage;
|
|
1736
|
+
try {
|
|
1737
|
+
newPage = await listProjectEvents(projectId, {
|
|
1738
|
+
since: lastTs,
|
|
1739
|
+
eventName: opts.eventName,
|
|
1740
|
+
userId: opts.userId,
|
|
1741
|
+
limit
|
|
1742
|
+
});
|
|
1743
|
+
} catch (err) {
|
|
1744
|
+
if (err instanceof ApiClientError) console.error(pc.yellow(" !") + ` poll error: ${err.message}`);
|
|
1745
|
+
else if (err instanceof Error) console.error(pc.yellow(" !") + ` poll error: ${err.message}`);
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
const fresh = [...newPage.data].reverse().filter((e) => !seenIds.has(e.id));
|
|
1749
|
+
for (const ev of fresh) {
|
|
1750
|
+
seenIds.add(ev.id);
|
|
1751
|
+
emitEvent(ev, json);
|
|
1752
|
+
lastTs = ev.occurred_at;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
if (sigintHandler) process.removeListener("SIGINT", sigintHandler);
|
|
1756
|
+
if (!json) {
|
|
1757
|
+
console.log();
|
|
1758
|
+
console.log(pc.dim(" (follow stopped)"));
|
|
1759
|
+
console.log();
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
//#endregion
|
|
1763
|
+
//#region src/commands/seed.ts
|
|
1764
|
+
async function safeCreate(label, fn, result) {
|
|
1765
|
+
try {
|
|
1766
|
+
await fn();
|
|
1767
|
+
result.created.push(label);
|
|
1768
|
+
console.log(pc.green(" ✓") + ` ${label}`);
|
|
1769
|
+
} catch (err) {
|
|
1770
|
+
if (err instanceof ApiClientError && (err.statusCode === 409 || err.statusCode === 422)) {
|
|
1771
|
+
result.skipped.push(label);
|
|
1772
|
+
console.log(pc.dim(" -") + ` ${label} ` + pc.dim("(already exists)"));
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
const message = err instanceof Error ? err.message : "unknown error";
|
|
1776
|
+
result.failed.push({
|
|
1777
|
+
label,
|
|
1778
|
+
error: message
|
|
1779
|
+
});
|
|
1780
|
+
console.log(pc.red(" ✗") + ` ${label} — ${message}`);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
async function seedStarter(projectId, result) {
|
|
1784
|
+
await safeCreate("segment: active_users", () => createSegment(projectId, {
|
|
1785
|
+
name: "Active Users",
|
|
1786
|
+
description: "Users with at least one session in the last 7 days",
|
|
1787
|
+
is_active: true,
|
|
1788
|
+
rules: { all: [{
|
|
1789
|
+
property: "last_seen_at",
|
|
1790
|
+
op: "gte",
|
|
1791
|
+
value: "7d_ago"
|
|
1792
|
+
}] }
|
|
1793
|
+
}), result);
|
|
1794
|
+
await safeCreate("content library: daily_quotes", () => createContentLibrary(projectId, {
|
|
1795
|
+
slug: "daily_quotes",
|
|
1796
|
+
name: "Daily Quotes",
|
|
1797
|
+
description: "Sample starter quotes"
|
|
1798
|
+
}), result);
|
|
1799
|
+
await safeCreate("xp rule: session_start +10", () => createXpRule(projectId, {
|
|
1800
|
+
event_name: "session_start",
|
|
1801
|
+
amount: 10,
|
|
1802
|
+
description: "Award 10 XP for starting a session"
|
|
1803
|
+
}), result);
|
|
1804
|
+
}
|
|
1805
|
+
async function seedGamification(projectId, result) {
|
|
1806
|
+
await safeCreate("achievement: first_session", () => createAchievement(projectId, {
|
|
1807
|
+
code: "first_session",
|
|
1808
|
+
name: "First Session",
|
|
1809
|
+
description: "Complete your first session",
|
|
1810
|
+
criteria: {
|
|
1811
|
+
event_name: "session_start",
|
|
1812
|
+
count: 1
|
|
1813
|
+
},
|
|
1814
|
+
reward: { xp: 50 }
|
|
1815
|
+
}), result);
|
|
1816
|
+
await safeCreate("achievement: streak_7", () => createAchievement(projectId, {
|
|
1817
|
+
code: "streak_7",
|
|
1818
|
+
name: "Week Warrior",
|
|
1819
|
+
description: "Maintain a 7-day streak",
|
|
1820
|
+
criteria: {
|
|
1821
|
+
streak: "daily",
|
|
1822
|
+
days: 7
|
|
1823
|
+
},
|
|
1824
|
+
reward: { xp: 250 }
|
|
1825
|
+
}), result);
|
|
1826
|
+
await safeCreate("xp rule: session_complete +20", () => createXpRule(projectId, {
|
|
1827
|
+
event_name: "session_complete",
|
|
1828
|
+
amount: 20,
|
|
1829
|
+
description: "Award 20 XP for completing a session"
|
|
1830
|
+
}), result);
|
|
1831
|
+
await safeCreate("xp rule: share_action +15", () => createXpRule(projectId, {
|
|
1832
|
+
event_name: "share_action",
|
|
1833
|
+
amount: 15,
|
|
1834
|
+
description: "Award 15 XP for sharing"
|
|
1835
|
+
}), result);
|
|
1836
|
+
}
|
|
1837
|
+
async function seedContent(projectId, result) {
|
|
1838
|
+
let libraryId = null;
|
|
1839
|
+
await safeCreate("content library: affirmations", async () => {
|
|
1840
|
+
libraryId = (await createContentLibrary(projectId, {
|
|
1841
|
+
slug: "affirmations",
|
|
1842
|
+
name: "Affirmations",
|
|
1843
|
+
description: "Sample daily affirmations"
|
|
1844
|
+
})).data.id;
|
|
1845
|
+
}, result);
|
|
1846
|
+
if (libraryId) await safeCreate("content items x3", () => addContentItems(projectId, libraryId, { items: [
|
|
1847
|
+
{
|
|
1848
|
+
key: "aff_1",
|
|
1849
|
+
content: { text: "You are capable of amazing things." }
|
|
1850
|
+
},
|
|
1851
|
+
{
|
|
1852
|
+
key: "aff_2",
|
|
1853
|
+
content: { text: "Every step forward counts." }
|
|
1854
|
+
},
|
|
1855
|
+
{
|
|
1856
|
+
key: "aff_3",
|
|
1857
|
+
content: { text: "Today is a fresh start." }
|
|
1858
|
+
}
|
|
1859
|
+
] }), result);
|
|
1860
|
+
}
|
|
1861
|
+
async function seedCommand(opts = {}) {
|
|
1862
|
+
const projectId = await requireProjectId(process.cwd(), opts.project);
|
|
1863
|
+
const preset = opts.preset ?? "starter";
|
|
1864
|
+
console.log();
|
|
1865
|
+
console.log(pc.bold(` amba seed --preset=${preset}`));
|
|
1866
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1867
|
+
console.log();
|
|
1868
|
+
console.log(pc.dim(` Project: ${projectId}`));
|
|
1869
|
+
console.log();
|
|
1870
|
+
const result = {
|
|
1871
|
+
created: [],
|
|
1872
|
+
skipped: [],
|
|
1873
|
+
failed: []
|
|
1874
|
+
};
|
|
1875
|
+
switch (preset) {
|
|
1876
|
+
case "starter":
|
|
1877
|
+
await seedStarter(projectId, result);
|
|
1878
|
+
break;
|
|
1879
|
+
case "gamification":
|
|
1880
|
+
await seedGamification(projectId, result);
|
|
1881
|
+
break;
|
|
1882
|
+
case "content":
|
|
1883
|
+
await seedContent(projectId, result);
|
|
1884
|
+
break;
|
|
1885
|
+
default:
|
|
1886
|
+
console.log(pc.red(" ✗") + ` Unknown preset: ${preset}`);
|
|
1887
|
+
console.log(pc.dim(" Valid: starter, gamification, content"));
|
|
1888
|
+
console.log();
|
|
1889
|
+
process.exit(1);
|
|
1890
|
+
}
|
|
1891
|
+
console.log();
|
|
1892
|
+
console.log(pc.dim(` ${result.created.length} created · ${result.skipped.length} skipped · ${result.failed.length} failed`));
|
|
1893
|
+
console.log();
|
|
1894
|
+
if (result.failed.length > 0) process.exit(1);
|
|
1895
|
+
}
|
|
1896
|
+
//#endregion
|
|
1897
|
+
//#region src/commands/db.ts
|
|
1898
|
+
async function dbMigrateCommand(opts = {}) {
|
|
1899
|
+
const projectId = await requireProjectId(process.cwd(), opts.project);
|
|
1900
|
+
console.log();
|
|
1901
|
+
console.log(pc.bold(" amba db migrate"));
|
|
1902
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
1903
|
+
console.log();
|
|
1904
|
+
console.log(pc.dim(` Project: ${projectId}`));
|
|
1905
|
+
console.log();
|
|
1906
|
+
console.log(pc.dim(" Running tenant migrations..."));
|
|
1907
|
+
console.log();
|
|
1908
|
+
try {
|
|
1909
|
+
const workflowId = (await reprovisionProject(projectId)).data.workflowId;
|
|
1910
|
+
console.log(pc.green(" ✓") + " Reprovision workflow started");
|
|
1911
|
+
if (workflowId) console.log(pc.dim(` workflowId: ${workflowId}`));
|
|
1912
|
+
console.log();
|
|
1913
|
+
try {
|
|
1914
|
+
const status = await getProvisioningStatus(projectId);
|
|
1915
|
+
console.log(pc.dim(` Status: ${status.data.status}`));
|
|
1916
|
+
if (status.data.errorMessage) console.log(pc.yellow(" !") + ` ${status.data.errorMessage}`);
|
|
1917
|
+
} catch {}
|
|
1918
|
+
console.log();
|
|
1919
|
+
console.log(pc.dim(" Check again with: ") + pc.cyan(`amba projects show ${projectId}`));
|
|
1920
|
+
console.log();
|
|
1921
|
+
} catch (err) {
|
|
1922
|
+
if (err instanceof ApiClientError) if (err.statusCode === 404) console.log(pc.red(" ✗") + ` Project not found: ${projectId}`);
|
|
1923
|
+
else if (err.statusCode === 409) {
|
|
1924
|
+
console.log(pc.yellow(" !") + " Reprovision already in progress (or project is archived). " + pc.dim("Nothing to do."));
|
|
1925
|
+
console.log();
|
|
1926
|
+
return;
|
|
1927
|
+
} else console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
1928
|
+
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
1929
|
+
console.log();
|
|
1930
|
+
process.exit(1);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
//#endregion
|
|
1934
|
+
//#region src/commands/analytics.ts
|
|
1935
|
+
const PAGE_SIZE = 1e3;
|
|
1936
|
+
const PROGRESS_EVERY = 500;
|
|
1937
|
+
function csvEscape(value) {
|
|
1938
|
+
if (value === null || value === void 0) return "";
|
|
1939
|
+
let s;
|
|
1940
|
+
if (typeof value === "string") s = value;
|
|
1941
|
+
else if (typeof value === "number" || typeof value === "boolean") s = String(value);
|
|
1942
|
+
else s = JSON.stringify(value);
|
|
1943
|
+
if (s.includes("\"") || s.includes(",") || s.includes("\n") || s.includes("\r")) return `"${s.replace(/"/g, "\"\"")}"`;
|
|
1944
|
+
return s;
|
|
1945
|
+
}
|
|
1946
|
+
function csvRow(values) {
|
|
1947
|
+
return values.map(csvEscape).join(",") + "\n";
|
|
1948
|
+
}
|
|
1949
|
+
function makeSink(out) {
|
|
1950
|
+
if (!out) return {
|
|
1951
|
+
resolvedPath: null,
|
|
1952
|
+
sink: {
|
|
1953
|
+
write(chunk) {
|
|
1954
|
+
process.stdout.write(chunk);
|
|
1955
|
+
},
|
|
1956
|
+
async close() {}
|
|
1957
|
+
}
|
|
1958
|
+
};
|
|
1959
|
+
const resolvedPath = resolve(process.cwd(), out);
|
|
1960
|
+
const stream = createWriteStream(resolvedPath, { encoding: "utf-8" });
|
|
1961
|
+
return {
|
|
1962
|
+
sink: {
|
|
1963
|
+
write(chunk) {
|
|
1964
|
+
stream.write(chunk);
|
|
1965
|
+
},
|
|
1966
|
+
close() {
|
|
1967
|
+
return new Promise((res, rej) => {
|
|
1968
|
+
stream.end((err) => err ? rej(err) : res());
|
|
1969
|
+
});
|
|
1970
|
+
}
|
|
1971
|
+
},
|
|
1972
|
+
resolvedPath
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
function progress(rowsEmitted, force = false) {
|
|
1976
|
+
if (!force && rowsEmitted % PROGRESS_EVERY !== 0) return;
|
|
1977
|
+
if (!process.stderr.isTTY && !force) return;
|
|
1978
|
+
process.stderr.write(pc.dim(` … ${rowsEmitted} rows emitted\r`));
|
|
1979
|
+
}
|
|
1980
|
+
async function exportUsers(projectId, opts) {
|
|
1981
|
+
const fmt = opts.format ?? "csv";
|
|
1982
|
+
const { sink, resolvedPath } = makeSink(opts.out);
|
|
1983
|
+
let res;
|
|
1984
|
+
try {
|
|
1985
|
+
res = await streamUsersExport(projectId, {
|
|
1986
|
+
format: fmt,
|
|
1987
|
+
since: opts.since
|
|
1988
|
+
});
|
|
1989
|
+
} catch (err) {
|
|
1990
|
+
await sink.close();
|
|
1991
|
+
throw err;
|
|
1992
|
+
}
|
|
1993
|
+
const body = res.body;
|
|
1994
|
+
if (!body) {
|
|
1995
|
+
await sink.close();
|
|
1996
|
+
throw new Error("Server returned an empty users export stream");
|
|
1997
|
+
}
|
|
1998
|
+
let bytes = 0;
|
|
1999
|
+
let lines = 0;
|
|
2000
|
+
let pending = "";
|
|
2001
|
+
const reader = body.getReader();
|
|
2002
|
+
const decoder = new TextDecoder("utf-8");
|
|
2003
|
+
while (true) {
|
|
2004
|
+
const { value, done } = await reader.read();
|
|
2005
|
+
if (done) break;
|
|
2006
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
2007
|
+
bytes += chunk.length;
|
|
2008
|
+
pending += chunk;
|
|
2009
|
+
let idx;
|
|
2010
|
+
while ((idx = pending.indexOf("\n")) >= 0) {
|
|
2011
|
+
lines++;
|
|
2012
|
+
pending = pending.slice(idx + 1);
|
|
2013
|
+
if (lines % PROGRESS_EVERY === 0) progress(lines);
|
|
2014
|
+
}
|
|
2015
|
+
sink.write(chunk);
|
|
2016
|
+
}
|
|
2017
|
+
if (pending.length > 0) sink.write(pending);
|
|
2018
|
+
await sink.close();
|
|
2019
|
+
if (process.stderr.isTTY) process.stderr.write("\n");
|
|
2020
|
+
if (resolvedPath) {
|
|
2021
|
+
console.log(pc.green(" ✓") + ` Wrote ${resolvedPath}`);
|
|
2022
|
+
console.log(pc.dim(` ${lines} line${lines === 1 ? "" : "s"} (${bytes} bytes)`));
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
function eventToCsv(ev) {
|
|
2026
|
+
return csvRow([
|
|
2027
|
+
ev.id,
|
|
2028
|
+
ev.app_user_id,
|
|
2029
|
+
ev.event_name,
|
|
2030
|
+
ev.occurred_at,
|
|
2031
|
+
JSON.stringify(ev.properties ?? {})
|
|
2032
|
+
]);
|
|
2033
|
+
}
|
|
2034
|
+
async function exportEvents(projectId, opts) {
|
|
2035
|
+
const fmt = opts.format ?? "csv";
|
|
2036
|
+
const { sink, resolvedPath } = makeSink(opts.out);
|
|
2037
|
+
const since = opts.since ?? (/* @__PURE__ */ new Date(Date.now() - 1440 * 60 * 1e3)).toISOString();
|
|
2038
|
+
const until = opts.until ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2039
|
+
if (fmt === "csv") sink.write(csvRow([
|
|
2040
|
+
"id",
|
|
2041
|
+
"app_user_id",
|
|
2042
|
+
"event_name",
|
|
2043
|
+
"occurred_at",
|
|
2044
|
+
"properties"
|
|
2045
|
+
]));
|
|
2046
|
+
let cursor = null;
|
|
2047
|
+
let rows = 0;
|
|
2048
|
+
let pages = 0;
|
|
2049
|
+
const maxPages = opts.maxPages ?? Infinity;
|
|
2050
|
+
try {
|
|
2051
|
+
do {
|
|
2052
|
+
pages++;
|
|
2053
|
+
const page = await listProjectEvents(projectId, {
|
|
2054
|
+
since,
|
|
2055
|
+
until,
|
|
2056
|
+
limit: opts.limit ?? PAGE_SIZE,
|
|
2057
|
+
cursor: cursor ?? void 0
|
|
2058
|
+
});
|
|
2059
|
+
for (const ev of page.data) {
|
|
2060
|
+
if (fmt === "ndjson") sink.write(JSON.stringify(ev) + "\n");
|
|
2061
|
+
else sink.write(eventToCsv(ev));
|
|
2062
|
+
rows++;
|
|
2063
|
+
if (rows % PROGRESS_EVERY === 0) progress(rows);
|
|
2064
|
+
}
|
|
2065
|
+
cursor = page.next_cursor;
|
|
2066
|
+
if (pages >= maxPages) break;
|
|
2067
|
+
} while (cursor);
|
|
2068
|
+
} finally {
|
|
2069
|
+
await sink.close();
|
|
2070
|
+
}
|
|
2071
|
+
if (process.stderr.isTTY) process.stderr.write("\n");
|
|
2072
|
+
if (resolvedPath) {
|
|
2073
|
+
console.log(pc.green(" ✓") + ` Wrote ${resolvedPath}`);
|
|
2074
|
+
console.log(pc.dim(` ${rows} event${rows === 1 ? "" : "s"} across ${pages} page(s)`));
|
|
2075
|
+
} else if (rows > 0) process.stderr.write(pc.dim(` ${rows} event${rows === 1 ? "" : "s"} emitted\n`));
|
|
2076
|
+
}
|
|
2077
|
+
async function analyticsExportCommand(opts) {
|
|
2078
|
+
const projectId = await requireProjectId(process.cwd(), opts.project);
|
|
2079
|
+
if (opts.type !== "users" && opts.type !== "events") {
|
|
2080
|
+
console.log();
|
|
2081
|
+
console.log(pc.red(" ✗") + ` Unknown --type: ${String(opts.type)}`);
|
|
2082
|
+
console.log(pc.dim(" Valid: users, events"));
|
|
2083
|
+
console.log();
|
|
2084
|
+
process.exit(1);
|
|
2085
|
+
}
|
|
2086
|
+
if (opts.format && opts.format !== "csv" && opts.format !== "ndjson") {
|
|
2087
|
+
console.log();
|
|
2088
|
+
console.log(pc.red(" ✗") + ` Unknown --format: ${String(opts.format)}`);
|
|
2089
|
+
console.log(pc.dim(" Valid: csv, ndjson"));
|
|
2090
|
+
console.log();
|
|
2091
|
+
process.exit(1);
|
|
2092
|
+
}
|
|
2093
|
+
process.stderr.write("\n");
|
|
2094
|
+
process.stderr.write(pc.bold(` amba analytics export --type=${opts.type}`) + "\n");
|
|
2095
|
+
process.stderr.write(pc.dim(" ─────────────────────────────────") + "\n");
|
|
2096
|
+
process.stderr.write(pc.dim(` Project: ${projectId}`) + "\n");
|
|
2097
|
+
process.stderr.write("\n");
|
|
2098
|
+
try {
|
|
2099
|
+
if (opts.type === "users") await exportUsers(projectId, opts);
|
|
2100
|
+
else await exportEvents(projectId, opts);
|
|
2101
|
+
console.log();
|
|
2102
|
+
} catch (err) {
|
|
2103
|
+
if (err instanceof ApiClientError) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
2104
|
+
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
2105
|
+
console.log();
|
|
2106
|
+
process.exit(1);
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
//#endregion
|
|
2110
|
+
//#region src/commands/schema.ts
|
|
2111
|
+
const SCHEMAS = {
|
|
2112
|
+
achievements: {
|
|
2113
|
+
name: "achievements",
|
|
2114
|
+
description: "Achievement definitions — unlocked when criteria are met.",
|
|
2115
|
+
fields: {
|
|
2116
|
+
id: "string (uuid)",
|
|
2117
|
+
code: "string — unique key per project",
|
|
2118
|
+
name: "string",
|
|
2119
|
+
description: "string | null",
|
|
2120
|
+
criteria: "AchievementCriteria — event_name/count/props or custom",
|
|
2121
|
+
reward: "{ xp?: number; currency?: Record<string, number>; items?: string[] } | null",
|
|
2122
|
+
is_active: "boolean",
|
|
2123
|
+
created_at: "string (ISO 8601)"
|
|
2124
|
+
}
|
|
2125
|
+
},
|
|
2126
|
+
streaks: {
|
|
2127
|
+
name: "streaks",
|
|
2128
|
+
description: "Streak definitions (daily, weekly, custom cadences).",
|
|
2129
|
+
fields: {
|
|
2130
|
+
id: "string (uuid)",
|
|
2131
|
+
code: "string",
|
|
2132
|
+
name: "string",
|
|
2133
|
+
description: "string | null",
|
|
2134
|
+
cadence: "'daily' | 'weekly'",
|
|
2135
|
+
grace_period_days: "number",
|
|
2136
|
+
is_active: "boolean",
|
|
2137
|
+
created_at: "string (ISO 8601)"
|
|
2138
|
+
}
|
|
2139
|
+
},
|
|
2140
|
+
xp: {
|
|
2141
|
+
name: "xp",
|
|
2142
|
+
description: "XP rules — award XP on matching events.",
|
|
2143
|
+
fields: {
|
|
2144
|
+
id: "string (uuid)",
|
|
2145
|
+
event_name: "string",
|
|
2146
|
+
amount: "number",
|
|
2147
|
+
description: "string | null",
|
|
2148
|
+
created_at: "string (ISO 8601)"
|
|
2149
|
+
}
|
|
2150
|
+
},
|
|
2151
|
+
leaderboards: {
|
|
2152
|
+
name: "leaderboards",
|
|
2153
|
+
description: "Leaderboard definitions and entries.",
|
|
2154
|
+
fields: {
|
|
2155
|
+
id: "string (uuid)",
|
|
2156
|
+
code: "string",
|
|
2157
|
+
name: "string",
|
|
2158
|
+
metric: "'xp' | 'streak' | 'custom'",
|
|
2159
|
+
period: "'all_time' | 'daily' | 'weekly' | 'monthly'",
|
|
2160
|
+
is_active: "boolean",
|
|
2161
|
+
created_at: "string (ISO 8601)"
|
|
2162
|
+
}
|
|
2163
|
+
},
|
|
2164
|
+
challenges: {
|
|
2165
|
+
name: "challenges",
|
|
2166
|
+
description: "Time-boxed user goals.",
|
|
2167
|
+
fields: {
|
|
2168
|
+
id: "string (uuid)",
|
|
2169
|
+
code: "string",
|
|
2170
|
+
name: "string",
|
|
2171
|
+
goal_type: "'event_count' | 'xp_earned' | 'streak_maintained'",
|
|
2172
|
+
goal_value: "number",
|
|
2173
|
+
starts_at: "string (ISO 8601)",
|
|
2174
|
+
ends_at: "string (ISO 8601)",
|
|
2175
|
+
reward: "{ xp?: number; items?: string[] } | null",
|
|
2176
|
+
created_at: "string (ISO 8601)"
|
|
2177
|
+
}
|
|
2178
|
+
},
|
|
2179
|
+
content: {
|
|
2180
|
+
name: "content",
|
|
2181
|
+
description: "Content libraries, items, and schedules.",
|
|
2182
|
+
fields: {
|
|
2183
|
+
library_id: "string (uuid)",
|
|
2184
|
+
slug: "string — unique per project",
|
|
2185
|
+
name: "string",
|
|
2186
|
+
description: "string | null",
|
|
2187
|
+
item: "{ key: string; content: unknown; locale?: string }",
|
|
2188
|
+
schedule: "{ library_id: string; cron: string; channel: string; target?: SegmentTarget }",
|
|
2189
|
+
created_at: "string (ISO 8601)"
|
|
2190
|
+
}
|
|
2191
|
+
},
|
|
2192
|
+
segments: {
|
|
2193
|
+
name: "segments",
|
|
2194
|
+
description: "User segments — predicate-based cohorts.",
|
|
2195
|
+
fields: {
|
|
2196
|
+
id: "string (uuid)",
|
|
2197
|
+
name: "string",
|
|
2198
|
+
description: "string | null",
|
|
2199
|
+
rules: "SegmentRule tree (all/any + property/cohort/date operators)",
|
|
2200
|
+
is_active: "boolean",
|
|
2201
|
+
created_at: "string (ISO 8601)"
|
|
2202
|
+
}
|
|
2203
|
+
},
|
|
2204
|
+
push: {
|
|
2205
|
+
name: "push",
|
|
2206
|
+
description: "Push campaigns and deliveries.",
|
|
2207
|
+
fields: {
|
|
2208
|
+
campaign_id: "string (uuid)",
|
|
2209
|
+
name: "string",
|
|
2210
|
+
title: "string",
|
|
2211
|
+
body: "string",
|
|
2212
|
+
schedule_cron: "string | null",
|
|
2213
|
+
segment_id: "string | null — null = all users",
|
|
2214
|
+
data: "Record<string, unknown> | null",
|
|
2215
|
+
created_at: "string (ISO 8601)"
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
function renderJson(domains) {
|
|
2220
|
+
return JSON.stringify(domains, null, 2) + "\n";
|
|
2221
|
+
}
|
|
2222
|
+
function renderTypescript(domains) {
|
|
2223
|
+
const lines = [
|
|
2224
|
+
"// Auto-generated by `amba schema export --format=typescript`.",
|
|
2225
|
+
"// For the canonical types, import from the @layers/amba-client SDK.",
|
|
2226
|
+
""
|
|
2227
|
+
];
|
|
2228
|
+
for (const d of domains) {
|
|
2229
|
+
lines.push(`// ${d.description}`);
|
|
2230
|
+
lines.push(`export interface ${pascal(d.name)} {`);
|
|
2231
|
+
for (const [field, type] of Object.entries(d.fields)) {
|
|
2232
|
+
lines.push(` /** ${type} */`);
|
|
2233
|
+
lines.push(` ${field}: unknown;`);
|
|
2234
|
+
}
|
|
2235
|
+
lines.push("}");
|
|
2236
|
+
lines.push("");
|
|
2237
|
+
}
|
|
2238
|
+
return lines.join("\n");
|
|
2239
|
+
}
|
|
2240
|
+
function pascal(s) {
|
|
2241
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
2242
|
+
}
|
|
2243
|
+
async function schemaExportCommand(opts) {
|
|
2244
|
+
const domain = opts.domain;
|
|
2245
|
+
const format = opts.format ?? "json";
|
|
2246
|
+
const available = Object.keys(SCHEMAS);
|
|
2247
|
+
if (domain !== "all" && !available.includes(domain)) {
|
|
2248
|
+
console.log();
|
|
2249
|
+
console.log(pc.red(" ✗") + ` Unknown domain: ${domain}`);
|
|
2250
|
+
console.log(pc.dim(` Valid: all, ${available.join(", ")}`));
|
|
2251
|
+
console.log();
|
|
2252
|
+
process.exit(1);
|
|
2253
|
+
}
|
|
2254
|
+
if (format !== "json" && format !== "typescript") {
|
|
2255
|
+
console.log();
|
|
2256
|
+
console.log(pc.red(" ✗") + ` Unknown --format: ${String(format)}`);
|
|
2257
|
+
console.log(pc.dim(" Valid: json, typescript"));
|
|
2258
|
+
console.log();
|
|
2259
|
+
process.exit(1);
|
|
2260
|
+
}
|
|
2261
|
+
const selected = domain === "all" ? Object.values(SCHEMAS) : [SCHEMAS[domain]];
|
|
2262
|
+
const output = format === "json" ? renderJson(selected) : renderTypescript(selected);
|
|
2263
|
+
process.stdout.write(output);
|
|
2264
|
+
}
|
|
2265
|
+
//#endregion
|
|
2266
|
+
//#region src/bundle.ts
|
|
2267
|
+
/**
|
|
2268
|
+
* Customer-function bundling for `amba functions deploy`.
|
|
2269
|
+
*
|
|
2270
|
+
* Uses esbuild (the Workers ecosystem bundler-of-record). The shared
|
|
2271
|
+
* runtime stdlib is marked `external` so customer bundles don't
|
|
2272
|
+
* re-include megabytes of `@anthropic-ai/sdk`, `postgres`, `zod`, etc.;
|
|
2273
|
+
* these resolve at dispatch time via platform-level bindings.
|
|
2274
|
+
*
|
|
2275
|
+
* Two checks gate the bundle before upload:
|
|
2276
|
+
* 1. Pre-upload size check against `BUNDLE_MAX_SIZE_BYTES` (8 MB
|
|
2277
|
+
* default — the platform's 10 MB compressed cap minus 2 MB
|
|
2278
|
+
* headroom) with a clear error pointing at the externalization
|
|
2279
|
+
* config.
|
|
2280
|
+
* 2. Bundle-shape report — the CLI prints what's externalized vs
|
|
2281
|
+
* bundled at deploy time so size issues are debuggable.
|
|
2282
|
+
*/
|
|
2283
|
+
/**
|
|
2284
|
+
* Modules customer code MUST externalize. The runtime exposes these as
|
|
2285
|
+
* platform-level bindings; bundling them per-script wastes hundreds of
|
|
2286
|
+
* KB to MBs and quickly hits the script-size cap.
|
|
2287
|
+
*/
|
|
2288
|
+
const RUNTIME_STDLIB_EXTERNALS = [
|
|
2289
|
+
"@layers/amba-functions",
|
|
2290
|
+
"@layers/amba-api-middleware",
|
|
2291
|
+
"@anthropic-ai/sdk",
|
|
2292
|
+
"postgres",
|
|
2293
|
+
"zod"
|
|
2294
|
+
];
|
|
2295
|
+
var BundleSizeError = class extends Error {
|
|
2296
|
+
sizeBytes;
|
|
2297
|
+
maxBytes;
|
|
2298
|
+
constructor(sizeBytes, maxBytes) {
|
|
2299
|
+
super(`Function bundle is ${formatBytes$1(sizeBytes)} which exceeds the ${formatBytes$1(maxBytes)} cap. Externalize heavy dependencies via the runtime stdlib (see RUNTIME_STDLIB_EXTERNALS) or split your function into smaller pieces.`);
|
|
2300
|
+
this.sizeBytes = sizeBytes;
|
|
2301
|
+
this.maxBytes = maxBytes;
|
|
2302
|
+
this.name = "BundleSizeError";
|
|
2303
|
+
}
|
|
2304
|
+
};
|
|
2305
|
+
/**
|
|
2306
|
+
* Bundle a customer function file for upload to a Workers dispatch
|
|
2307
|
+
* namespace. Returns the bundled code + metadata; throws
|
|
2308
|
+
* `BundleSizeError` when the compressed size exceeds the cap so the CLI
|
|
2309
|
+
* can surface a clear error.
|
|
2310
|
+
*/
|
|
2311
|
+
async function bundleFunction(options) {
|
|
2312
|
+
if (!(await stat(options.entryPoint).catch(() => null))?.isFile()) throw new Error(`Entry point not found or not a file: ${options.entryPoint}`);
|
|
2313
|
+
const externals = [...RUNTIME_STDLIB_EXTERNALS, ...options.extraExternals ?? []];
|
|
2314
|
+
const output = (await build({
|
|
2315
|
+
entryPoints: [options.entryPoint],
|
|
2316
|
+
bundle: true,
|
|
2317
|
+
format: "esm",
|
|
2318
|
+
platform: "browser",
|
|
2319
|
+
target: "es2022",
|
|
2320
|
+
external: externals,
|
|
2321
|
+
minify: true,
|
|
2322
|
+
sourcemap: options.sourcemap ?? "inline",
|
|
2323
|
+
write: false,
|
|
2324
|
+
conditions: [
|
|
2325
|
+
"workerd",
|
|
2326
|
+
"worker",
|
|
2327
|
+
"browser",
|
|
2328
|
+
"import"
|
|
2329
|
+
],
|
|
2330
|
+
metafile: true
|
|
2331
|
+
})).outputFiles?.[0];
|
|
2332
|
+
if (!output) throw new Error("esbuild produced no output");
|
|
2333
|
+
const code = new TextDecoder().decode(output.contents);
|
|
2334
|
+
const uncompressedSize = output.contents.byteLength;
|
|
2335
|
+
const compressedSize = await gzipSizeOf(output.contents);
|
|
2336
|
+
const max = options.maxSizeBytes ?? 8388608;
|
|
2337
|
+
if (compressedSize > max) throw new BundleSizeError(compressedSize, max);
|
|
2338
|
+
return {
|
|
2339
|
+
code,
|
|
2340
|
+
sha256: await hashSha256Hex(output.contents),
|
|
2341
|
+
compressedSize,
|
|
2342
|
+
uncompressedSize,
|
|
2343
|
+
externals
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
/**
|
|
2347
|
+
* Print the bundle's externalization report to stdout. Intended to be
|
|
2348
|
+
* called from `amba functions deploy` after bundling so customers can
|
|
2349
|
+
* see what got externalized vs included at a glance.
|
|
2350
|
+
*/
|
|
2351
|
+
function printBundleReport(bundle) {
|
|
2352
|
+
console.log();
|
|
2353
|
+
console.log(pc.dim(" Bundle:"));
|
|
2354
|
+
console.log(pc.dim(" size: ") + `${formatBytes$1(bundle.compressedSize)} compressed ` + pc.dim(`(${formatBytes$1(bundle.uncompressedSize)} raw)`));
|
|
2355
|
+
console.log(pc.dim(" sha: ") + bundle.sha256.slice(0, 16) + pc.dim("…"));
|
|
2356
|
+
console.log(pc.dim(" externalized:"));
|
|
2357
|
+
for (const e of bundle.externals) console.log(pc.dim(" • ") + e);
|
|
2358
|
+
console.log();
|
|
2359
|
+
}
|
|
2360
|
+
async function gzipSizeOf(bytes) {
|
|
2361
|
+
const { gzipSync } = await import("node:zlib");
|
|
2362
|
+
return gzipSync(bytes, { level: 9 }).byteLength;
|
|
2363
|
+
}
|
|
2364
|
+
async function hashSha256Hex(bytes) {
|
|
2365
|
+
const { createHash } = await import("node:crypto");
|
|
2366
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
2367
|
+
}
|
|
2368
|
+
function formatBytes$1(n) {
|
|
2369
|
+
if (n < 1024) return `${n}B`;
|
|
2370
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
2371
|
+
return `${(n / 1024 / 1024).toFixed(2)}MB`;
|
|
2372
|
+
}
|
|
2373
|
+
//#endregion
|
|
2374
|
+
//#region src/project-config.ts
|
|
2375
|
+
/**
|
|
2376
|
+
* Local project config loader.
|
|
2377
|
+
*
|
|
2378
|
+
* `amba init` writes `.env.local` with `AMBA_PROJECT_ID` + `AMBA_API_KEY`.
|
|
2379
|
+
* Subsequent commands resolve the active project by reading `.env.local`
|
|
2380
|
+
* (or the OS env if exported); fail with a clear "run amba init first"
|
|
2381
|
+
* error if neither is set.
|
|
2382
|
+
*
|
|
2383
|
+
* Kept tiny on purpose — the CLI's full config story (per-environment
|
|
2384
|
+
* dev/prod selection) is a v2 follow-up; v1 just needs project id.
|
|
2385
|
+
*/
|
|
2386
|
+
const ENV_LOCAL_FILES = [".env.local", ".env"];
|
|
2387
|
+
async function loadProjectConfig(cwd = process.cwd()) {
|
|
2388
|
+
let projectId = process.env["AMBA_PROJECT_ID"];
|
|
2389
|
+
let apiUrl = process.env["AMBA_API_URL"];
|
|
2390
|
+
if (!projectId || !apiUrl) for (const filename of ENV_LOCAL_FILES) {
|
|
2391
|
+
const content = await readFile(join(cwd, filename), "utf-8").catch(() => null);
|
|
2392
|
+
if (!content) continue;
|
|
2393
|
+
const parsed = parseEnv(content);
|
|
2394
|
+
if (!projectId) projectId = parsed["AMBA_PROJECT_ID"];
|
|
2395
|
+
if (!apiUrl) apiUrl = parsed["AMBA_API_URL"];
|
|
2396
|
+
if (projectId && apiUrl) break;
|
|
2397
|
+
}
|
|
2398
|
+
if (!projectId) throw new Error(`AMBA_PROJECT_ID not found. Run ${pc.cyan("amba init")} or set it in .env.local.`);
|
|
2399
|
+
return {
|
|
2400
|
+
projectId,
|
|
2401
|
+
apiUrl: apiUrl ?? "https://api.amba.dev"
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
function parseEnv(content) {
|
|
2405
|
+
const out = {};
|
|
2406
|
+
for (const rawLine of content.split("\n")) {
|
|
2407
|
+
const line = rawLine.trim();
|
|
2408
|
+
if (!line || line.startsWith("#")) continue;
|
|
2409
|
+
const eq = line.indexOf("=");
|
|
2410
|
+
if (eq === -1) continue;
|
|
2411
|
+
const key = line.slice(0, eq).trim();
|
|
2412
|
+
let value = line.slice(eq + 1).trim();
|
|
2413
|
+
if (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1);
|
|
2414
|
+
out[key] = value;
|
|
2415
|
+
}
|
|
2416
|
+
return out;
|
|
2417
|
+
}
|
|
2418
|
+
//#endregion
|
|
2419
|
+
//#region src/commands/functions.ts
|
|
2420
|
+
/**
|
|
2421
|
+
* `amba functions ...` commands.
|
|
2422
|
+
*
|
|
2423
|
+
* `deploy` bundles the entry file with esbuild (externalizing the runtime
|
|
2424
|
+
* stdlib + size-checking before upload), then POSTs the bundle to the
|
|
2425
|
+
* platform API. The server resolves bindings, uploads the script, and
|
|
2426
|
+
* records the deployment in a single round-trip. The other subcommands
|
|
2427
|
+
* are thin shells over the admin API helpers in `api-client.ts`.
|
|
2428
|
+
*/
|
|
2429
|
+
async function functionsDeployCommand(entryPoint, options = {}) {
|
|
2430
|
+
const projectId = (await loadProjectConfig()).projectId;
|
|
2431
|
+
const functionName = options.name ?? basename(entryPoint).replace(/\.[^.]+$/, "");
|
|
2432
|
+
validateFunctionName(functionName);
|
|
2433
|
+
const rateLimit = parseRateLimitFlags(options);
|
|
2434
|
+
console.log();
|
|
2435
|
+
console.log(pc.bold(` amba functions deploy ${pc.cyan(functionName)}`));
|
|
2436
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
2437
|
+
console.log();
|
|
2438
|
+
console.log(pc.dim(" Bundling…"));
|
|
2439
|
+
const bundle = await bundleFunction({ entryPoint });
|
|
2440
|
+
printBundleReport(bundle);
|
|
2441
|
+
if (options.dryRun) {
|
|
2442
|
+
console.log(pc.yellow(" ! Dry run — skipping API upload."));
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
console.log(pc.dim(" Uploading…"));
|
|
2446
|
+
const result = await deployFunctionViaApi(projectId, {
|
|
2447
|
+
name: functionName,
|
|
2448
|
+
bundleCode: bundle.code,
|
|
2449
|
+
rate_limit: rateLimit
|
|
2450
|
+
});
|
|
2451
|
+
console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version} (${result.data.cf_script_name})`)}`);
|
|
2452
|
+
console.log(pc.green(" ✓") + ` URL: ${pc.underline(result.fn_url)}`);
|
|
2453
|
+
if (rateLimit) console.log(pc.dim(` Rate limit: ${rateLimit.max} per ${rateLimit.window} (key=${rateLimit.key}) — enforced pre-dispatch`));
|
|
2454
|
+
console.log();
|
|
2455
|
+
}
|
|
2456
|
+
async function functionsListCommand() {
|
|
2457
|
+
const res = await listFunctionDeployments((await loadProjectConfig()).projectId, { activeOnly: true });
|
|
2458
|
+
console.log();
|
|
2459
|
+
if (res.data.length === 0) {
|
|
2460
|
+
console.log(pc.dim(" No functions deployed."));
|
|
2461
|
+
console.log();
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
for (const d of res.data) console.log(` ${pc.bold(d.name)} ${pc.dim("v" + d.version)} ` + pc.dim(`sha=${d.bundle_sha.slice(0, 12)}… ${d.created_at}`));
|
|
2465
|
+
console.log();
|
|
2466
|
+
}
|
|
2467
|
+
async function functionsDeleteCommand(name, options = {}) {
|
|
2468
|
+
validateFunctionName(name);
|
|
2469
|
+
if (!options.confirm || options.confirm !== name) throw new Error(`Delete is destructive. Pass --confirm ${name} to proceed. Customer Workers calling this function will start 404'ing immediately.`);
|
|
2470
|
+
const cascade = (await deleteFunctionViaApi((await loadProjectConfig()).projectId, name, { confirm: name })).data.cascade;
|
|
2471
|
+
console.log(pc.green(" ✓") + ` Deleted ${pc.bold(name)}.`);
|
|
2472
|
+
console.log(pc.dim(` Cascade: cf_dispatch_script_deleted=${cascade.cf_dispatch_script_deleted ?? false}, function_deployments_marked_disabled=${cascade.function_deployments_marked_disabled ?? 0}`));
|
|
2473
|
+
}
|
|
2474
|
+
async function functionsScheduleCommand(name, cron, options = {}) {
|
|
2475
|
+
const projectConfig = await loadProjectConfig();
|
|
2476
|
+
validateFunctionName(name);
|
|
2477
|
+
const timezone = options.tz ?? "UTC";
|
|
2478
|
+
const res = await scheduleFunction(projectConfig.projectId, {
|
|
2479
|
+
name,
|
|
2480
|
+
cron,
|
|
2481
|
+
timezone
|
|
2482
|
+
});
|
|
2483
|
+
console.log();
|
|
2484
|
+
console.log(pc.green(" ✓") + ` Scheduled ${pc.bold(name)} — ${pc.cyan(cron)} ${pc.dim(`(${timezone})`)}`);
|
|
2485
|
+
console.log(pc.dim(` schedule_id: ${res.data.schedule_id}`));
|
|
2486
|
+
console.log();
|
|
2487
|
+
}
|
|
2488
|
+
/**
|
|
2489
|
+
* `amba functions dev` — not configured in this release. Use
|
|
2490
|
+
* `amba functions deploy <file>` to deploy via the platform API.
|
|
2491
|
+
*/
|
|
2492
|
+
async function functionsDevCommand(_entryPoint) {
|
|
2493
|
+
console.error(pc.red(" Error: `amba functions dev` is not available in this release."));
|
|
2494
|
+
console.error(pc.dim(" Use `amba functions deploy <file>` to deploy via the platform API."));
|
|
2495
|
+
process.exit(1);
|
|
2496
|
+
}
|
|
2497
|
+
/**
|
|
2498
|
+
* `amba functions consume <queue> <function>` — bind a function as the
|
|
2499
|
+
* consumer for a queue. Customers send to a queue with `ctx.queue.send`;
|
|
2500
|
+
* the genericQueueJobWorkflow looks up the binding and invokes the
|
|
2501
|
+
* bound function with the payload.
|
|
2502
|
+
*
|
|
2503
|
+
* Single binding per (project, queue). Re-running with a different
|
|
2504
|
+
* function-name overwrites — same upsert semantics as `amba secrets set`.
|
|
2505
|
+
*/
|
|
2506
|
+
async function functionsConsumeCommand(queueName, functionName, options = {}) {
|
|
2507
|
+
validateFunctionName(functionName);
|
|
2508
|
+
validateFunctionName(queueName);
|
|
2509
|
+
const res = await upsertQueueBinding((await loadProjectConfig()).projectId, {
|
|
2510
|
+
queue_name: queueName,
|
|
2511
|
+
function_name: functionName,
|
|
2512
|
+
status: options.paused ? "paused" : "active"
|
|
2513
|
+
});
|
|
2514
|
+
console.log();
|
|
2515
|
+
console.log(pc.green(" ✓") + ` Queue ${pc.cyan(queueName)} → function ${pc.cyan(functionName)} ${pc.dim(`(${res.data.status})`)}`);
|
|
2516
|
+
console.log();
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* `amba functions consumers list` / `amba functions consumers unbind`
|
|
2520
|
+
* sub-tree. Mirrors the `amba secrets list` shape so the CLI surface
|
|
2521
|
+
* stays consistent.
|
|
2522
|
+
*/
|
|
2523
|
+
async function functionsConsumersListCommand() {
|
|
2524
|
+
const res = await listQueueBindings((await loadProjectConfig()).projectId);
|
|
2525
|
+
console.log();
|
|
2526
|
+
if (res.data.length === 0) {
|
|
2527
|
+
console.log(pc.dim(" No queue bindings configured."));
|
|
2528
|
+
console.log();
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
for (const b of res.data) {
|
|
2532
|
+
const status = b.status === "active" ? pc.green("active") : pc.yellow("paused");
|
|
2533
|
+
console.log(` ${pc.bold(b.queue_name)} → ${pc.cyan(b.function_name)} ${status}`);
|
|
2534
|
+
}
|
|
2535
|
+
console.log();
|
|
2536
|
+
}
|
|
2537
|
+
async function functionsConsumersUnbindCommand(queueName) {
|
|
2538
|
+
validateFunctionName(queueName);
|
|
2539
|
+
await deleteQueueBinding((await loadProjectConfig()).projectId, queueName);
|
|
2540
|
+
console.log(pc.green(" ✓") + ` Unbound queue ${pc.cyan(queueName)}.`);
|
|
2541
|
+
}
|
|
2542
|
+
function validateFunctionName(name) {
|
|
2543
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(name)) throw new Error(`Invalid function name '${name}'. Must match /^[a-z][a-z0-9_-]*$/ (lowercase ASCII, digits, underscore or hyphen; must start with a letter).`);
|
|
2544
|
+
if (name.length > 58) throw new Error("Function name must be 58 characters or fewer.");
|
|
2545
|
+
}
|
|
2546
|
+
/**
|
|
2547
|
+
* Resolve the optional rate-limit config from CLI flags. Returns the
|
|
2548
|
+
* validated config if all three sub-flags are set, `null` if all three
|
|
2549
|
+
* are absent, or throws if a partial set was supplied.
|
|
2550
|
+
*
|
|
2551
|
+
* The "all-or-nothing" rule is intentional — defaulting any sub-flag
|
|
2552
|
+
* is too easy to mis-set. A customer who types `--rate-limit-max 20`
|
|
2553
|
+
* expecting "use a default 60s window with ip key" would silently get
|
|
2554
|
+
* NO rate limit instead, which is the wrong default for a security-
|
|
2555
|
+
* adjacent flag. Forcing all three to be explicit means the failure
|
|
2556
|
+
* mode is "fail loud at deploy" not "silently no rate limit."
|
|
2557
|
+
*/
|
|
2558
|
+
function parseRateLimitFlags(options) {
|
|
2559
|
+
const window = options.rateLimitWindow;
|
|
2560
|
+
const max = options.rateLimitMax;
|
|
2561
|
+
const key = options.rateLimitKey;
|
|
2562
|
+
if (window === void 0 && max === void 0 && key === void 0) return null;
|
|
2563
|
+
const missing = [];
|
|
2564
|
+
if (window === void 0) missing.push("--rate-limit-window");
|
|
2565
|
+
if (max === void 0) missing.push("--rate-limit-max");
|
|
2566
|
+
if (key === void 0) missing.push("--rate-limit-key");
|
|
2567
|
+
if (missing.length > 0) throw new Error(`Rate-limit flags must be supplied together. Missing: ${missing.join(", ")}. Either provide all three or omit all three (no rate limit).`);
|
|
2568
|
+
const validated = validateRateLimitConfig({
|
|
2569
|
+
window,
|
|
2570
|
+
max,
|
|
2571
|
+
key
|
|
2572
|
+
});
|
|
2573
|
+
if ("error" in validated) throw new Error(`Rate-limit config invalid: ${validated.error}`);
|
|
2574
|
+
return validated;
|
|
2575
|
+
}
|
|
2576
|
+
//#endregion
|
|
2577
|
+
//#region src/commands/functions-logs.ts
|
|
2578
|
+
/**
|
|
2579
|
+
* `amba functions logs <name>` — read recent log events.
|
|
2580
|
+
*
|
|
2581
|
+
* Two modes:
|
|
2582
|
+
* - One-shot (default): fetch events in `[since, until)` and exit.
|
|
2583
|
+
* - `--tail`: print the last hour's events, then poll every 3s for
|
|
2584
|
+
* new events past the highest seen `EventTimestampMs`. Ctrl+C to stop.
|
|
2585
|
+
*
|
|
2586
|
+
* Output formatting:
|
|
2587
|
+
* - `--json`: NDJSON to stdout (one event per line) so `| jq` works.
|
|
2588
|
+
* - default: human-readable lines:
|
|
2589
|
+
* 2026-05-08T12:34:56.789Z scan-letter-v3 [info] "scanning"
|
|
2590
|
+
* 2026-05-08T12:34:57.012Z scan-letter-v3 [exception] TypeError: …
|
|
2591
|
+
*
|
|
2592
|
+
* Server-side scoping is enforced — the API filters by ScriptName
|
|
2593
|
+
* prefix so a developer can never read another tenant's logs.
|
|
2594
|
+
*/
|
|
2595
|
+
const TAIL_POLL_MS = 3e3;
|
|
2596
|
+
async function functionsLogsCommand(functionName, options = {}) {
|
|
2597
|
+
const projectConfig = await loadProjectConfig();
|
|
2598
|
+
if (options.tail) {
|
|
2599
|
+
await runTail(projectConfig.projectId, functionName, options);
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
const res = await getFunctionLogs(projectConfig.projectId, functionName, {
|
|
2603
|
+
since: options.since,
|
|
2604
|
+
until: options.until,
|
|
2605
|
+
limit: options.limit
|
|
2606
|
+
});
|
|
2607
|
+
printEvents(res.data.events, options.json);
|
|
2608
|
+
if (res.data.truncated) if (options.json) process.stderr.write(`{"truncated":true}\n`);
|
|
2609
|
+
else console.error(pc.yellow(` ! Result truncated at limit; narrow --since / --until or raise --limit.`));
|
|
2610
|
+
}
|
|
2611
|
+
async function runTail(projectId, functionName, options) {
|
|
2612
|
+
let sinceMs = options.since ? Date.parse(options.since) : Date.now() - 3600 * 1e3;
|
|
2613
|
+
if (Number.isNaN(sinceMs)) throw new Error("--since must be a valid ISO 8601 timestamp");
|
|
2614
|
+
if (!options.json) console.error(pc.dim(` Tailing ${functionName} — Ctrl+C to stop. Initial backfill from ${new Date(sinceMs).toISOString()}.`));
|
|
2615
|
+
let highWaterMs = sinceMs;
|
|
2616
|
+
for (;;) {
|
|
2617
|
+
let result;
|
|
2618
|
+
try {
|
|
2619
|
+
result = await getFunctionLogs(projectId, functionName, {
|
|
2620
|
+
since: new Date(highWaterMs).toISOString(),
|
|
2621
|
+
limit: options.limit ?? 200
|
|
2622
|
+
});
|
|
2623
|
+
} catch (err) {
|
|
2624
|
+
console.error(pc.yellow(` ! tail fetch failed: `) + (err instanceof Error ? err.message : String(err)));
|
|
2625
|
+
await sleep$1(TAIL_POLL_MS);
|
|
2626
|
+
continue;
|
|
2627
|
+
}
|
|
2628
|
+
if (result.data.events.length > 0) {
|
|
2629
|
+
printEvents(result.data.events, options.json);
|
|
2630
|
+
highWaterMs = result.data.events.reduce((m, e) => Math.max(m, e.EventTimestampMs ?? 0), highWaterMs) + 1;
|
|
2631
|
+
}
|
|
2632
|
+
await sleep$1(TAIL_POLL_MS);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
function printEvents(events, asJson) {
|
|
2636
|
+
const ordered = [...events].sort((a, b) => (a.EventTimestampMs ?? 0) - (b.EventTimestampMs ?? 0));
|
|
2637
|
+
if (asJson) {
|
|
2638
|
+
for (const e of ordered) process.stdout.write(JSON.stringify(e) + "\n");
|
|
2639
|
+
return;
|
|
2640
|
+
}
|
|
2641
|
+
for (const e of ordered) {
|
|
2642
|
+
const ts = e.EventTimestampMs ? new Date(e.EventTimestampMs).toISOString() : "????-??-??T??:??:??Z";
|
|
2643
|
+
const script = e.ScriptName ?? "<unknown-script>";
|
|
2644
|
+
if (e.Logs && e.Logs.length > 0) for (const logLine of e.Logs) {
|
|
2645
|
+
const level = renderLevel(logLine.Level);
|
|
2646
|
+
const msg = renderMessage(logLine.Message);
|
|
2647
|
+
console.log(`${pc.dim(ts)} ${pc.cyan(script)} ${level} ${msg}`);
|
|
2648
|
+
}
|
|
2649
|
+
if (e.Exceptions && e.Exceptions.length > 0) for (const ex of e.Exceptions) console.log(`${pc.dim(ts)} ${pc.cyan(script)} ${pc.red("[exception]")} ${ex.Name ?? "Error"}: ${ex.Message ?? ""}`);
|
|
2650
|
+
if ((!e.Logs || e.Logs.length === 0) && (!e.Exceptions || e.Exceptions.length === 0) && e.Outcome && e.Outcome !== "ok") console.log(`${pc.dim(ts)} ${pc.cyan(script)} ${pc.yellow("[outcome]")} ${e.Outcome}`);
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
function renderLevel(level) {
|
|
2654
|
+
switch (level) {
|
|
2655
|
+
case "error": return pc.red("[error]");
|
|
2656
|
+
case "warn": return pc.yellow("[warn]");
|
|
2657
|
+
case "log":
|
|
2658
|
+
case "info": return pc.green("[info]");
|
|
2659
|
+
case "debug": return pc.dim("[debug]");
|
|
2660
|
+
default: return pc.dim(`[${level ?? "info"}]`);
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
function renderMessage(parts) {
|
|
2664
|
+
if (!parts || parts.length === 0) return "";
|
|
2665
|
+
return parts.map((p) => {
|
|
2666
|
+
if (typeof p === "string") return p;
|
|
2667
|
+
try {
|
|
2668
|
+
return JSON.stringify(p);
|
|
2669
|
+
} catch {
|
|
2670
|
+
return String(p);
|
|
2671
|
+
}
|
|
2672
|
+
}).join(" ");
|
|
2673
|
+
}
|
|
2674
|
+
function sleep$1(ms) {
|
|
2675
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
2676
|
+
}
|
|
2677
|
+
//#endregion
|
|
2678
|
+
//#region src/commands/ai.ts
|
|
2679
|
+
/**
|
|
2680
|
+
* `amba ai providers ...` — register / list / remove AI provider API keys.
|
|
2681
|
+
*
|
|
2682
|
+
* Wire path: CLI → platform admin API. Plaintext is stored canonically
|
|
2683
|
+
* server-side; the CLI never echoes plaintext back. The API returns a
|
|
2684
|
+
* `api_key_preview` (first-6 + last-4) for confirmation.
|
|
2685
|
+
*/
|
|
2686
|
+
const VALID_PROVIDERS = new Set(["anthropic", "openai"]);
|
|
2687
|
+
function assertValidProvider(name) {
|
|
2688
|
+
if (!VALID_PROVIDERS.has(name)) throw new Error(`Unknown AI provider '${name}'. Supported: ${[...VALID_PROVIDERS].join(", ")}.`);
|
|
2689
|
+
}
|
|
2690
|
+
async function aiProvidersAddCommand(provider, options) {
|
|
2691
|
+
assertValidProvider(provider);
|
|
2692
|
+
const apiKey = await resolveSecretValue(options);
|
|
2693
|
+
if (apiKey.length < 10) throw new Error("AI provider api_key must be at least 10 characters.");
|
|
2694
|
+
const projectConfig = await loadProjectConfig();
|
|
2695
|
+
console.log();
|
|
2696
|
+
console.log(pc.bold(` amba ai providers add ${pc.cyan(provider)}`));
|
|
2697
|
+
console.log();
|
|
2698
|
+
const res = await registerAiProvider(projectConfig.projectId, {
|
|
2699
|
+
name: provider,
|
|
2700
|
+
api_key: apiKey
|
|
2701
|
+
});
|
|
2702
|
+
console.log(pc.green(" ✓") + ` Registered ${provider}`);
|
|
2703
|
+
if (res.data.api_key_preview) console.log(pc.dim(` key preview: ${res.data.api_key_preview}`));
|
|
2704
|
+
console.log(pc.dim(` secret_name: ${res.data.api_key_secret_name ?? "(unset)"}`));
|
|
2705
|
+
console.log();
|
|
2706
|
+
}
|
|
2707
|
+
async function aiProvidersListCommand() {
|
|
2708
|
+
const res = await listAiProviders((await loadProjectConfig()).projectId);
|
|
2709
|
+
console.log();
|
|
2710
|
+
if (res.data.length === 0) {
|
|
2711
|
+
console.log(pc.dim(" No AI providers registered."));
|
|
2712
|
+
console.log(pc.dim(" Add one with: amba ai providers add anthropic --key sk-ant-... (or --from-stdin)"));
|
|
2713
|
+
console.log();
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
for (const p of res.data) console.log(` ${pc.bold(p.name)} ` + pc.dim(`secret=${p.api_key_secret_name ?? "(unset)"}`) + (p.updated_at ? pc.dim(` updated=${p.updated_at}`) : ""));
|
|
2717
|
+
console.log();
|
|
2718
|
+
}
|
|
2719
|
+
async function aiProvidersDeleteCommand(provider) {
|
|
2720
|
+
assertValidProvider(provider);
|
|
2721
|
+
if ((await deleteAiProvider((await loadProjectConfig()).projectId, provider)).data.deleted) console.log(pc.green(" ✓") + ` Removed ${provider}`);
|
|
2722
|
+
}
|
|
2723
|
+
/**
|
|
2724
|
+
* Read a secret value from CLI options. Shared by `amba ai providers
|
|
2725
|
+
* add` and `amba secrets set` — either `--key`/`<value>` arg OR
|
|
2726
|
+
* `--from-stdin` for shell-history-safe input.
|
|
2727
|
+
*
|
|
2728
|
+
* Resolution order:
|
|
2729
|
+
* 1. `--from-stdin` → consume stdin to EOF, return trimmed value.
|
|
2730
|
+
* Errors if stdin is a TTY (would hang waiting for input the
|
|
2731
|
+
* caller can't see they need to type).
|
|
2732
|
+
* 2. `--key` (or its inline equivalent) → return as-is.
|
|
2733
|
+
* 3. Neither → throw with the actionable error message.
|
|
2734
|
+
*/
|
|
2735
|
+
async function resolveSecretValue(opts) {
|
|
2736
|
+
if (opts.fromStdin) {
|
|
2737
|
+
if (process.stdin.isTTY) throw new Error("--from-stdin was set but stdin is a TTY. Pipe the value in, e.g. `echo $KEY | amba ai providers add anthropic --from-stdin` or `amba ai providers add anthropic --from-stdin < ~/.anthropic-key`.");
|
|
2738
|
+
const chunks = [];
|
|
2739
|
+
for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
2740
|
+
const value = Buffer.concat(chunks).toString("utf8").trim();
|
|
2741
|
+
if (value.length === 0) throw new Error("--from-stdin: no input received on stdin.");
|
|
2742
|
+
return value;
|
|
2743
|
+
}
|
|
2744
|
+
if (opts.key !== void 0 && opts.key.length > 0) return opts.key;
|
|
2745
|
+
throw new Error("Missing API key. Provide --key <value> OR --from-stdin (and pipe the value in).");
|
|
2746
|
+
}
|
|
2747
|
+
//#endregion
|
|
2748
|
+
//#region src/commands/secrets.ts
|
|
2749
|
+
/**
|
|
2750
|
+
* `amba secrets ...` — write per-function secrets through the platform
|
|
2751
|
+
* API. The CLI sends `{name, value, function}` to the admin endpoint;
|
|
2752
|
+
* the server stores the value, validates reserved binding names, and
|
|
2753
|
+
* propagates it to the deployed Worker's binding. Sync is eventual —
|
|
2754
|
+
* `amba secrets list` shows progress.
|
|
2755
|
+
*/
|
|
2756
|
+
async function secretsSetCommand(name, value, options) {
|
|
2757
|
+
validateSecretName(name);
|
|
2758
|
+
const resolvedValue = await resolveSecretValue({
|
|
2759
|
+
key: value,
|
|
2760
|
+
fromStdin: options.fromStdin
|
|
2761
|
+
});
|
|
2762
|
+
const projectConfig = await loadProjectConfig();
|
|
2763
|
+
if (options.env === "prod") console.log(pc.dim(" Note: --env is informational; prod and dev share one secret namespace per project."));
|
|
2764
|
+
console.log();
|
|
2765
|
+
console.log(pc.bold(` amba secrets set ${pc.cyan(name)}`));
|
|
2766
|
+
console.log(pc.dim(` function=${options.function} env=${options.env ?? "dev"}`));
|
|
2767
|
+
console.log();
|
|
2768
|
+
const res = await setSecretViaApi(projectConfig.projectId, {
|
|
2769
|
+
name,
|
|
2770
|
+
value: resolvedValue,
|
|
2771
|
+
function: options.function
|
|
2772
|
+
});
|
|
2773
|
+
console.log(pc.green(" ✓") + ` Secret ${pc.cyan(name)} stored ${pc.dim(`v${res.data.version}`)}`);
|
|
2774
|
+
console.log(pc.green(" ✓") + ` Workers Secret sync ${pc.dim(`(status=${res.data.sync_status})`)} queued`);
|
|
2775
|
+
console.log();
|
|
2776
|
+
console.log(pc.dim(" Workers Secret will be live within ~30s. Run `amba secrets list` to check sync status."));
|
|
2777
|
+
console.log();
|
|
2778
|
+
}
|
|
2779
|
+
async function secretsListCommand() {
|
|
2780
|
+
const res = await listSecretsViaApi((await loadProjectConfig()).projectId);
|
|
2781
|
+
console.log();
|
|
2782
|
+
if (res.data.length === 0) {
|
|
2783
|
+
console.log(pc.dim(" No secrets configured."));
|
|
2784
|
+
console.log();
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
for (const row of res.data) {
|
|
2788
|
+
const status = renderStatus(row.sync_status);
|
|
2789
|
+
console.log(` ${pc.bold(row.name)} ` + pc.dim(`(${row.function})`) + ` v${row.version} ${status}` + (row.last_error ? pc.dim(` — ${row.last_error}`) : ""));
|
|
2790
|
+
}
|
|
2791
|
+
console.log();
|
|
2792
|
+
}
|
|
2793
|
+
async function secretsUnsetCommand(name, options) {
|
|
2794
|
+
validateSecretName(name);
|
|
2795
|
+
await deleteSecretViaApi((await loadProjectConfig()).projectId, name, { function: options.function });
|
|
2796
|
+
console.log(pc.green(" ✓") + ` Removed from GCP Secret Manager.`);
|
|
2797
|
+
console.log(pc.dim(" Workers Secret on the dispatched script remains until the next deploy — redeploy to clear."));
|
|
2798
|
+
}
|
|
2799
|
+
function validateSecretName(name) {
|
|
2800
|
+
const reason = getBindingReservationReason(name);
|
|
2801
|
+
if (reason !== null) throw new Error(`Secret name '${name}' rejected: ${reason}`);
|
|
2802
|
+
}
|
|
2803
|
+
function renderStatus(status) {
|
|
2804
|
+
switch (status) {
|
|
2805
|
+
case "synced": return pc.green("synced");
|
|
2806
|
+
case "syncing": return pc.cyan("syncing");
|
|
2807
|
+
case "sync_failed_retrying": return pc.yellow("retrying");
|
|
2808
|
+
default: return pc.dim(status);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
//#endregion
|
|
2812
|
+
//#region src/commands/collections.ts
|
|
2813
|
+
/**
|
|
2814
|
+
* `amba collections ...` — thin shells over the admin collection routes.
|
|
2815
|
+
*
|
|
2816
|
+
* Wire shapes:
|
|
2817
|
+
*
|
|
2818
|
+
* POST /admin/projects/:p/collections — create (full schema)
|
|
2819
|
+
* GET /admin/projects/:p/collections — list
|
|
2820
|
+
* PATCH /admin/projects/:p/collections/:name — single-op alter
|
|
2821
|
+
* DELETE /admin/projects/:p/collections/:name — drop (requires ?confirm)
|
|
2822
|
+
*
|
|
2823
|
+
* The CLI's job is to:
|
|
2824
|
+
* 1. Validate names / types client-side so an obviously-bad invocation
|
|
2825
|
+
* doesn't round-trip.
|
|
2826
|
+
* 2. Translate the user-friendly CLI flag shape (`--field name:type`,
|
|
2827
|
+
* `--add-field`, `--drop-field`) into the wire shape (single op per
|
|
2828
|
+
* PATCH).
|
|
2829
|
+
* 3. Block until the server reports success.
|
|
2830
|
+
* 4. Surface server errors with a sensible CLI message.
|
|
2831
|
+
*
|
|
2832
|
+
* Multi-op alters are issued sequentially so the operator audit trail
|
|
2833
|
+
* stays one-to-one with developer actions.
|
|
2834
|
+
*/
|
|
2835
|
+
/**
|
|
2836
|
+
* Closed-set type catalog. Matches data's `ColumnType` exactly — keeps
|
|
2837
|
+
* the CLI fail-fast path aligned with the DDL emit's accepted shapes.
|
|
2838
|
+
*
|
|
2839
|
+
* The CLI accepts customer-friendly synonyms (e.g. `int` → `integer`)
|
|
2840
|
+
* via `normalizeColumnType` so the `--field` flag stays ergonomic
|
|
2841
|
+
* without the API needing to relax its closed catalog.
|
|
2842
|
+
*/
|
|
2843
|
+
const ALLOWED_TYPES = new Set([
|
|
2844
|
+
"uuid",
|
|
2845
|
+
"text",
|
|
2846
|
+
"integer",
|
|
2847
|
+
"bigint",
|
|
2848
|
+
"numeric",
|
|
2849
|
+
"boolean",
|
|
2850
|
+
"timestamptz",
|
|
2851
|
+
"date",
|
|
2852
|
+
"jsonb",
|
|
2853
|
+
"vector"
|
|
2854
|
+
]);
|
|
2855
|
+
const TYPE_SYNONYMS = {
|
|
2856
|
+
int: "integer",
|
|
2857
|
+
int4: "integer",
|
|
2858
|
+
int8: "bigint",
|
|
2859
|
+
bool: "boolean",
|
|
2860
|
+
json: "jsonb",
|
|
2861
|
+
string: "text"
|
|
2862
|
+
};
|
|
2863
|
+
async function collectionsCreateCommand(name, options) {
|
|
2864
|
+
const reason = getReservationReason(name);
|
|
2865
|
+
if (reason) throw new Error(`Cannot create collection '${name}': ${reason}`);
|
|
2866
|
+
const columns = options.field.map(parseColumnSpec);
|
|
2867
|
+
const indexes = options.index.map(parseIndexSpec);
|
|
2868
|
+
const projectConfig = await loadProjectConfig();
|
|
2869
|
+
console.log();
|
|
2870
|
+
console.log(pc.bold(` amba collections create ${pc.cyan(name)}`));
|
|
2871
|
+
for (const c of columns) console.log(pc.dim(" ") + c.name + pc.dim(": ") + c.type + (c.nullable ? pc.dim(" NULL") : pc.dim(" NOT NULL")));
|
|
2872
|
+
console.log();
|
|
2873
|
+
const res = await createCollection(projectConfig.projectId, {
|
|
2874
|
+
name,
|
|
2875
|
+
columns,
|
|
2876
|
+
indexes
|
|
2877
|
+
});
|
|
2878
|
+
console.log(pc.green(" ✓") + ` Created — version ${res.data.version}`);
|
|
2879
|
+
console.log(pc.dim(` workflow_id: ${res.data.workflow_id}`));
|
|
2880
|
+
console.log();
|
|
2881
|
+
}
|
|
2882
|
+
async function collectionsAlterCommand(name, options) {
|
|
2883
|
+
if (options.addField.length === 0 && options.dropField.length === 0 && options.addIndex.length === 0) throw new Error("amba collections alter: at least one of --add-field, --add-index, or --drop-field is required.");
|
|
2884
|
+
const confirmed = new Set(options.confirm ?? []);
|
|
2885
|
+
for (const col of options.dropField) if (!confirmed.has(col)) throw new Error(`Refusing to drop column '${col}' without --confirm ${col} (DROP COLUMN is destructive).`);
|
|
2886
|
+
const projectConfig = await loadProjectConfig();
|
|
2887
|
+
for (const fieldSpec of options.addField) {
|
|
2888
|
+
const column = parseColumnSpec(fieldSpec);
|
|
2889
|
+
const res = await alterCollection(projectConfig.projectId, name, { add_column: column });
|
|
2890
|
+
console.log(pc.green(" ✓") + ` add column ${pc.cyan(column.name)} (${column.type}) — version ${res.data.version}`);
|
|
2891
|
+
}
|
|
2892
|
+
for (const indexSpec of options.addIndex) {
|
|
2893
|
+
const index = parseIndexSpec(indexSpec);
|
|
2894
|
+
const res = await alterCollection(projectConfig.projectId, name, { add_index: index });
|
|
2895
|
+
console.log(pc.green(" ✓") + ` add index on (${index.columns.join(", ")}) — version ${res.data.version}`);
|
|
2896
|
+
}
|
|
2897
|
+
for (const col of options.dropField) {
|
|
2898
|
+
const res = await alterCollection(projectConfig.projectId, name, { drop_column: col }, { confirm: col });
|
|
2899
|
+
console.log(pc.green(" ✓") + ` drop column ${pc.cyan(col)} — version ${res.data.version}`);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
async function collectionsListCommand() {
|
|
2903
|
+
const res = await listCollections$1((await loadProjectConfig()).projectId, { limit: 200 });
|
|
2904
|
+
console.log();
|
|
2905
|
+
if (res.data.length === 0) {
|
|
2906
|
+
console.log(pc.dim(" No collections defined."));
|
|
2907
|
+
console.log();
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
for (const c of res.data) console.log(` ${pc.bold(c.name)} ` + pc.dim(c.created_at));
|
|
2911
|
+
console.log();
|
|
2912
|
+
}
|
|
2913
|
+
async function collectionsDropCommand(name, options = {}) {
|
|
2914
|
+
if (options.confirm !== name) throw new Error(`Refusing to drop ${name}: pass --confirm ${name} to confirm (DROP TABLE is destructive).`);
|
|
2915
|
+
await dropCollection((await loadProjectConfig()).projectId, name, name);
|
|
2916
|
+
console.log(pc.green(" ✓") + ` Dropped ${name}.`);
|
|
2917
|
+
}
|
|
2918
|
+
function normalizeColumnType(raw) {
|
|
2919
|
+
const baseRaw = (raw.split("(")[0] ?? raw).trim().toLowerCase();
|
|
2920
|
+
const base = TYPE_SYNONYMS[baseRaw] ?? baseRaw;
|
|
2921
|
+
if (!ALLOWED_TYPES.has(base)) throw new Error(`Unknown type '${raw}'. Allowed: ${[...ALLOWED_TYPES].join(", ")}.`);
|
|
2922
|
+
return base;
|
|
2923
|
+
}
|
|
2924
|
+
/**
|
|
2925
|
+
* Parse a `--field` spec into a `CollectionColumn`.
|
|
2926
|
+
*
|
|
2927
|
+
* Accepted grammars:
|
|
2928
|
+
*
|
|
2929
|
+
* 1. **Plain column:** `name:type[:nullable]`
|
|
2930
|
+
* Examples: `letter_id:uuid`, `parsed:jsonb:nullable`.
|
|
2931
|
+
*
|
|
2932
|
+
* 2. **Foreign key:** `name:type:fk(<table>[.<column>][:onDelete])`
|
|
2933
|
+
* Examples:
|
|
2934
|
+
* `plan_id:uuid:fk(plans)` — references `plans(id)`, default no-action
|
|
2935
|
+
* `plan_id:uuid:fk(plans.id)` — explicit column
|
|
2936
|
+
* `plan_id:uuid:fk(plans.id:cascade)` — ON DELETE CASCADE
|
|
2937
|
+
* `parent_id:uuid:fk(comments:set_null)` — ON DELETE SET NULL
|
|
2938
|
+
*
|
|
2939
|
+
* Note: a `user_id` FK to `app_users(id)` is auto-emitted on every
|
|
2940
|
+
* collection server-side. Customers don't need to declare it.
|
|
2941
|
+
*
|
|
2942
|
+
* 3. **Vector:** `name:vector:<dim>` or `name:vector(<dim>)`
|
|
2943
|
+
* Examples:
|
|
2944
|
+
* `embedding:vector:1536` — OpenAI text-embedding-3-small
|
|
2945
|
+
* `embedding:vector(384)` — sentence-transformers / all-MiniLM
|
|
2946
|
+
* Dimension is validated client-side as 1..4096. For an HNSW /
|
|
2947
|
+
* IVFFlat index on the column, supply `--index 'embedding'`
|
|
2948
|
+
* separately.
|
|
2949
|
+
*
|
|
2950
|
+
* 4. **Vector + nullable:** `name:vector:<dim>:nullable`
|
|
2951
|
+
* Vector columns can be nullable for "embedding generated lazily".
|
|
2952
|
+
*
|
|
2953
|
+
* Multi-arg combinations (e.g. fk + nullable on the same column) parse
|
|
2954
|
+
* left-to-right: the third segment is the FK / vector / nullable
|
|
2955
|
+
* marker; if it's an FK or vector, the optional fourth segment is
|
|
2956
|
+
* `nullable`. Plain columns put `nullable` in slot 3.
|
|
2957
|
+
*/
|
|
2958
|
+
function parseColumnSpec(spec) {
|
|
2959
|
+
const parts = spec.replace(/^([a-z_][a-z0-9_]*):vector\((\d+)\)/i, "$1:vector:$2").replace(/fk\(([^)]+)\)/i, (_match, inner) => {
|
|
2960
|
+
return `fk(${inner.replace(/:/g, "|")})`;
|
|
2961
|
+
}).split(":");
|
|
2962
|
+
if (parts.length < 2 || parts.length > 4) throw new Error(`Invalid field spec '${spec}'. Grammar:\n name:type[:nullable] (plain)\n name:type:fk(table[.col][:onDelete])[:nullable] (foreign key)\n name:vector:<dim>[:nullable] (vector / pgvector)\nExamples: user_id:uuid, parsed:jsonb:nullable, plan_id:uuid:fk(plans),\n embedding:vector:1536, comment_id:uuid:fk(comments.id:set_null).`);
|
|
2963
|
+
const colName = parts[0];
|
|
2964
|
+
const type = parts[1];
|
|
2965
|
+
const slot3 = parts[2];
|
|
2966
|
+
const slot4 = parts[3];
|
|
2967
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(colName)) throw new Error(`Invalid column name '${colName}'.`);
|
|
2968
|
+
const normalizedType = normalizeColumnType(type);
|
|
2969
|
+
const out = {
|
|
2970
|
+
name: colName,
|
|
2971
|
+
type: normalizedType
|
|
2972
|
+
};
|
|
2973
|
+
if (slot3 === void 0) {
|
|
2974
|
+
if (normalizedType === "vector") throw new Error(`Vector column '${colName}' requires a dimension. Use ${colName}:vector:<dim> (e.g. ${colName}:vector:1536) or the paren form ${colName}:vector(<dim>).`);
|
|
2975
|
+
return out;
|
|
2976
|
+
}
|
|
2977
|
+
if (normalizedType === "vector") {
|
|
2978
|
+
const dim = Number.parseInt(slot3, 10);
|
|
2979
|
+
if (!Number.isFinite(dim) || dim <= 0) throw new Error(`Invalid vector dimension '${slot3}' on column '${colName}'. Expected a positive integer (e.g. embedding:vector:1536).`);
|
|
2980
|
+
if (dim > 4096) throw new Error(`Vector dimension ${dim} on column '${colName}' exceeds amba's cap of 4096.`);
|
|
2981
|
+
out.dimension = dim;
|
|
2982
|
+
if (slot4 !== void 0) {
|
|
2983
|
+
if (slot4 !== "nullable") throw new Error(`Invalid trailing token '${slot4}' on vector column '${colName}'. Only 'nullable' is allowed after the dimension.`);
|
|
2984
|
+
out.nullable = true;
|
|
2985
|
+
}
|
|
2986
|
+
return out;
|
|
2987
|
+
}
|
|
2988
|
+
if (slot3 === "nullable") {
|
|
2989
|
+
out.nullable = true;
|
|
2990
|
+
if (slot4 !== void 0) throw new Error(`Invalid trailing token '${slot4}' on column '${colName}'. 'nullable' must be the last segment for plain fields.`);
|
|
2991
|
+
return out;
|
|
2992
|
+
}
|
|
2993
|
+
if (slot3.startsWith("fk(") && slot3.endsWith(")")) {
|
|
2994
|
+
out.references = parseFkSpec(slot3.slice(3, -1).replace(/\|/g, ":"));
|
|
2995
|
+
if (slot4 !== void 0) {
|
|
2996
|
+
if (slot4 !== "nullable") throw new Error(`Invalid trailing token '${slot4}' on column '${colName}'. Only 'nullable' is allowed after fk(...).`);
|
|
2997
|
+
out.nullable = true;
|
|
2998
|
+
}
|
|
2999
|
+
return out;
|
|
3000
|
+
}
|
|
3001
|
+
throw new Error(`Invalid third segment '${slot3}' on column '${colName}'. Expected 'nullable', fk(...), or — for vector columns — a positive integer dimension.`);
|
|
3002
|
+
}
|
|
3003
|
+
/**
|
|
3004
|
+
* Parse the `fk(...)` body into the API's `references` shape.
|
|
3005
|
+
* Supported inner grammars:
|
|
3006
|
+
* - `<table>` → `{ table }` (FK to `<table>(id)`, no ON DELETE clause)
|
|
3007
|
+
* - `<table>.<column>` → `{ table, column }`
|
|
3008
|
+
* - `<table>:<onDelete>` → `{ table, onDelete }`
|
|
3009
|
+
* - `<table>.<column>:<onDelete>` → `{ table, column, onDelete }`
|
|
3010
|
+
*
|
|
3011
|
+
* `onDelete` accepts case-insensitive `cascade | restrict | set_null | no_action`
|
|
3012
|
+
* (and the `set null` / `no action` SQL spellings) — translated to the
|
|
3013
|
+
* canonical uppercased form the API expects.
|
|
3014
|
+
*/
|
|
3015
|
+
function parseFkSpec(inner) {
|
|
3016
|
+
const [tablePart, onDelete] = inner.includes(":") ? inner.split(":") : [inner, void 0];
|
|
3017
|
+
const trimmedTable = tablePart.trim();
|
|
3018
|
+
if (trimmedTable.length === 0) throw new Error(`Invalid fk(...) — table name is required.`);
|
|
3019
|
+
const dotIdx = trimmedTable.indexOf(".");
|
|
3020
|
+
let table;
|
|
3021
|
+
let column;
|
|
3022
|
+
if (dotIdx === -1) table = trimmedTable;
|
|
3023
|
+
else {
|
|
3024
|
+
table = trimmedTable.slice(0, dotIdx);
|
|
3025
|
+
column = trimmedTable.slice(dotIdx + 1);
|
|
3026
|
+
}
|
|
3027
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(table)) throw new Error(`Invalid fk table name '${table}'.`);
|
|
3028
|
+
if (column !== void 0 && !/^[a-z_][a-z0-9_]*$/.test(column)) throw new Error(`Invalid fk column name '${column}'.`);
|
|
3029
|
+
const out = { table };
|
|
3030
|
+
if (column !== void 0) out.column = column;
|
|
3031
|
+
if (onDelete !== void 0) out.onDelete = normalizeOnDelete(onDelete);
|
|
3032
|
+
return out;
|
|
3033
|
+
}
|
|
3034
|
+
function normalizeOnDelete(raw) {
|
|
3035
|
+
switch (raw.trim().toLowerCase().replace(/_/g, " ")) {
|
|
3036
|
+
case "cascade": return "CASCADE";
|
|
3037
|
+
case "restrict": return "RESTRICT";
|
|
3038
|
+
case "set null": return "SET NULL";
|
|
3039
|
+
case "no action": return "NO ACTION";
|
|
3040
|
+
default: throw new Error(`Invalid onDelete '${raw}' in fk(...). Supported: cascade | restrict | set_null | no_action.`);
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
/**
|
|
3044
|
+
* Parse an index spec into the wire shape data's API expects.
|
|
3045
|
+
*
|
|
3046
|
+
* Input syntax (CLI-friendly): `"col1 [asc|desc], col2 [asc|desc]"`.
|
|
3047
|
+
* Wire shape (per data's contract): `{ columns: ['col1 desc', 'col2 asc'] }` —
|
|
3048
|
+
* direction is embedded in each column string and parsed by the DDL emit.
|
|
3049
|
+
*
|
|
3050
|
+
* Bare column names (no direction) are passed through verbatim; the
|
|
3051
|
+
* default sort direction is the DDL emit's responsibility.
|
|
3052
|
+
*/
|
|
3053
|
+
function parseIndexSpec(spec) {
|
|
3054
|
+
const cols = [];
|
|
3055
|
+
for (const part of spec.split(",")) {
|
|
3056
|
+
const trimmed = part.trim();
|
|
3057
|
+
if (!trimmed) throw new Error(`Invalid index spec '${spec}'.`);
|
|
3058
|
+
const m = /^([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+(asc|desc))?$/i.exec(trimmed);
|
|
3059
|
+
if (!m) throw new Error(`Invalid index column '${trimmed}'. Expected '<col>' or '<col> asc|desc'.`);
|
|
3060
|
+
const colName = m[1];
|
|
3061
|
+
const direction = m[2]?.toLowerCase();
|
|
3062
|
+
cols.push(direction ? `${colName} ${direction}` : colName);
|
|
3063
|
+
}
|
|
3064
|
+
return { columns: cols };
|
|
3065
|
+
}
|
|
3066
|
+
//#endregion
|
|
3067
|
+
//#region src/_internal/codegen.ts
|
|
3068
|
+
/** Read every collection schema and emit a single `.amba/types.d.ts` body. */
|
|
3069
|
+
async function generateCollectionTypes(input) {
|
|
3070
|
+
const sorted = [...await listCollections(input.http, input.projectId)].sort();
|
|
3071
|
+
const collections = [];
|
|
3072
|
+
for (const name of sorted) collections.push(await describeCollection(input.http, input.projectId, name));
|
|
3073
|
+
return {
|
|
3074
|
+
declarationsTs: emitDeclarations(collections, input.bannerTimestamp),
|
|
3075
|
+
collectionNames: sorted
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
async function listCollections(http, projectId) {
|
|
3079
|
+
const { data } = await http.get(`/admin/projects/${encodeURIComponent(projectId)}/collections?limit=200`);
|
|
3080
|
+
return data.data.map((c) => c.name).filter((n) => typeof n === "string");
|
|
3081
|
+
}
|
|
3082
|
+
async function describeCollection(http, projectId, name) {
|
|
3083
|
+
const { data } = await http.get(`/admin/projects/${encodeURIComponent(projectId)}/collections/${encodeURIComponent(name)}`);
|
|
3084
|
+
return {
|
|
3085
|
+
name: data.data.name,
|
|
3086
|
+
columns: data.data.columns
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* Postgres `data_type` → TypeScript surface form. Names match the spelling
|
|
3091
|
+
* `information_schema.columns` returns.
|
|
3092
|
+
*/
|
|
3093
|
+
const PG_TYPE_TO_TS = {
|
|
3094
|
+
uuid: "string",
|
|
3095
|
+
text: "string",
|
|
3096
|
+
"character varying": "string",
|
|
3097
|
+
integer: "number",
|
|
3098
|
+
bigint: "number",
|
|
3099
|
+
smallint: "number",
|
|
3100
|
+
numeric: "number",
|
|
3101
|
+
"double precision": "number",
|
|
3102
|
+
real: "number",
|
|
3103
|
+
boolean: "boolean",
|
|
3104
|
+
"timestamp with time zone": "string",
|
|
3105
|
+
"timestamp without time zone": "string",
|
|
3106
|
+
date: "string",
|
|
3107
|
+
jsonb: "unknown",
|
|
3108
|
+
json: "unknown"
|
|
3109
|
+
};
|
|
3110
|
+
function tsTypeForColumn(col) {
|
|
3111
|
+
const base = PG_TYPE_TO_TS[col.data_type] ?? "unknown";
|
|
3112
|
+
if (col.column_name === "deleted_at") return "string | null";
|
|
3113
|
+
if (col.is_nullable === "YES") return `${base} | null`;
|
|
3114
|
+
return base;
|
|
3115
|
+
}
|
|
3116
|
+
const HEADER_BANNER = `// .amba/types.d.ts
|
|
3117
|
+
// AUTO-GENERATED by \`amba types generate\` — do not edit by hand.
|
|
3118
|
+
//
|
|
3119
|
+
// This file is regenerated whenever you run the CLI. It declares one
|
|
3120
|
+
// interface per customer collection and module-augments @layers/amba-client +
|
|
3121
|
+
// @layers/amba-functions so \`Amba.collections.<name>\`, \`client.collections.<name>\`,
|
|
3122
|
+
// and \`ctx.collections.<name>\` (Worker side) are all statically typed.
|
|
3123
|
+
//
|
|
3124
|
+
// Commit OR gitignore at your discretion. The shape mirrors your current
|
|
3125
|
+
// schema — regenerate after every collection migration.`;
|
|
3126
|
+
function emitDeclarations(collections, bannerTimestamp) {
|
|
3127
|
+
const banner = bannerTimestamp ? `${HEADER_BANNER}\n// Generated: ${bannerTimestamp}` : HEADER_BANNER;
|
|
3128
|
+
const interfaces = collections.map((c) => emitInterface(c)).join("\n\n");
|
|
3129
|
+
const augmentClient = collections.length ? `
|
|
3130
|
+
declare module '@layers/amba-client' {
|
|
3131
|
+
interface CollectionsRoot {
|
|
3132
|
+
${collections.map((c) => ` readonly ${tsLiteralKey(c.name)}: import('@layers/amba-client').ClientCollection<Amba.collections.${tsTypeName(c.name)}>;`).join("\n")}
|
|
3133
|
+
}
|
|
3134
|
+
}
|
|
3135
|
+
` : "";
|
|
3136
|
+
const augmentFunctions = collections.length ? `
|
|
3137
|
+
declare module '@layers/amba-functions' {
|
|
3138
|
+
interface CollectionsRoot {
|
|
3139
|
+
${collections.map((c) => ` readonly ${tsLiteralKey(c.name)}: import('@layers/amba-functions').Collection<Amba.collections.${tsTypeName(c.name)}>;`).join("\n")}
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
` : "";
|
|
3143
|
+
return `${banner}\n\nexport {};\n\ndeclare global {\n namespace Amba {\n namespace collections {\n${interfaces ? indent(interfaces, 6) : " // (no collections defined yet)"}\n }\n }\n}\n${augmentClient}${augmentFunctions}`;
|
|
3144
|
+
}
|
|
3145
|
+
function emitInterface(c) {
|
|
3146
|
+
const fields = c.columns.map((col) => {
|
|
3147
|
+
const ts = tsTypeForColumn(col);
|
|
3148
|
+
const optional = col.is_nullable === "YES" ? "?" : "";
|
|
3149
|
+
return `${tsLiteralKey(col.column_name)}${optional}: ${ts};`;
|
|
3150
|
+
});
|
|
3151
|
+
return `interface ${tsTypeName(c.name)} {\n${fields.map((f) => ` ${f}`).join("\n")}\n}`;
|
|
3152
|
+
}
|
|
3153
|
+
function indent(s, n) {
|
|
3154
|
+
const pad = " ".repeat(n);
|
|
3155
|
+
return s.split("\n").map((line) => line.length === 0 ? "" : pad + line).join("\n");
|
|
3156
|
+
}
|
|
3157
|
+
function tsLiteralKey(name) {
|
|
3158
|
+
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) return name;
|
|
3159
|
+
return JSON.stringify(name);
|
|
3160
|
+
}
|
|
3161
|
+
function tsTypeName(collectionName) {
|
|
3162
|
+
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(collectionName)) return JSON.stringify(collectionName);
|
|
3163
|
+
return collectionName;
|
|
3164
|
+
}
|
|
3165
|
+
//#endregion
|
|
3166
|
+
//#region src/commands/types.ts
|
|
3167
|
+
/**
|
|
3168
|
+
* `amba types generate [--watch]` — emits `.amba/types.d.ts`.
|
|
3169
|
+
*
|
|
3170
|
+
* One-shot is the default (CI-friendly). `--watch` is opt-in; the
|
|
3171
|
+
* engine emits deterministic output (no embedded timestamps unless we
|
|
3172
|
+
* pass `bannerTimestamp`), so the CLI compares strings to decide
|
|
3173
|
+
* whether to touch the file.
|
|
3174
|
+
*/
|
|
3175
|
+
async function typesGenerateCommand(options = {}) {
|
|
3176
|
+
const outPath = options.out ?? join(process.cwd(), ".amba", "types.d.ts");
|
|
3177
|
+
if (options.watch) {
|
|
3178
|
+
console.log(pc.dim(` Watching for collection schema changes — Ctrl+C to stop.`));
|
|
3179
|
+
let last = "";
|
|
3180
|
+
for (;;) {
|
|
3181
|
+
try {
|
|
3182
|
+
const next = await emit(outPath);
|
|
3183
|
+
if (next !== last) {
|
|
3184
|
+
console.log(pc.dim(` ${(/* @__PURE__ */ new Date()).toISOString()}`) + pc.green(" regenerated"));
|
|
3185
|
+
last = next;
|
|
3186
|
+
}
|
|
3187
|
+
} catch (err) {
|
|
3188
|
+
console.error(pc.red(" Error: ") + (err instanceof Error ? err.message : String(err)));
|
|
3189
|
+
}
|
|
3190
|
+
await new Promise((r) => setTimeout(r, 5e3));
|
|
3191
|
+
}
|
|
3192
|
+
} else {
|
|
3193
|
+
await emit(outPath);
|
|
3194
|
+
console.log(pc.green(" ✓") + ` Wrote ${outPath}`);
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
async function emit(outPath) {
|
|
3198
|
+
const projectConfig = await loadProjectConfig();
|
|
3199
|
+
const result = await generateCollectionTypes({
|
|
3200
|
+
http: makeCodegenHttpClient(),
|
|
3201
|
+
projectId: projectConfig.projectId
|
|
3202
|
+
});
|
|
3203
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
3204
|
+
await writeFile(outPath, result.declarationsTs, "utf-8");
|
|
3205
|
+
return result.declarationsTs;
|
|
3206
|
+
}
|
|
3207
|
+
/**
|
|
3208
|
+
* Adapt the CLI's authenticated admin-API helper to the engine's
|
|
3209
|
+
* minimal `CodegenHttpClient` shape. The engine emits paths starting
|
|
3210
|
+
* with `/admin/projects/...`; our `adminGet` already prefixes
|
|
3211
|
+
* `/admin`, so we strip the engine's `/admin` prefix before passing
|
|
3212
|
+
* through.
|
|
3213
|
+
*/
|
|
3214
|
+
function makeCodegenHttpClient() {
|
|
3215
|
+
return { async get(path) {
|
|
3216
|
+
return adminGet(path.startsWith("/admin") ? path.slice(6) : path);
|
|
3217
|
+
} };
|
|
3218
|
+
}
|
|
3219
|
+
//#endregion
|
|
3220
|
+
//#region src/commands/sites.ts
|
|
3221
|
+
/**
|
|
3222
|
+
* `amba sites ...` commands.
|
|
3223
|
+
*
|
|
3224
|
+
* Static-site hosting. The CLI orchestrates two halves on every deploy:
|
|
3225
|
+
*
|
|
3226
|
+
* 1. Register the site row in the control plane via the admin API so
|
|
3227
|
+
* domains can attach with a stable `cert_status` for polling.
|
|
3228
|
+
* 2. Upload the pre-built static directory through the platform API,
|
|
3229
|
+
* which proxies to the underlying CDN.
|
|
3230
|
+
*
|
|
3231
|
+
* This command does NOT build static files — accept a pre-built
|
|
3232
|
+
* directory (mirrors `wrangler pages deploy ./out` semantics). Dynamic
|
|
3233
|
+
* logic belongs in `amba functions deploy`, not in a site directory.
|
|
3234
|
+
*/
|
|
3235
|
+
const SITE_NAME_RE = /^[a-z][a-z0-9_-]{0,49}$/;
|
|
3236
|
+
const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
3237
|
+
function validateSiteName(name) {
|
|
3238
|
+
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).`);
|
|
3239
|
+
}
|
|
3240
|
+
function validateHostname(host) {
|
|
3241
|
+
if (!HOSTNAME_RE.test(host)) throw new Error(`Invalid hostname '${host}'. Must be a DNS-shaped name (e.g. site.example.com).`);
|
|
3242
|
+
}
|
|
3243
|
+
/**
|
|
3244
|
+
* Per-deploy size cap. CF Pages enforces 25 MiB per file + 25k files; we
|
|
3245
|
+
* pre-flight at 100 MiB total so the developer sees an actionable error
|
|
3246
|
+
* before we spend their time on a multi-second upload. Above this, point
|
|
3247
|
+
* them at a Pages-only deployment outside amba.
|
|
3248
|
+
*/
|
|
3249
|
+
const MAX_DEPLOYMENT_BYTES = 100 * 1024 * 1024;
|
|
3250
|
+
const MAX_DEPLOYMENT_FILES = 2e4;
|
|
3251
|
+
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
3252
|
+
async function sitesDeployCommand(inputDir, options = {}) {
|
|
3253
|
+
const projectId = (await loadProjectConfig()).projectId;
|
|
3254
|
+
const siteName = options.name ?? basename(inputDir).replace(/[^a-z0-9_-]/gi, "-").toLowerCase();
|
|
3255
|
+
validateSiteName(siteName);
|
|
3256
|
+
console.log();
|
|
3257
|
+
console.log(pc.bold(` amba sites deploy ${pc.cyan(siteName)}`));
|
|
3258
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
3259
|
+
console.log();
|
|
3260
|
+
console.log(pc.dim(` Scanning ${inputDir}…`));
|
|
3261
|
+
const dirStat = await stat(inputDir).catch(() => null);
|
|
3262
|
+
if (!dirStat || !dirStat.isDirectory()) throw new Error(`'${inputDir}' is not a directory. Pass a built static site folder.`);
|
|
3263
|
+
const files = await collectFiles(inputDir);
|
|
3264
|
+
if (files.length === 0) throw new Error(`No files found under ${inputDir}.`);
|
|
3265
|
+
if (files.length > MAX_DEPLOYMENT_FILES) throw new Error(`Too many files (${files.length} > ${MAX_DEPLOYMENT_FILES}). Pages-for-Platforms enforces a 20k-file cap.`);
|
|
3266
|
+
const totalBytes = files.reduce((sum, f) => sum + f.size, 0);
|
|
3267
|
+
if (totalBytes > MAX_DEPLOYMENT_BYTES) throw new Error(`Deployment too large (${formatBytes(totalBytes)} > ${formatBytes(MAX_DEPLOYMENT_BYTES)}). Trim assets or split into multiple sites.`);
|
|
3268
|
+
console.log(pc.dim(` ${files.length} files, ${formatBytes(totalBytes)}`));
|
|
3269
|
+
if (options.dryRun) {
|
|
3270
|
+
console.log(pc.yellow(" ! Dry run — skipping CF Pages upload + control-plane write."));
|
|
3271
|
+
console.log();
|
|
3272
|
+
return;
|
|
3273
|
+
}
|
|
3274
|
+
let cfPagesProjectName;
|
|
3275
|
+
try {
|
|
3276
|
+
cfPagesProjectName = (await createSite(projectId, { name: siteName })).data.cf_pages_project_name;
|
|
3277
|
+
console.log(pc.green(" ✓") + ` Registered site (cf_pages_project=${cfPagesProjectName})`);
|
|
3278
|
+
} catch (err) {
|
|
3279
|
+
cfPagesProjectName = (await describeSite(projectId, siteName)).data.cf_pages_project_name;
|
|
3280
|
+
console.log(pc.dim(` Site already registered (cf_pages_project=${cfPagesProjectName})`));
|
|
3281
|
+
}
|
|
3282
|
+
console.log(pc.dim(" Uploading…"));
|
|
3283
|
+
const dep = (await deploySiteViaApi(projectId, siteName, await buildPagesDeploymentForm(files))).data;
|
|
3284
|
+
console.log(pc.green(" ✓") + ` Deployed ${dep.deployment_id.slice(0, 12)} ${pc.dim(`(branch=${dep.branch}, status=${dep.status})`)}`);
|
|
3285
|
+
console.log(pc.green(" ✓") + ` URL: ${pc.underline(dep.url)}`);
|
|
3286
|
+
if (dep.preview_url && dep.preview_url !== dep.url) console.log(pc.dim(` preview (CF): ${dep.preview_url}`));
|
|
3287
|
+
const domains = await listSiteDomains(projectId, siteName);
|
|
3288
|
+
if (domains.data.length > 0) {
|
|
3289
|
+
console.log();
|
|
3290
|
+
console.log(pc.dim(" Domains:"));
|
|
3291
|
+
for (const d of domains.data) console.log(` ${pc.bold(d.hostname)} ${formatCertStatus(d.cert_status)}`);
|
|
3292
|
+
}
|
|
3293
|
+
console.log();
|
|
3294
|
+
}
|
|
3295
|
+
async function sitesListCommand() {
|
|
3296
|
+
const res = await listSites((await loadProjectConfig()).projectId);
|
|
3297
|
+
console.log();
|
|
3298
|
+
if (res.data.length === 0) {
|
|
3299
|
+
console.log(pc.dim(" No sites deployed."));
|
|
3300
|
+
console.log();
|
|
3301
|
+
return;
|
|
3302
|
+
}
|
|
3303
|
+
for (const s of res.data) {
|
|
3304
|
+
const status = s.status === "active" ? pc.green("active") : pc.yellow(s.status);
|
|
3305
|
+
console.log(` ${pc.bold(s.name)} ${status} ${pc.dim(`pages=${s.cf_pages_project_name} ${s.created_at}`)}`);
|
|
3306
|
+
}
|
|
3307
|
+
console.log();
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* `amba sites logs <name>` — not yet available via the public API. Use
|
|
3311
|
+
* the developer console for deployment history, or `amba sites describe`
|
|
3312
|
+
* for the current cert / domain state.
|
|
3313
|
+
*/
|
|
3314
|
+
async function sitesLogsCommand(name) {
|
|
3315
|
+
validateSiteName(name);
|
|
3316
|
+
console.log();
|
|
3317
|
+
console.log(pc.yellow(" ! `amba sites logs` is not available in this release."));
|
|
3318
|
+
console.log();
|
|
3319
|
+
console.log(pc.dim(" Alternatives:"));
|
|
3320
|
+
console.log(pc.dim(" amba sites describe <name> (current state + domains/certs)"));
|
|
3321
|
+
console.log(pc.dim(" https://app.amba.dev (full deployment history UI)"));
|
|
3322
|
+
console.log();
|
|
3323
|
+
}
|
|
3324
|
+
async function sitesRollbackCommand(name, options = {}) {
|
|
3325
|
+
validateSiteName(name);
|
|
3326
|
+
if (!options.to) throw new Error("sites rollback requires --to <deployment_id>. Find the target deployment in `amba sites describe` or the developer console.");
|
|
3327
|
+
const dep = (await rollbackSiteViaApi((await loadProjectConfig()).projectId, name, options.to)).data;
|
|
3328
|
+
console.log(pc.green(" ✓") + ` Rolled back to ${options.to.slice(0, 12)}; new deployment ${pc.cyan(dep.deployment_id.slice(0, 12))} ${pc.dim(`(status=${dep.status})`)} is now live.`);
|
|
3329
|
+
console.log(pc.green(" ✓") + ` URL: ${pc.underline(dep.url)}`);
|
|
3330
|
+
}
|
|
3331
|
+
async function sitesDomainAddCommand(hostname, options) {
|
|
3332
|
+
validateSiteName(options.site);
|
|
3333
|
+
validateHostname(hostname);
|
|
3334
|
+
const projectId = (await loadProjectConfig()).projectId;
|
|
3335
|
+
console.log();
|
|
3336
|
+
console.log(pc.bold(` amba sites domain add ${pc.cyan(hostname)}`));
|
|
3337
|
+
console.log(pc.dim(` → site ${pc.cyan(options.site)}`));
|
|
3338
|
+
console.log();
|
|
3339
|
+
const res = await addSiteDomainViaApi(projectId, options.site, hostname);
|
|
3340
|
+
console.log(pc.green(" ✓") + ` Custom Hostname registered (cf_id=${res.data.cf_hostname_id})`);
|
|
3341
|
+
console.log();
|
|
3342
|
+
console.log(pc.dim(" Point your DNS at:"));
|
|
3343
|
+
console.log(` ${pc.bold("CNAME")} ${hostname} → ${pc.cyan(res.data.dns_target)}`);
|
|
3344
|
+
console.log();
|
|
3345
|
+
if (options.noWait) {
|
|
3346
|
+
console.log(pc.yellow(" ! --no-wait — skipping cert poll. Run `amba sites describe` later."));
|
|
3347
|
+
return;
|
|
3348
|
+
}
|
|
3349
|
+
const timeout = (options.timeout ?? 600) * 1e3;
|
|
3350
|
+
const start = Date.now();
|
|
3351
|
+
let lastStatus = "";
|
|
3352
|
+
while (Date.now() - start < timeout) {
|
|
3353
|
+
const row = (await listSiteDomains(projectId, options.site)).data.find((d) => d.hostname === hostname);
|
|
3354
|
+
if (!row) throw new Error(`Domain ${hostname} disappeared from listing — check API state.`);
|
|
3355
|
+
if (row.cert_status !== lastStatus) {
|
|
3356
|
+
console.log(pc.dim(` cert_status: ${formatCertStatus(row.cert_status)}`));
|
|
3357
|
+
lastStatus = row.cert_status;
|
|
3358
|
+
}
|
|
3359
|
+
if (row.cert_status === "active") {
|
|
3360
|
+
console.log(pc.green(" ✓") + ` ${hostname} live with valid cert.`);
|
|
3361
|
+
return;
|
|
3362
|
+
}
|
|
3363
|
+
if (row.cert_status === "error") throw new Error(`Cert provisioning failed for ${hostname}. Check that the CNAME points at ${res.data.dns_target}.`);
|
|
3364
|
+
await sleep(5e3);
|
|
3365
|
+
}
|
|
3366
|
+
console.log(pc.yellow(` ! Timed out after ${options.timeout ?? 600}s waiting for cert. Re-run \`amba sites describe ${options.site}\` to check status.`));
|
|
3367
|
+
}
|
|
3368
|
+
async function sitesDomainListCommand(siteName) {
|
|
3369
|
+
validateSiteName(siteName);
|
|
3370
|
+
const res = await listSiteDomains((await loadProjectConfig()).projectId, siteName);
|
|
3371
|
+
console.log();
|
|
3372
|
+
if (res.data.length === 0) {
|
|
3373
|
+
console.log(pc.dim(` No domains attached to ${siteName}.`));
|
|
3374
|
+
console.log();
|
|
3375
|
+
return;
|
|
3376
|
+
}
|
|
3377
|
+
for (const d of res.data) console.log(` ${pc.bold(d.hostname)} ${formatCertStatus(d.cert_status)}`);
|
|
3378
|
+
console.log();
|
|
3379
|
+
}
|
|
3380
|
+
async function sitesDomainRemoveCommand(hostname, options) {
|
|
3381
|
+
validateSiteName(options.site);
|
|
3382
|
+
validateHostname(hostname);
|
|
3383
|
+
const projectConfig = await loadProjectConfig();
|
|
3384
|
+
options.zoneId;
|
|
3385
|
+
await removeSiteDomainViaApi(projectConfig.projectId, options.site, hostname);
|
|
3386
|
+
console.log(pc.green(" ✓") + ` Detached ${hostname} from ${options.site}.`);
|
|
3387
|
+
}
|
|
3388
|
+
async function sitesDisableCommand(name) {
|
|
3389
|
+
validateSiteName(name);
|
|
3390
|
+
await updateSite((await loadProjectConfig()).projectId, name, { status: "disabled" });
|
|
3391
|
+
console.log(pc.green(" ✓") + ` Disabled ${name}.`);
|
|
3392
|
+
}
|
|
3393
|
+
async function sitesEnableCommand(name) {
|
|
3394
|
+
validateSiteName(name);
|
|
3395
|
+
await updateSite((await loadProjectConfig()).projectId, name, { status: "active" });
|
|
3396
|
+
console.log(pc.green(" ✓") + ` Re-enabled ${name}.`);
|
|
3397
|
+
}
|
|
3398
|
+
async function sitesArchiveCommand(name, options = {}) {
|
|
3399
|
+
validateSiteName(name);
|
|
3400
|
+
if (!options.confirm || options.confirm !== name) throw new Error(`Archive is destructive. Pass --confirm ${name} to proceed. The site project will be removed and traffic will 404.`);
|
|
3401
|
+
const cascade = (await deleteSiteViaApi((await loadProjectConfig()).projectId, name, { confirm: name })).data.cascade;
|
|
3402
|
+
console.log(pc.green(" ✓") + ` Archived ${name}.`);
|
|
3403
|
+
console.log(pc.dim(` Cascade: domains_removed=${cascade.domains_removed ?? 0}, cf_pages_project_deleted=${cascade.cf_pages_project_deleted ?? false}`));
|
|
3404
|
+
}
|
|
3405
|
+
/**
|
|
3406
|
+
* Sites are static-only. Dynamic logic belongs in `amba functions deploy`;
|
|
3407
|
+
* Pages-Functions inputs are rejected before upload so customer code
|
|
3408
|
+
* cannot bypass the platform's auth/edge router by smuggling itself
|
|
3409
|
+
* into the static-site deployment surface.
|
|
3410
|
+
*/
|
|
3411
|
+
const BLOCKED_FILE_NAMES = new Set([
|
|
3412
|
+
"_worker.js",
|
|
3413
|
+
"_worker.ts",
|
|
3414
|
+
"_worker.mjs",
|
|
3415
|
+
"_routes.json",
|
|
3416
|
+
"_middleware.js",
|
|
3417
|
+
"_middleware.ts"
|
|
3418
|
+
]);
|
|
3419
|
+
const BLOCKED_DIR_NAMES = new Set(["functions"]);
|
|
3420
|
+
/**
|
|
3421
|
+
* Recursively walk `dir` and return every file, skipping common build
|
|
3422
|
+
* detritus (`.DS_Store`, `.git`). Dynamic-handler inputs (`_worker.*`,
|
|
3423
|
+
* `functions/`, `_routes.json`, `_middleware.*`) are rejected.
|
|
3424
|
+
*/
|
|
3425
|
+
async function collectFiles(dir) {
|
|
3426
|
+
const out = [];
|
|
3427
|
+
async function walk(current, depth) {
|
|
3428
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
3429
|
+
for (const e of entries) {
|
|
3430
|
+
const p = join(current, e.name);
|
|
3431
|
+
if (e.name === ".DS_Store" || e.name === ".git") continue;
|
|
3432
|
+
if (e.isDirectory() && BLOCKED_DIR_NAMES.has(e.name) && depth === 0) throw new SitesStaticOnlyError(`Found '${e.name}/' directory at the deploy root. Dynamic handlers are not allowed in static sites.\n → Move dynamic logic to its own function: \`amba functions deploy <entry>\`\n → Then call it from your static site via fetch().`);
|
|
3433
|
+
if (e.isFile() && BLOCKED_FILE_NAMES.has(e.name)) throw new SitesStaticOnlyError(`Found '${e.name}' in the deploy directory. Dynamic handlers are not allowed in static sites.\n → Move dynamic logic to: \`amba functions deploy <entry>\`\n → Then call it from your static site via fetch().`);
|
|
3434
|
+
if (e.isDirectory()) await walk(p, depth + 1);
|
|
3435
|
+
else if (e.isFile()) {
|
|
3436
|
+
const st = await stat(p);
|
|
3437
|
+
if (st.size > MAX_FILE_BYTES) throw new Error(`File ${p} is ${formatBytes(st.size)} (> ${formatBytes(MAX_FILE_BYTES)} per-file cap).`);
|
|
3438
|
+
out.push({
|
|
3439
|
+
relPath: relative(dir, p).split(/[\\/]/).join("/"),
|
|
3440
|
+
absPath: p,
|
|
3441
|
+
size: st.size
|
|
3442
|
+
});
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
}
|
|
3446
|
+
await walk(dir, 0);
|
|
3447
|
+
return out;
|
|
3448
|
+
}
|
|
3449
|
+
/**
|
|
3450
|
+
* Distinct error class so tests can assert on the specific Decision
|
|
3451
|
+
* Log #10 rejection rather than string-matching the message. CLI's
|
|
3452
|
+
* `runAction` wrapper renders Errors uniformly so users still see the
|
|
3453
|
+
* full message.
|
|
3454
|
+
*/
|
|
3455
|
+
var SitesStaticOnlyError = class extends Error {
|
|
3456
|
+
code = "SITES_STATIC_ONLY";
|
|
3457
|
+
};
|
|
3458
|
+
/**
|
|
3459
|
+
* Build the multipart payload the deployment proxy expects: one form
|
|
3460
|
+
* part per file plus a required `manifest` field mapping
|
|
3461
|
+
* `/relative/path` → `sha256-hex`. Leading slash on the manifest key is
|
|
3462
|
+
* required.
|
|
3463
|
+
*/
|
|
3464
|
+
async function buildPagesDeploymentForm(files) {
|
|
3465
|
+
const fd = new FormData();
|
|
3466
|
+
const manifest = {};
|
|
3467
|
+
for (const f of files) {
|
|
3468
|
+
const buf = await readFile(f.absPath);
|
|
3469
|
+
fd.append(f.relPath, new Blob([new Uint8Array(buf)]), f.relPath);
|
|
3470
|
+
const manifestKey = "/" + f.relPath.split(/[/\\]/).join("/");
|
|
3471
|
+
manifest[manifestKey] = createHash("sha256").update(buf).digest("hex");
|
|
3472
|
+
}
|
|
3473
|
+
fd.append("manifest", JSON.stringify(manifest));
|
|
3474
|
+
return fd;
|
|
3475
|
+
}
|
|
3476
|
+
function formatBytes(n) {
|
|
3477
|
+
if (n < 1024) return `${n} B`;
|
|
3478
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
|
3479
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MiB`;
|
|
3480
|
+
}
|
|
3481
|
+
function formatCertStatus(s) {
|
|
3482
|
+
if (s === "active") return pc.green(s);
|
|
3483
|
+
if (s === "error") return pc.red(s);
|
|
3484
|
+
return pc.yellow(s);
|
|
3485
|
+
}
|
|
3486
|
+
function sleep(ms) {
|
|
3487
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
3488
|
+
}
|
|
3489
|
+
//#endregion
|
|
3490
|
+
//#region src/index.ts
|
|
3491
|
+
const program = new Command();
|
|
3492
|
+
program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("0.1.0");
|
|
3493
|
+
program.option("--token <pat>", "Use a Personal Access Token for headless / CI / agent use (overrides ~/.amba/credentials.json + AMBA_PAT env)");
|
|
3494
|
+
program.hook("preAction", (thisCommand) => {
|
|
3495
|
+
setBearerOverride(resolveTokenSource({
|
|
3496
|
+
flagToken: thisCommand.optsWithGlobals().token,
|
|
3497
|
+
envToken: process.env["AMBA_PAT"]
|
|
3498
|
+
}));
|
|
3499
|
+
});
|
|
3500
|
+
function runAction(fn) {
|
|
3501
|
+
return fn().catch((err) => {
|
|
3502
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
3503
|
+
console.error(pc.red(`\n Error: ${message}\n`));
|
|
3504
|
+
process.exit(1);
|
|
3505
|
+
});
|
|
3506
|
+
}
|
|
3507
|
+
program.command("init").description("Initialize Amba in the current project (mints a personal dev project by default)").option("--with-example", "Scaffold a sample app.tsx + README snippet into the current directory").option("--env <env>", "'development' (default) or 'production'").action(async (opts) => {
|
|
3508
|
+
let env;
|
|
3509
|
+
if (opts.env === "development" || opts.env === "dev") env = "development";
|
|
3510
|
+
else if (opts.env === "production" || opts.env === "prod") env = "production";
|
|
3511
|
+
else if (opts.env !== void 0) {
|
|
3512
|
+
console.error(`Error: --env must be 'development' or 'production' (got '${opts.env}').`);
|
|
3513
|
+
process.exit(1);
|
|
3514
|
+
}
|
|
3515
|
+
await runAction(() => initCommand({
|
|
3516
|
+
withExample: opts.withExample,
|
|
3517
|
+
env
|
|
3518
|
+
}));
|
|
3519
|
+
});
|
|
3520
|
+
program.command("login").description("Authenticate with Amba").action(async () => {
|
|
3521
|
+
await runAction(loginCommand);
|
|
3522
|
+
});
|
|
3523
|
+
program.command("logout").description("Clear stored credentials").action(async () => {
|
|
3524
|
+
await runAction(async () => {
|
|
3525
|
+
await clearCredentials();
|
|
3526
|
+
console.log();
|
|
3527
|
+
console.log(pc.green(" ✓") + " Logged out — credentials removed");
|
|
3528
|
+
console.log();
|
|
3529
|
+
});
|
|
3530
|
+
});
|
|
3531
|
+
program.command("status").description("Show project health and integration status").option("--detailed", "Include integrations, user count, and segment summary").action(async (opts) => {
|
|
3532
|
+
await runAction(() => statusCommand({ detailed: opts.detailed }));
|
|
3533
|
+
});
|
|
3534
|
+
program.command("push").description("Push notification commands").command("test").description("Send a test push notification to all registered devices").action(async () => {
|
|
3535
|
+
await runAction(pushTestCommand);
|
|
3536
|
+
});
|
|
3537
|
+
const config = program.command("config").description("Remote config commands");
|
|
3538
|
+
config.command("list").description("List all remote config values").action(async () => {
|
|
3539
|
+
await runAction(configListCommand);
|
|
3540
|
+
});
|
|
3541
|
+
config.command("set <key> <value>").description("Set a remote config value").action(async (key, value) => {
|
|
3542
|
+
await runAction(() => configSetCommand(key, value));
|
|
3543
|
+
});
|
|
3544
|
+
const projects = program.command("projects").description("Project management commands");
|
|
3545
|
+
projects.command("list").description("List all projects in the authenticated developer account").action(async () => {
|
|
3546
|
+
await runAction(projectsListCommand);
|
|
3547
|
+
});
|
|
3548
|
+
projects.command("create").description("Create a new project").requiredOption("--name <name>", "Project name").option("--env <env>", "Environment hint (informational; projects start in development)").option("--bundle-id <id>", "Bundle identifier (iOS/Android)").option("--platform <platform>", "Platform: 'ios' | 'android' | 'all'").action(async (opts) => {
|
|
3549
|
+
await runAction(() => projectsCreateCommand({
|
|
3550
|
+
name: opts.name,
|
|
3551
|
+
env: opts.env,
|
|
3552
|
+
bundleId: opts.bundleId,
|
|
3553
|
+
platform: opts.platform
|
|
3554
|
+
}));
|
|
3555
|
+
});
|
|
3556
|
+
projects.command("show <projectId>").description("Show full project details as JSON").action(async (projectId) => {
|
|
3557
|
+
await runAction(() => projectsShowCommand(projectId));
|
|
3558
|
+
});
|
|
3559
|
+
projects.command("delete <projectId>").description("Delete a project (irreversible)").option("-y, --yes", "Skip confirmation prompt").action(async (projectId, opts) => {
|
|
3560
|
+
await runAction(() => projectsDeleteCommand(projectId, { yes: opts.yes }));
|
|
3561
|
+
});
|
|
3562
|
+
program.command("logs").description("Log streaming commands").command("tail").description("Tail the engagement event log for a project").option("--project <id>", "Project id (overrides .env.local)").option("--follow", "Keep the stream open and poll for new events every 2s").option("--json", "Emit raw NDJSON for piping (no decoration)").option("--since <iso>", "Only show events on or after this ISO 8601 timestamp").option("--event-name <name>", "Filter to a single event_name").option("--user-id <id>", "Filter to a single app_user_id").option("--limit <n>", "Max events per page (default 100, max 1000)", (v) => parseInt(v, 10)).action(async (opts) => {
|
|
3563
|
+
await runAction(() => logsTailCommand({
|
|
3564
|
+
project: opts.project,
|
|
3565
|
+
follow: opts.follow,
|
|
3566
|
+
json: opts.json,
|
|
3567
|
+
since: opts.since,
|
|
3568
|
+
eventName: opts.eventName,
|
|
3569
|
+
userId: opts.userId,
|
|
3570
|
+
limit: opts.limit
|
|
3571
|
+
}));
|
|
3572
|
+
});
|
|
3573
|
+
program.command("seed").description("Populate test data for the current project").option("--project <id>", "Project id (overrides .env.local)").option("--preset <preset>", "Preset: 'starter' | 'gamification' | 'content'", "starter").action(async (opts) => {
|
|
3574
|
+
await runAction(() => seedCommand({
|
|
3575
|
+
project: opts.project,
|
|
3576
|
+
preset: opts.preset ?? "starter"
|
|
3577
|
+
}));
|
|
3578
|
+
});
|
|
3579
|
+
program.command("db").description("Database operations").command("migrate").description("Run tenant migrations").option("--project <id>", "Project id (overrides .env.local)").action(async (opts) => {
|
|
3580
|
+
await runAction(() => dbMigrateCommand({ project: opts.project }));
|
|
3581
|
+
});
|
|
3582
|
+
program.command("analytics").description("Analytics export commands").command("export").description("Export users or events to CSV or NDJSON").requiredOption("--type <type>", "'users' | 'events'").option("--project <id>", "Project id (overrides .env.local)").option("--since <date>", "Only include rows since this ISO 8601 timestamp").option("--until <date>", "Only include rows up to this ISO 8601 timestamp").option("--out <file>", "Write output to file instead of stdout").option("--format <fmt>", "'csv' (default) | 'ndjson'").option("--limit <n>", "Per-page row limit when paginating events", (v) => parseInt(v, 10)).action(async (opts) => {
|
|
3583
|
+
await runAction(() => analyticsExportCommand({
|
|
3584
|
+
type: opts.type,
|
|
3585
|
+
project: opts.project,
|
|
3586
|
+
since: opts.since,
|
|
3587
|
+
until: opts.until,
|
|
3588
|
+
out: opts.out,
|
|
3589
|
+
format: opts.format,
|
|
3590
|
+
limit: opts.limit
|
|
3591
|
+
}));
|
|
3592
|
+
});
|
|
3593
|
+
program.command("schema").description("Schema export commands").command("export").description("Dump schema definitions for domains").requiredOption("--domain <domain>", "Domain: 'all' | 'achievements' | 'streaks' | 'xp' | 'leaderboards' | 'challenges' | 'content' | 'segments' | 'push'").option("--format <format>", "'json' | 'typescript'", "json").action(async (opts) => {
|
|
3594
|
+
await runAction(() => schemaExportCommand({
|
|
3595
|
+
domain: opts.domain,
|
|
3596
|
+
format: opts.format ?? "json"
|
|
3597
|
+
}));
|
|
3598
|
+
});
|
|
3599
|
+
const functions = program.command("functions").description("Customer Worker functions (Cloudflare Workers for Platforms)");
|
|
3600
|
+
functions.command("deploy <file>").description("Bundle a function file and deploy to the dispatch namespace").option("--name <name>", "Function name (default: filename without extension)").option("--dry-run", "Bundle and report size without uploading").option("--rate-limit-window <duration>", "Rate-limit window: 60s | 5m | 1h").option("--rate-limit-max <int>", "Rate-limit max requests per window", (v) => Number.parseInt(v, 10)).option("--rate-limit-key <kind>", "Rate-limit bucket key: user_id | ip").action(async (file, opts) => {
|
|
3601
|
+
await runAction(() => functionsDeployCommand(file, opts));
|
|
3602
|
+
});
|
|
3603
|
+
functions.command("list").description("List active functions for the current project").action(async () => {
|
|
3604
|
+
await runAction(functionsListCommand);
|
|
3605
|
+
});
|
|
3606
|
+
functions.command("delete <name>").description("Disable + remove a function from the dispatch namespace").action(async (name) => {
|
|
3607
|
+
await runAction(() => functionsDeleteCommand(name));
|
|
3608
|
+
});
|
|
3609
|
+
functions.command("schedule <name> <cron>").description("Register a cron schedule that invokes a deployed function").option("--tz <iana>", "IANA timezone for the schedule (default: UTC)").action(async (name, cron, opts) => {
|
|
3610
|
+
await runAction(() => functionsScheduleCommand(name, cron, opts));
|
|
3611
|
+
});
|
|
3612
|
+
functions.command("dev <file>").description("Run wrangler dev --remote against your dev project").action(async (file) => {
|
|
3613
|
+
await runAction(() => functionsDevCommand(file));
|
|
3614
|
+
});
|
|
3615
|
+
functions.command("logs <name>").description("Stream log events for a deployed function").option("--since <iso>", "Start of the time range (default: 1 hour ago)").option("--until <iso>", "End of the time range (default: now). Ignored on --tail.").option("--limit <n>", "Max events per fetch (default 100, max 1000)", (v) => parseInt(v, 10)).option("--tail", "Follow new events; polls every 3s. Ctrl+C to stop.").option("--follow", "Alias for --tail (kept for backwards compatibility with v1 log commands).").option("--json", "NDJSON output to stdout (one event per line)").action(async (name, opts) => {
|
|
3616
|
+
await runAction(() => functionsLogsCommand(name, {
|
|
3617
|
+
since: opts.since,
|
|
3618
|
+
until: opts.until,
|
|
3619
|
+
limit: opts.limit,
|
|
3620
|
+
tail: opts.tail || opts.follow,
|
|
3621
|
+
json: opts.json
|
|
3622
|
+
}));
|
|
3623
|
+
});
|
|
3624
|
+
functions.command("consume <queue-name> <function-name>").description("Bind a function as the consumer for a queue").option("--paused", "Create the binding paused (the workflow DLQs payloads until resumed)").action(async (queueName, functionName, opts) => {
|
|
3625
|
+
await runAction(() => functionsConsumeCommand(queueName, functionName, opts));
|
|
3626
|
+
});
|
|
3627
|
+
const consumers = functions.command("consumers").description("Manage queue → function bindings");
|
|
3628
|
+
consumers.command("list").description("List queue bindings for the current project").action(async () => {
|
|
3629
|
+
await runAction(functionsConsumersListCommand);
|
|
3630
|
+
});
|
|
3631
|
+
consumers.command("unbind <queue-name>").description("Remove the binding for a queue (DROP)").action(async (queueName) => {
|
|
3632
|
+
await runAction(() => functionsConsumersUnbindCommand(queueName));
|
|
3633
|
+
});
|
|
3634
|
+
const secrets = program.command("secrets").description("Function secrets (GCP canonical, Workers-Secret synced)");
|
|
3635
|
+
secrets.command("set <name> [value]").description("Write a secret to GCP Secret Manager and queue Workers Secret sync. Use --from-stdin to keep the value out of shell history.").requiredOption("--function <name>", "Function name the secret binds to. Secrets are scoped per dispatched script — there is no project-wide secret in v1; every secret belongs to exactly one function. Use the same name as the entry passed to 'amba functions 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 --function fn --from-stdin`.").action(async (name, value, opts) => {
|
|
3636
|
+
await runAction(() => secretsSetCommand(name, value, {
|
|
3637
|
+
function: opts.function,
|
|
3638
|
+
env: opts.env ?? "dev",
|
|
3639
|
+
fromStdin: opts.fromStdin
|
|
3640
|
+
}));
|
|
3641
|
+
});
|
|
3642
|
+
secrets.command("list").description("List secret sync status for the current project").action(async () => {
|
|
3643
|
+
await runAction(secretsListCommand);
|
|
3644
|
+
});
|
|
3645
|
+
secrets.command("unset <name>").description("Remove a secret from GCP Secret Manager (Workers Secret cleared on next deploy)").requiredOption("--function <name>", "Function name the secret binds to").action(async (name, opts) => {
|
|
3646
|
+
await runAction(() => secretsUnsetCommand(name, opts));
|
|
3647
|
+
});
|
|
3648
|
+
const collections = program.command("collections").description("Customer collections (schema-first Postgres in tenant Neon)");
|
|
3649
|
+
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) => {
|
|
3650
|
+
await runAction(() => collectionsCreateCommand(name, opts));
|
|
3651
|
+
});
|
|
3652
|
+
collections.command("alter <name>").description("Add columns / indexes or drop columns on an existing collection").option("--add-field <spec>", "Column to add (name:type[:nullable]). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--add-index <spec>", "Index to add (\"col [asc|desc], …\"). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--drop-field <name>", "Column to drop (DESTRUCTIVE — requires --confirm <name>). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--confirm <name>", "Confirm a destructive --drop-field <name>. Repeatable.", (val, prev) => [...prev ?? [], val], []).action(async (name, opts) => {
|
|
3653
|
+
await runAction(() => collectionsAlterCommand(name, opts));
|
|
3654
|
+
});
|
|
3655
|
+
collections.command("list").description("List collections for the current project").action(async () => {
|
|
3656
|
+
await runAction(collectionsListCommand);
|
|
3657
|
+
});
|
|
3658
|
+
collections.command("drop <name>").description("Drop a collection (DESTRUCTIVE — requires --confirm <name>)").option("--confirm <name>", "Pass the collection name to confirm the drop").action(async (name, opts) => {
|
|
3659
|
+
await runAction(() => collectionsDropCommand(name, opts));
|
|
3660
|
+
});
|
|
3661
|
+
program.command("types").description("TypeScript codegen for collections").command("generate").description("Emit .amba/types.d.ts from the current collection schemas").option("--out <path>", "Output path (default: .amba/types.d.ts)").option("--watch", "Re-emit every 5s on schema changes").action(async (opts) => {
|
|
3662
|
+
await runAction(() => typesGenerateCommand(opts));
|
|
3663
|
+
});
|
|
3664
|
+
const sites = program.command("sites").description("Static site hosting");
|
|
3665
|
+
sites.command("deploy <dir>").description("Upload a built static-site directory to Pages-for-Platforms").option("--name <name>", "Site name (default: basename of dir, slug-cleaned)").option("--dry-run", "Scan + size-check without uploading").action(async (dir, opts) => {
|
|
3666
|
+
await runAction(() => sitesDeployCommand(dir, opts));
|
|
3667
|
+
});
|
|
3668
|
+
sites.command("list").description("List sites for the current project").action(async () => {
|
|
3669
|
+
await runAction(sitesListCommand);
|
|
3670
|
+
});
|
|
3671
|
+
sites.command("logs <name>").description("List recent CF Pages deployments for a site").action(async (name) => {
|
|
3672
|
+
await runAction(() => sitesLogsCommand(name));
|
|
3673
|
+
});
|
|
3674
|
+
sites.command("rollback <name>").description("Roll back a site to a previous deployment (default: previous successful)").option("--to <deployment_id>", "Specific deployment id to roll back to").action(async (name, opts) => {
|
|
3675
|
+
await runAction(() => sitesRollbackCommand(name, opts));
|
|
3676
|
+
});
|
|
3677
|
+
sites.command("disable <name>").description("Disable a site (control-plane only — CF Pages project remains)").action(async (name) => {
|
|
3678
|
+
await runAction(() => sitesDisableCommand(name));
|
|
3679
|
+
});
|
|
3680
|
+
sites.command("enable <name>").description("Re-enable a previously disabled site").action(async (name) => {
|
|
3681
|
+
await runAction(() => sitesEnableCommand(name));
|
|
3682
|
+
});
|
|
3683
|
+
sites.command("archive <name>").description("Archive a site (DESTRUCTIVE — deletes CF Pages project + custom hostnames)").option("--confirm <name>", "Pass the site name to confirm").action(async (name, opts) => {
|
|
3684
|
+
await runAction(() => sitesArchiveCommand(name, opts));
|
|
3685
|
+
});
|
|
3686
|
+
const sitesDomain = sites.command("domain").description("Manage custom hostnames per site");
|
|
3687
|
+
sitesDomain.command("add <hostname>").description("Attach a custom hostname (CF for SaaS — DV cert, polls until active)").requiredOption("--site <name>", "Site name to attach the hostname to").option("--zone-id <id>", "CF zone id (default: env CLOUDFLARE_AMBA_HOST_ZONE_ID)").option("--no-wait", "Skip the cert-status poll loop; return as soon as the row is recorded").option("--timeout <seconds>", "Cert poll timeout (default 600)", (v) => parseInt(v, 10)).action(async (hostname, opts) => {
|
|
3688
|
+
await runAction(() => sitesDomainAddCommand(hostname, {
|
|
3689
|
+
site: opts.site,
|
|
3690
|
+
zoneId: opts.zoneId,
|
|
3691
|
+
noWait: opts.noWait,
|
|
3692
|
+
timeout: opts.timeout
|
|
3693
|
+
}));
|
|
3694
|
+
});
|
|
3695
|
+
sitesDomain.command("list <site>").description("List custom hostnames attached to a site").action(async (site) => {
|
|
3696
|
+
await runAction(() => sitesDomainListCommand(site));
|
|
3697
|
+
});
|
|
3698
|
+
sitesDomain.command("remove <hostname>").description("Detach a custom hostname (best-effort CF detach + control-plane row delete)").requiredOption("--site <name>", "Site name the hostname is attached to").option("--zone-id <id>", "CF zone id (default: env CLOUDFLARE_AMBA_HOST_ZONE_ID)").action(async (hostname, opts) => {
|
|
3699
|
+
await runAction(() => sitesDomainRemoveCommand(hostname, {
|
|
3700
|
+
site: opts.site,
|
|
3701
|
+
zoneId: opts.zoneId
|
|
3702
|
+
}));
|
|
3703
|
+
});
|
|
3704
|
+
const aiProviders = program.command("ai").description("AI gateway — manage provider keys + prompts").command("providers").description("Per-project provider key registration (Anthropic, OpenAI)");
|
|
3705
|
+
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) => {
|
|
3706
|
+
await runAction(() => aiProvidersAddCommand(provider, opts));
|
|
3707
|
+
});
|
|
3708
|
+
aiProviders.command("list").description("List registered AI providers for the current project").action(async () => {
|
|
3709
|
+
await runAction(aiProvidersListCommand);
|
|
3710
|
+
});
|
|
3711
|
+
aiProviders.command("delete <provider>").description("Remove a provider registration (refuses if active prompts reference it)").action(async (provider) => {
|
|
3712
|
+
await runAction(() => aiProvidersDeleteCommand(provider));
|
|
3713
|
+
});
|
|
3714
|
+
program.parse();
|
|
3715
|
+
//#endregion
|
|
3716
|
+
export {};
|