@layers/amba 4.0.2 → 4.0.4

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/dist/index.js CHANGED
@@ -8,11 +8,460 @@ import { createServer } from "node:http";
8
8
  import open from "open";
9
9
  import { createHash, randomBytes } from "node:crypto";
10
10
  import { createInterface } from "node:readline";
11
- import { fileURLToPath } from "node:url";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { createWriteStream, watch } from "node:fs";
13
13
  import { spawn } from "node:child_process";
14
14
  import { build } from "esbuild";
15
- //#region ../shared/dist/index.js
15
+ new Map([
16
+ {
17
+ type: "user.created",
18
+ plane: "app",
19
+ description: "An app user (end user) was created in the project.",
20
+ payloadExample: {
21
+ app_user_id: "usr_123",
22
+ external_id: "auth0|abc",
23
+ created_at: "2026-06-03T00:00:00Z"
24
+ },
25
+ status: "live"
26
+ },
27
+ {
28
+ type: "user.updated",
29
+ plane: "app",
30
+ description: "An app user's profile or attributes changed.",
31
+ payloadExample: {
32
+ app_user_id: "usr_123",
33
+ changed: ["display_name"]
34
+ },
35
+ status: "live"
36
+ },
37
+ {
38
+ type: "user.deleted",
39
+ plane: "app",
40
+ description: "An app user was deleted.",
41
+ payloadExample: { app_user_id: "usr_123" },
42
+ status: "live"
43
+ },
44
+ {
45
+ type: "user.reset",
46
+ plane: "app",
47
+ description: "An app user's gamification, economy, engagement and participation state was reset to zero (the user row itself was kept).",
48
+ payloadExample: {
49
+ app_user_id: "usr_123",
50
+ cleared: {
51
+ user_xp: 1,
52
+ xp_ledger: 12
53
+ }
54
+ },
55
+ status: "live"
56
+ },
57
+ {
58
+ type: "engagement.push.delivered",
59
+ plane: "app",
60
+ description: "A push notification was delivered to a device.",
61
+ payloadExample: {
62
+ app_user_id: "usr_123",
63
+ campaign_id: "cmp_1",
64
+ delivered_at: "2026-06-03T00:00:00Z"
65
+ },
66
+ status: "live"
67
+ },
68
+ {
69
+ type: "engagement.push.failed",
70
+ plane: "app",
71
+ description: "A push notification failed to deliver.",
72
+ payloadExample: {
73
+ app_user_id: "usr_123",
74
+ campaign_id: "cmp_1",
75
+ reason: "unregistered_token"
76
+ },
77
+ status: "live"
78
+ },
79
+ {
80
+ type: "email.sent",
81
+ plane: "app",
82
+ description: "A transactional email was accepted by the provider for delivery.",
83
+ payloadExample: {
84
+ app_user_id: "usr_123",
85
+ template: "welcome",
86
+ to: "a@b.com"
87
+ },
88
+ status: "live"
89
+ },
90
+ {
91
+ type: "email.failed",
92
+ plane: "app",
93
+ description: "A transactional email failed to send.",
94
+ payloadExample: {
95
+ app_user_id: "usr_123",
96
+ template: "welcome",
97
+ reason: "bounce"
98
+ },
99
+ status: "live"
100
+ },
101
+ {
102
+ type: "email.suppressed",
103
+ plane: "app",
104
+ description: "An email was suppressed (recipient on the suppression list).",
105
+ payloadExample: {
106
+ app_user_id: "usr_123",
107
+ to: "a@b.com",
108
+ reason: "unsubscribed"
109
+ },
110
+ status: "live"
111
+ },
112
+ {
113
+ type: "gamification.xp.granted",
114
+ plane: "app",
115
+ description: "XP was granted to a user.",
116
+ payloadExample: {
117
+ app_user_id: "usr_123",
118
+ amount: 50,
119
+ total_xp: 1250,
120
+ rule: "mission_completed",
121
+ transaction_id: "xpl_abc"
122
+ },
123
+ status: "live"
124
+ },
125
+ {
126
+ type: "gamification.level.up",
127
+ plane: "app",
128
+ description: "A user crossed an XP threshold into a new level.",
129
+ payloadExample: {
130
+ app_user_id: "usr_123",
131
+ level: 7,
132
+ previous_level: 6
133
+ },
134
+ status: "live"
135
+ },
136
+ {
137
+ type: "gamification.achievement.unlocked",
138
+ plane: "app",
139
+ description: "A user unlocked an achievement.",
140
+ payloadExample: {
141
+ app_user_id: "usr_123",
142
+ achievement_id: "first_win",
143
+ unlocked_at: "2026-06-03T00:00:00Z"
144
+ },
145
+ status: "live"
146
+ },
147
+ {
148
+ type: "gamification.streak.advanced",
149
+ plane: "app",
150
+ description: "A user's streak advanced (a qualifying action extended it).",
151
+ payloadExample: {
152
+ app_user_id: "usr_123",
153
+ streak_id: "daily",
154
+ count: 12
155
+ },
156
+ status: "live"
157
+ },
158
+ {
159
+ type: "gamification.streak.broken",
160
+ plane: "app",
161
+ description: "A user's streak reset to zero after a missed window.",
162
+ payloadExample: {
163
+ app_user_id: "usr_123",
164
+ streak_id: "daily",
165
+ previous_count: 12
166
+ },
167
+ status: "live"
168
+ },
169
+ {
170
+ type: "gamification.challenge.completed",
171
+ plane: "app",
172
+ description: "A user completed a challenge.",
173
+ payloadExample: {
174
+ app_user_id: "usr_123",
175
+ challenge_id: "ch_1"
176
+ },
177
+ status: "live"
178
+ },
179
+ {
180
+ type: "economy.currency.granted",
181
+ plane: "app",
182
+ description: "Currency was granted to a user.",
183
+ payloadExample: {
184
+ app_user_id: "usr_123",
185
+ currency: "coins",
186
+ amount: 100,
187
+ balance: 350,
188
+ transaction_id: "ctx_abc"
189
+ },
190
+ status: "live"
191
+ },
192
+ {
193
+ type: "economy.currency.spent",
194
+ plane: "app",
195
+ description: "A user spent currency.",
196
+ payloadExample: {
197
+ app_user_id: "usr_123",
198
+ currency: "coins",
199
+ amount: 100,
200
+ balance: 250,
201
+ transaction_id: "ctx_def"
202
+ },
203
+ status: "live"
204
+ },
205
+ {
206
+ type: "economy.catalog.purchased",
207
+ plane: "app",
208
+ description: "A user purchased a catalog item.",
209
+ payloadExample: {
210
+ app_user_id: "usr_123",
211
+ item_id: "sword",
212
+ price: 100,
213
+ currency: "coins"
214
+ },
215
+ status: "live"
216
+ },
217
+ {
218
+ type: "economy.inventory.granted",
219
+ plane: "app",
220
+ description: "An item was granted into a user inventory.",
221
+ payloadExample: {
222
+ app_user_id: "usr_123",
223
+ item_id: "sword",
224
+ quantity: 1
225
+ },
226
+ status: "live"
227
+ },
228
+ {
229
+ type: "subscription.started",
230
+ plane: "app",
231
+ description: "A user gained an active subscription / entitlement.",
232
+ payloadExample: {
233
+ app_user_id: "usr_123",
234
+ entitlement_id: "pro",
235
+ product_id: "pro_yearly",
236
+ store: "app_store",
237
+ period_type: "normal"
238
+ },
239
+ status: "live"
240
+ },
241
+ {
242
+ type: "subscription.renewed",
243
+ plane: "app",
244
+ description: "An active subscription renewed for another billing period.",
245
+ payloadExample: {
246
+ app_user_id: "usr_123",
247
+ entitlement_id: "pro",
248
+ product_id: "pro_yearly",
249
+ store: "app_store"
250
+ },
251
+ status: "live"
252
+ },
253
+ {
254
+ type: "subscription.expired",
255
+ plane: "app",
256
+ description: "A subscription lapsed / was revoked and access ended.",
257
+ payloadExample: {
258
+ app_user_id: "usr_123",
259
+ entitlement_id: "pro",
260
+ product_id: "pro_yearly"
261
+ },
262
+ status: "live"
263
+ },
264
+ {
265
+ type: "entitlement.granted",
266
+ plane: "app",
267
+ description: "An entitlement became active for a user (any source).",
268
+ payloadExample: {
269
+ app_user_id: "usr_123",
270
+ entitlement_id: "pro",
271
+ product_id: "pro_yearly",
272
+ source: "webhook"
273
+ },
274
+ status: "live"
275
+ },
276
+ {
277
+ type: "entitlement.revoked",
278
+ plane: "app",
279
+ description: "An entitlement became inactive for a user (any source).",
280
+ payloadExample: {
281
+ app_user_id: "usr_123",
282
+ entitlement_id: "pro",
283
+ source: "manual"
284
+ },
285
+ status: "live"
286
+ },
287
+ {
288
+ type: "social.friendship.created",
289
+ plane: "app",
290
+ description: "A friendship was established between two users.",
291
+ payloadExample: {
292
+ app_user_id: "usr_123",
293
+ friend_id: "usr_456"
294
+ },
295
+ status: "live"
296
+ },
297
+ {
298
+ type: "social.message.sent",
299
+ plane: "app",
300
+ description: "A message was sent in a conversation.",
301
+ payloadExample: {
302
+ conversation_id: "cnv_1",
303
+ sender_id: "usr_123"
304
+ },
305
+ status: "live"
306
+ },
307
+ {
308
+ type: "social.review.created",
309
+ plane: "app",
310
+ description: "A review was submitted.",
311
+ payloadExample: {
312
+ app_user_id: "usr_123",
313
+ rating: 5,
314
+ target_id: "item_1"
315
+ },
316
+ status: "live"
317
+ },
318
+ {
319
+ type: "content.published",
320
+ plane: "app",
321
+ description: "A content item was published / went live.",
322
+ payloadExample: {
323
+ item_id: "post_1",
324
+ library: "news"
325
+ },
326
+ status: "live"
327
+ },
328
+ {
329
+ type: "moderation.flagged",
330
+ plane: "app",
331
+ description: "User content entered the moderation queue.",
332
+ payloadExample: {
333
+ queue_id: "mq_1",
334
+ content_type: "message",
335
+ reason: "auto_flag"
336
+ },
337
+ status: "live"
338
+ },
339
+ {
340
+ type: "moderation.rejected",
341
+ plane: "app",
342
+ description: "Moderated content was rejected.",
343
+ payloadExample: {
344
+ queue_id: "mq_1",
345
+ moderator: "auto"
346
+ },
347
+ status: "live"
348
+ },
349
+ {
350
+ type: "project.provisioning.started",
351
+ plane: "control",
352
+ description: "Project provisioning began (tenant DB + keys being created).",
353
+ payloadExample: { project_id: "prj_1" },
354
+ status: "live"
355
+ },
356
+ {
357
+ type: "project.provisioned",
358
+ plane: "control",
359
+ description: "The project finished provisioning and is ready to use.",
360
+ payloadExample: {
361
+ project_id: "prj_1",
362
+ provisioned_at: "2026-06-03T00:00:00Z"
363
+ },
364
+ status: "live"
365
+ },
366
+ {
367
+ type: "project.provisioning.failed",
368
+ plane: "control",
369
+ description: "Project provisioning failed and was compensated.",
370
+ payloadExample: {
371
+ project_id: "prj_1",
372
+ reason: "neon_quota"
373
+ },
374
+ status: "live"
375
+ },
376
+ {
377
+ type: "site.deploy.completed",
378
+ plane: "control",
379
+ description: "A static site deploy completed and is serving.",
380
+ payloadExample: {
381
+ site: "marketing",
382
+ deployment_id: "dep_1",
383
+ url: "https://x.app.amba.host",
384
+ file_count: 4,
385
+ serving_configured: true
386
+ },
387
+ status: "live"
388
+ },
389
+ {
390
+ type: "site.deploy.failed",
391
+ plane: "control",
392
+ description: "A static site deploy failed.",
393
+ payloadExample: {
394
+ project_id: "prj_1",
395
+ site: "marketing",
396
+ reason: "storage_write_failed"
397
+ },
398
+ status: "live"
399
+ },
400
+ {
401
+ type: "domain.registered",
402
+ plane: "control",
403
+ description: "A domain was registered through Amba.",
404
+ payloadExample: {
405
+ domain: "myapp.com",
406
+ site: "marketing"
407
+ },
408
+ status: "live"
409
+ },
410
+ {
411
+ type: "domain.cert.issued",
412
+ plane: "control",
413
+ description: "The TLS certificate for an attached domain became active.",
414
+ payloadExample: {
415
+ project_id: "prj_1",
416
+ domain: "app.myapp.com"
417
+ },
418
+ status: "live"
419
+ },
420
+ {
421
+ type: "domain.connected",
422
+ plane: "control",
423
+ description: "A domain is fully connected to a site and serving.",
424
+ payloadExample: {
425
+ project_id: "prj_1",
426
+ domain: "myapp.com",
427
+ site: "marketing"
428
+ },
429
+ status: "live"
430
+ },
431
+ {
432
+ type: "billing.subscription.updated",
433
+ plane: "control",
434
+ description: "The project's subscription state changed.",
435
+ payloadExample: {
436
+ project_id: "prj_1",
437
+ tier: "pro",
438
+ status: "active"
439
+ },
440
+ status: "live"
441
+ },
442
+ {
443
+ type: "billing.invoice.paid",
444
+ plane: "control",
445
+ description: "An invoice was paid.",
446
+ payloadExample: {
447
+ project_id: "prj_1",
448
+ invoice_id: "in_1",
449
+ amount_usd: 49
450
+ },
451
+ status: "live"
452
+ },
453
+ {
454
+ type: "billing.payment_failed",
455
+ plane: "control",
456
+ description: "A payment attempt failed.",
457
+ payloadExample: {
458
+ project_id: "prj_1",
459
+ invoice_id: "in_1",
460
+ reason: "card_declined"
461
+ },
462
+ status: "live"
463
+ }
464
+ ].map((e) => [e.type, e]));
16
465
  /**
17
466
  * Shared email-shape validation.
18
467
  *
@@ -377,7 +826,7 @@ async function request(method, path, body) {
377
826
  try {
378
827
  const errorBody = await res.json();
379
828
  if (errorBody.error?.message) {
380
- errorMessage = errorBody.error.message;
829
+ errorMessage = `${res.status} — ${errorBody.error.message}`;
381
830
  errorCode = errorBody.error.code;
382
831
  }
383
832
  } catch {}
@@ -498,7 +947,7 @@ async function streamUsersExport(projectId, query = {}) {
498
947
  try {
499
948
  const errorBody = await res.json();
500
949
  if (errorBody.error?.message) {
501
- errorMessage = errorBody.error.message;
950
+ errorMessage = `${res.status} — ${errorBody.error.message}`;
502
951
  errorCode = errorBody.error.code;
503
952
  }
504
953
  } catch {}
@@ -529,7 +978,8 @@ async function deployFunctionViaApi(projectId, input) {
529
978
  fd.set("script", new Blob([input.bundleCode], { type: "application/javascript+module" }), `${input.name}.js`);
530
979
  const metadata = {
531
980
  name: input.name,
532
- ...input.rate_limit !== void 0 ? { rate_limit: input.rate_limit } : {}
981
+ ...input.rate_limit !== void 0 ? { rate_limit: input.rate_limit } : {},
982
+ ...input.public === true ? { public: true } : {}
533
983
  };
534
984
  fd.set("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }), "metadata.json");
535
985
  return request("POST", `/projects/${projectId}/functions/deploy`, fd);
@@ -545,6 +995,7 @@ async function getFunctionLogs(projectId, functionName, options = {}) {
545
995
  if (options.since) params.set("since", options.since);
546
996
  if (options.until) params.set("until", options.until);
547
997
  if (options.limit !== void 0) params.set("limit", String(options.limit));
998
+ if (options.requestId) params.set("request_id", options.requestId);
548
999
  const qs = params.toString();
549
1000
  return request("GET", `/projects/${projectId}/functions/${functionName}/logs${qs ? `?${qs}` : ""}`);
550
1001
  }
@@ -560,12 +1011,12 @@ async function deleteQueueBinding(projectId, queueName) {
560
1011
  async function setSecretViaApi(projectId, input) {
561
1012
  return request("POST", `/projects/${projectId}/secrets`, input);
562
1013
  }
563
- async function listSecretsViaApi(projectId) {
564
- return request("GET", `/projects/${projectId}/secrets`);
1014
+ async function listSecretsViaApi(projectId, options = {}) {
1015
+ return request("GET", `/projects/${projectId}/secrets${options.function ? `?${new URLSearchParams({ function: options.function }).toString()}` : ""}`);
565
1016
  }
566
- async function deleteSecretViaApi(projectId, name, options) {
567
- const qs = new URLSearchParams({ function: options.function });
568
- return request("DELETE", `/projects/${projectId}/secrets/${encodeURIComponent(name)}?${qs.toString()}`);
1017
+ async function deleteSecretViaApi(projectId, name, options = {}) {
1018
+ const qs = options.function ? `?${new URLSearchParams({ function: options.function }).toString()}` : "";
1019
+ return request("DELETE", `/projects/${projectId}/secrets/${encodeURIComponent(name)}${qs}`);
569
1020
  }
570
1021
  async function createCollection(projectId, input) {
571
1022
  return request("POST", `/projects/${projectId}/collections`, input);
@@ -645,6 +1096,20 @@ async function rollbackSiteViaApi(projectId, siteName, deploymentId) {
645
1096
  async function deleteSiteViaApi(projectId, siteName, options) {
646
1097
  return request("DELETE", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}?confirm=${encodeURIComponent(options.confirm)}`);
647
1098
  }
1099
+ async function searchDomainsViaApi(projectId, query, limit) {
1100
+ const params = new URLSearchParams({ q: query });
1101
+ if (limit !== void 0) params.set("limit", String(limit));
1102
+ return request("GET", `/projects/${projectId}/domains/search?${params.toString()}`);
1103
+ }
1104
+ async function checkDomainsViaApi(projectId, domains) {
1105
+ return request("POST", `/projects/${projectId}/domains/check`, { domains });
1106
+ }
1107
+ async function purchaseDomainViaApi(projectId, body) {
1108
+ return request("POST", `/projects/${projectId}/domains/purchase`, body);
1109
+ }
1110
+ async function listPurchasedDomainsViaApi(projectId) {
1111
+ return request("GET", `/projects/${projectId}/domains`);
1112
+ }
648
1113
  /**
649
1114
  * Delete a function entirely. Cascade: removes the deployed script
650
1115
  * (backend 404 treated as success), then marks every historical
@@ -658,6 +1123,17 @@ async function deleteFunctionViaApi(projectId, functionName, options) {
658
1123
  async function listSiteDomains(projectId, siteName) {
659
1124
  return request("GET", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/domains`);
660
1125
  }
1126
+ /** GET the current project's declarative configuration bundle. */
1127
+ async function exportPromotionBundle(projectId) {
1128
+ return request("GET", `/projects/${projectId}/promotion/export`);
1129
+ }
1130
+ /** POST a declarative bundle to import it into the project. */
1131
+ async function importPromotionBundle(projectId, bundle, mode) {
1132
+ return request("POST", `/projects/${projectId}/promotion/import`, {
1133
+ bundle,
1134
+ mode
1135
+ });
1136
+ }
661
1137
  async function validateApiKey(apiKey) {
662
1138
  const res = await fetch(`${getApiRoot()}/v1/auth/validate`, {
663
1139
  method: "POST",
@@ -2135,6 +2611,14 @@ Tell the user where their credentials live:
2135
2611
 
2136
2612
  **Already have a PAT?** Skip the signup. Call \`amba_developer_me({})\` to verify the Bearer; if it succeeds, either reuse the most recent project (\`amba_projects_list\`) or call \`amba_projects_create({ name: "<app-name>", platform: "all" })\` and then \`amba_api_keys_create\` twice to mint client + server keys for \`environment: "development"\`.
2137
2613
 
2614
+ **Can't reach \`amba_developer_signup\` — it's not in your tool list, or your harness blocks the call?** Do NOT try to work around it by adding the MCP server yourself, running an installer, or editing your own settings/permissions — refusing to self-modify or widen your own permissions is the correct call, and you should not circumvent it. Instead, hand the developer **one** command to run in **their own** terminal (which is not subject to your sandbox):
2615
+
2616
+ \`\`\`bash
2617
+ npx @layers/amba init
2618
+ \`\`\`
2619
+
2620
+ It creates the account with no browser and writes \`AMBA_PROJECT_ID\` + \`AMBA_CLIENT_KEY\` to \`.env.local\` (and wires this MCP server up for your next session). Ask them to run it, tell you when it's done, then resume from this Step 0 by reading \`.env.local\`. Asking the developer to paste an \`amb_ck_…\` client key is the last-resort lite path — it's enough to wire \`Amba.configure()\` + runtime calls and verify events land, but a client key alone can't do admin provisioning (creating achievements, collections, segments, etc.), which needs the PAT or server key.
2621
+
2138
2622
  ## Step 1 — Classify the app
2139
2623
 
2140
2624
  Look at what the user told you and at any files they shared. You're trying to pick one of ten presets in 30 seconds, not write a treatise. Inputs:
@@ -2256,7 +2740,7 @@ SKIPPED (low signal — re-run with /amba <feature> if you want them)
2256
2740
  NEEDS YOUR INPUT
2257
2741
  - Apple Sign In: add the "Sign in with Apple" capability in Xcode > Signing & Capabilities.
2258
2742
  - Google Sign In: paste your Google OAuth client ID into amba_projects_update({ google_oauth_client_id: "..." }).
2259
- - APNs / FCM: upload credentials in app.amba.dev before push delivers.
2743
+ - APNs / FCM: configure credentials with amba_integrations_configure (paste the .p8 / service-account JSON inline) before push delivers.
2260
2744
 
2261
2745
  NEXT STEPS
2262
2746
  - Paste AMBA_CLIENT_KEY into your build env (already shown above)
@@ -2318,7 +2802,7 @@ All SDKs expose the same surface: \`Amba.configure({ projectId, apiKey })\`, the
2318
2802
  - **challenges** — time-bounded goals. Define with \`amba_challenges_create\`; progress via SDK.
2319
2803
 
2320
2804
  ### Economy
2321
- - **currencies** — virtual currencies (coins, gems). Define with \`amba_currencies_create\`; grant via \`amba_currencies_grant\` or event rules via \`amba_currency_grant_rules_create\`.
2805
+ - **currencies** — virtual currencies (coins, gems). Define with \`amba_currencies_create\`; grant via \`amba_currencies_grant\` or event rules via \`amba_currency_grant_rules_create\`; debit via \`amba_currencies_spend\` (atomic, rejects on insufficient funds).
2322
2806
  - **catalog + stores** — purchasable items + storefronts. Chain: \`amba_catalog_items_create\` → \`amba_catalog_items_set_price\` → \`amba_stores_create\` → \`amba_stores_add_listing\`. (Define currency first.)
2323
2807
  - **inventory** — items users own. Read via SDK \`Amba.inventory.*\`; grant with \`amba_inventory_grant_item\`.
2324
2808
 
@@ -2341,7 +2825,7 @@ All SDKs expose the same surface: \`Amba.configure({ projectId, apiKey })\`, the
2341
2825
  - **sites** — static site hosting at \`*.app.amba.host\`. Deploy with \`amba_sites_deploy\`.
2342
2826
  - **media** — file storage + CDN. Upload via \`amba_media_upload\`.
2343
2827
  - **secrets** — env vars for functions. Set via \`amba_secrets_set\`.
2344
- - **configs** — remote config flags. Define with \`amba_configs_create\`.
2828
+ - **configs** — remote config flags. Define with \`amba_remote_configs_create\`.
2345
2829
  - **integrations** — third-party webhooks (RevenueCat, Superwall, AppsFlyer, etc.). Configure with \`amba_integrations_configure\`.
2346
2830
  - **ai_prompts** — versioned LLM prompts callable from SDK. Define with \`amba_ai_prompts_create\`; call via \`amba_ai_prompts_invoke\`.
2347
2831
  `;
@@ -4055,7 +4539,7 @@ async function configSetCommand(key, rawValue) {
4055
4539
  }
4056
4540
  //#endregion
4057
4541
  //#region src/commands/projects.ts
4058
- function confirm(question) {
4542
+ function confirm$1(question) {
4059
4543
  const rl = createInterface({
4060
4544
  input: process.stdin,
4061
4545
  output: process.stdout
@@ -4067,7 +4551,7 @@ function confirm(question) {
4067
4551
  });
4068
4552
  });
4069
4553
  }
4070
- function handleError$1(err) {
4554
+ function handleError$2(err) {
4071
4555
  if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
4072
4556
  else console.log(pc.red(" ✗") + ` ${err.message}`);
4073
4557
  else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
@@ -4117,7 +4601,7 @@ async function projectsListCommand() {
4117
4601
  console.log(pc.dim(` ${projects.length} project${projects.length === 1 ? "" : "s"}`));
4118
4602
  console.log();
4119
4603
  } catch (err) {
4120
- handleError$1(err);
4604
+ handleError$2(err);
4121
4605
  }
4122
4606
  }
4123
4607
  async function projectsCreateCommand(input) {
@@ -4163,7 +4647,7 @@ async function projectsCreateCommand(input) {
4163
4647
  console.log(pc.dim(" Next: ") + pc.cyan(`amba projects show ${id}`));
4164
4648
  console.log();
4165
4649
  } catch (err) {
4166
- handleError$1(err);
4650
+ handleError$2(err);
4167
4651
  }
4168
4652
  }
4169
4653
  async function projectsUpdateCommand(projectId, input) {
@@ -4189,7 +4673,7 @@ async function projectsUpdateCommand(projectId, input) {
4189
4673
  console.log();
4190
4674
  process.exit(1);
4191
4675
  }
4192
- handleError$1(err);
4676
+ handleError$2(err);
4193
4677
  }
4194
4678
  }
4195
4679
  async function projectsShowCommand(projectId) {
@@ -4207,7 +4691,7 @@ async function projectsShowCommand(projectId) {
4207
4691
  console.log();
4208
4692
  process.exit(1);
4209
4693
  }
4210
- handleError$1(err);
4694
+ handleError$2(err);
4211
4695
  }
4212
4696
  }
4213
4697
  async function projectsDeleteCommand(projectId, opts = {}) {
@@ -4216,7 +4700,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
4216
4700
  console.log(pc.dim(" ─────────────────────────────────"));
4217
4701
  console.log();
4218
4702
  if (!opts.yes) {
4219
- if (!await confirm(pc.yellow(` Delete project ${projectId}? This is irreversible. (y/N) `))) {
4703
+ if (!await confirm$1(pc.yellow(` Delete project ${projectId}? This is irreversible. (y/N) `))) {
4220
4704
  console.log(pc.dim(" Aborted."));
4221
4705
  console.log();
4222
4706
  return;
@@ -4232,7 +4716,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
4232
4716
  console.log();
4233
4717
  process.exit(1);
4234
4718
  }
4235
- handleError$1(err);
4719
+ handleError$2(err);
4236
4720
  }
4237
4721
  }
4238
4722
  //#endregion
@@ -4975,6 +5459,47 @@ function parseEnv(content) {
4975
5459
  * empty; customer code is expected to be self-contained.
4976
5460
  */
4977
5461
  /**
5462
+ * Conventional entry-point filenames probed (in order) when
5463
+ * `amba functions deploy <dir>` is pointed at a directory rather than a
5464
+ * single file. Mirrors the Node / bundler convention (`index.*`) so a
5465
+ * developer can `amba functions deploy ./my-fn` with an `index.ts` inside.
5466
+ */
5467
+ const FUNCTION_ENTRY_CANDIDATES = [
5468
+ "index.ts",
5469
+ "index.js",
5470
+ "index.mjs"
5471
+ ];
5472
+ /**
5473
+ * Resolve the bundler entry point from a deploy target that may be either a
5474
+ * single file OR a directory.
5475
+ *
5476
+ * - File → returned as-is (single-file back-compat path).
5477
+ * - Dir → resolved by convention to `index.{ts,js,mjs}`, or to an
5478
+ * explicit `entry` (relative to the directory) when provided.
5479
+ *
5480
+ * Throws an actionable error when the target doesn't exist, or when a
5481
+ * directory has no resolvable entry point (and no `--entry` was passed).
5482
+ */
5483
+ async function resolveFunctionEntry(target, explicitEntry) {
5484
+ const st = await stat(target).catch(() => null);
5485
+ if (!st) throw new Error(`Deploy target not found: ${target}`);
5486
+ if (st.isFile()) {
5487
+ if (explicitEntry) throw new Error(`--entry is only valid when deploying a directory; ${target} is a file. Drop --entry, or point the deploy at the directory containing ${explicitEntry}.`);
5488
+ return target;
5489
+ }
5490
+ if (!st.isDirectory()) throw new Error(`Deploy target is neither a file nor a directory: ${target}`);
5491
+ if (explicitEntry) {
5492
+ const candidate = join(target, explicitEntry);
5493
+ if (!(await stat(candidate).catch(() => null))?.isFile()) throw new Error(`--entry file not found: ${candidate}`);
5494
+ return candidate;
5495
+ }
5496
+ for (const name of FUNCTION_ENTRY_CANDIDATES) {
5497
+ const candidate = join(target, name);
5498
+ if ((await stat(candidate).catch(() => null))?.isFile()) return candidate;
5499
+ }
5500
+ throw new Error(`No entry point found in directory ${target}. Add one of ${FUNCTION_ENTRY_CANDIDATES.join(", ")}, or pass --entry <file> (relative to the directory).`);
5501
+ }
5502
+ /**
4978
5503
  * Modules the bundler treats as `external` by default. The Amba
4979
5504
  * function runtime exposes zero npm packages — there is no "platform
4980
5505
  * stdlib" for customer functions to import. Keep this list empty.
@@ -4997,6 +5522,91 @@ var BundleSizeError = class extends Error {
4997
5522
  }
4998
5523
  };
4999
5524
  /**
5525
+ * The single source-of-truth message for the wrong-handler-shape failure.
5526
+ * Shared between the CLI bundler (pre-upload), the local `dev` runner, and
5527
+ * the server-side deploy hint so dev / deploy / prod all describe the fix
5528
+ * identically. Customer functions run as Module Workers; the legacy
5529
+ * service-worker shape (`export default async function (req) { … }`) is
5530
+ * rejected by the runtime at upload, so we reject it before the round-trip.
5531
+ */
5532
+ const HANDLER_SHAPE_FIX_MESSAGE = "Function entrypoint must `export default { async fetch(req, env, ctx) { … } }` (the Module Worker shape). Found a default-exported function instead.";
5533
+ /** Message used when the entry file has no `export default` at all. */
5534
+ const HANDLER_SHAPE_MISSING_DEFAULT_MESSAGE = "Function entrypoint must `export default { async fetch(req, env, ctx) { … } }` (the Module Worker shape). No default export was found.";
5535
+ /** Message used when the default export is present but has no `fetch` method. */
5536
+ const HANDLER_SHAPE_NO_FETCH_MESSAGE = "Function entrypoint must `export default { async fetch(req, env, ctx) { … } }` (the Module Worker shape). The default export has no `fetch` method.";
5537
+ /**
5538
+ * Thrown when the bundled entry's `export default` is not the Module Worker
5539
+ * shape (`{ fetch(req, env, ctx) }`). Raised BEFORE any upload so a wrong
5540
+ * handler shape fails fast on the developer's machine with an actionable
5541
+ * message instead of an opaque upstream rejection at deploy time.
5542
+ */
5543
+ var HandlerShapeError = class extends Error {
5544
+ kind;
5545
+ constructor(message, kind) {
5546
+ super(message);
5547
+ this.kind = kind;
5548
+ this.name = "HandlerShapeError";
5549
+ }
5550
+ };
5551
+ /**
5552
+ * Inspect an already-imported module namespace and assert it has the
5553
+ * Module Worker handler shape: a default export that is an object (or
5554
+ * function-with-properties) carrying a callable `fetch`.
5555
+ *
5556
+ * Pure + side-effect free — it only reads the shape of `mod.default`, never
5557
+ * invokes `fetch`. This is the unit-testable core; `assertModuleWorkerShape`
5558
+ * wraps the import-from-code plumbing around it.
5559
+ *
5560
+ * Throws `HandlerShapeError` on:
5561
+ * - no default export (`missing-default`)
5562
+ * - a default-exported plain function — the legacy service-worker shape
5563
+ * (`plain-function`)
5564
+ * - a default export with no callable `fetch` (`no-fetch`)
5565
+ */
5566
+ function assertHandlerShape(mod) {
5567
+ const def = mod.default;
5568
+ if (def === void 0 || def === null) throw new HandlerShapeError(HANDLER_SHAPE_MISSING_DEFAULT_MESSAGE, "missing-default");
5569
+ if (typeof def === "function") {
5570
+ if (typeof def.fetch !== "function") throw new HandlerShapeError(HANDLER_SHAPE_FIX_MESSAGE, "plain-function");
5571
+ return;
5572
+ }
5573
+ if (typeof def !== "object") throw new HandlerShapeError(HANDLER_SHAPE_NO_FETCH_MESSAGE, "no-fetch");
5574
+ if (typeof def.fetch !== "function") throw new HandlerShapeError(HANDLER_SHAPE_NO_FETCH_MESSAGE, "no-fetch");
5575
+ }
5576
+ /**
5577
+ * Validate that the bundled customer code exports the Module Worker handler
5578
+ * shape, by importing it in isolation and inspecting `mod.default`.
5579
+ *
5580
+ * Mechanism: write the bundle to a throwaway temp `.mjs` and dynamic-import
5581
+ * it, then hand the namespace to `assertHandlerShape`. This is the same
5582
+ * import-and-inspect approach the local `dev` runner uses
5583
+ * (`functions.ts` DEV_RUNNER_SOURCE) — reused here so the pre-upload check
5584
+ * and the dev runner agree on what "valid shape" means. Importing runs the
5585
+ * module's top-level code (unavoidable to read the export), but the handler
5586
+ * itself is NEVER invoked — we only read the shape of the exported object.
5587
+ *
5588
+ * The temp file is always cleaned up (best-effort) before returning.
5589
+ */
5590
+ async function assertModuleWorkerShape(code) {
5591
+ const dir = await mkdtemp(join(tmpdir(), "amba-fn-shape-"));
5592
+ const filePath = join(dir, "fn.mjs");
5593
+ try {
5594
+ await writeFile(filePath, code, "utf8");
5595
+ let mod;
5596
+ try {
5597
+ mod = await import(pathToFileURL(filePath).href);
5598
+ } catch (err) {
5599
+ throw new Error(`Failed to load the function entrypoint while validating its handler shape: ${err instanceof Error ? err.message : String(err)}`);
5600
+ }
5601
+ assertHandlerShape(mod);
5602
+ } finally {
5603
+ await rm(dir, {
5604
+ recursive: true,
5605
+ force: true
5606
+ }).catch(() => {});
5607
+ }
5608
+ }
5609
+ /**
5000
5610
  * Bundle a customer function file for upload to a Workers dispatch
5001
5611
  * namespace. Returns the bundled code + metadata; throws
5002
5612
  * `BundleSizeError` when the compressed size exceeds the cap so the CLI
@@ -5029,6 +5639,7 @@ async function bundleFunction(options) {
5029
5639
  const compressedSize = await gzipSizeOf(output.contents);
5030
5640
  const max = options.maxSizeBytes ?? 8388608;
5031
5641
  if (compressedSize > max) throw new BundleSizeError(compressedSize, max);
5642
+ if (options.validateHandlerShape !== false) await assertModuleWorkerShape(code);
5032
5643
  return {
5033
5644
  code,
5034
5645
  sha256: await hashSha256Hex(output.contents),
@@ -5077,16 +5688,19 @@ function formatBytes$1(n) {
5077
5688
  * records the deployment in a single round-trip. The other subcommands
5078
5689
  * are thin shells over the admin API helpers in `api-client.ts`.
5079
5690
  */
5080
- async function functionsDeployCommand(entryPoint, options = {}) {
5691
+ async function functionsDeployCommand(target, options = {}) {
5081
5692
  const projectId = (await loadProjectConfig()).projectId;
5082
- const functionName = options.name ?? basename(entryPoint).replace(/\.[^.]+$/, "");
5693
+ const targetIsDir = (await stat(target).catch(() => null))?.isDirectory() ?? false;
5694
+ const entryPoint = await resolveFunctionEntry(target, options.entry);
5695
+ const defaultName = targetIsDir ? basename(target.replace(/[/\\]+$/, "")) : basename(entryPoint).replace(/\.[^.]+$/, "");
5696
+ const functionName = options.name ?? defaultName;
5083
5697
  validateFunctionName(functionName);
5084
5698
  const rateLimit = parseRateLimitFlags(options);
5085
5699
  console.log();
5086
5700
  console.log(pc.bold(` amba functions deploy ${pc.cyan(functionName)}`));
5087
5701
  console.log(pc.dim(" ─────────────────────────────────"));
5088
5702
  console.log();
5089
- console.log(pc.dim(" Bundling…"));
5703
+ console.log(pc.dim(` Bundling ${entryPoint}…`));
5090
5704
  const bundle = await bundleFunction({ entryPoint });
5091
5705
  printBundleReport(bundle);
5092
5706
  if (options.dryRun) {
@@ -5094,14 +5708,26 @@ async function functionsDeployCommand(entryPoint, options = {}) {
5094
5708
  return;
5095
5709
  }
5096
5710
  console.log(pc.dim(" Uploading…"));
5097
- const result = await deployFunctionViaApi(projectId, {
5098
- name: functionName,
5099
- bundleCode: bundle.code,
5100
- rate_limit: rateLimit
5101
- });
5711
+ let result;
5712
+ try {
5713
+ result = await deployFunctionViaApi(projectId, {
5714
+ name: functionName,
5715
+ bundleCode: bundle.code,
5716
+ rate_limit: rateLimit,
5717
+ public: options.public === true ? true : void 0
5718
+ });
5719
+ } catch (err) {
5720
+ if (err instanceof ApiClientError && err.statusCode === 503) {
5721
+ const base = err.message.trimEnd();
5722
+ const sep = /[.!?]$/.test(base) ? " " : ". ";
5723
+ throw new Error(`${base}${sep}The function platform is temporarily unavailable; retry in ~30s.`);
5724
+ }
5725
+ throw err;
5726
+ }
5102
5727
  console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version}`)}`);
5103
5728
  console.log(pc.green(" ✓") + ` URL: ${pc.underline(result.fn_url)}`);
5104
5729
  if (rateLimit) console.log(pc.dim(` Rate limit: ${rateLimit.max} per ${rateLimit.window} (key=${rateLimit.key}) — enforced pre-dispatch`));
5730
+ if (options.public === true) console.log(pc.yellow(" ! Public") + pc.dim(" — no X-Api-Key required. Verify the webhook signature inside your handler."));
5105
5731
  console.log();
5106
5732
  }
5107
5733
  async function functionsListCommand() {
@@ -5153,11 +5779,12 @@ async function functionsScheduleCommand(name, cron, options = {}) {
5153
5779
  * — externalization rules, size cap, and source-map handling are
5154
5780
  * identical so dev → prod parity is automatic.
5155
5781
  *
5156
- * Handler shape: `export default async function (req: Request): Promise<Response>`.
5782
+ * Handler shape: `export default { async fetch(req, env, ctx) { ... } }`.
5157
5783
  * `.env.local` values in the customer's working directory are passed
5158
- * through to the child process so the handler can read them via
5159
- * `process.env.MY_KEY`. Existing values in the parent's env take
5160
- * priority (so `MY_KEY=x amba functions dev …` wins).
5784
+ * through to the child process and bridged into the `env` parameter so
5785
+ * the handler reads them the same way as in production (`env.MY_KEY`).
5786
+ * Existing values in the parent's env take priority (so
5787
+ * `MY_KEY=x amba functions dev …` wins).
5161
5788
  */
5162
5789
  async function functionsDevCommand(entryPoint, options = {}) {
5163
5790
  const port = validatePort(options.port);
@@ -5432,11 +6059,41 @@ try {
5432
6059
  // \`pathToFileURL\` is a no-op equivalent on POSIX (returns file:///...).
5433
6060
  const bundleUrl = pathToFileURL(bundlePath).href;
5434
6061
  const mod = await import(bundleUrl);
5435
- if (typeof mod.default !== 'function') {
5436
- process.stderr.write(' Error: entry file must export default async function (req: Request): Promise<Response>\\n');
6062
+ // Support the canonical modules export shape: export default { async fetch(req, env, ctx) { ... } }
6063
+ // .env.local values are already in process.env (parent passes childEnv to the child process).
6064
+ // Bridge them into the env parameter so dev/prod parity holds — production injects the same
6065
+ // names as env bindings; local dev reads them from .env.local via process.env.
6066
+ if (mod.default && typeof mod.default.fetch === 'function') {
6067
+ const env = Object.assign(Object.create(null), process.env);
6068
+ const fetchFn = mod.default.fetch.bind(mod.default);
6069
+ handler = (req) => {
6070
+ // Fresh ctx per request — production gives each request its own
6071
+ // ExecutionContext; a shared stub would leak per-request state
6072
+ // across concurrent requests in dev but not in prod.
6073
+ const ctx = {
6074
+ waitUntil: (p) => {
6075
+ // Swallow rejections from background promises — prod handles
6076
+ // waitUntil rejections without crashing. Leaving the rejection
6077
+ // unhandled would cause Node 15+ to crash the child process
6078
+ // even though the HTTP response was already sent.
6079
+ Promise.resolve(p).catch(() => {});
6080
+ },
6081
+ passThroughOnException: () => {},
6082
+ };
6083
+ return fetchFn(req, env, ctx);
6084
+ };
6085
+ } else if (typeof mod.default === 'function') {
6086
+ // Legacy service-worker shape (\`export default async function (req) { ... }\`).
6087
+ // REJECTED — production runs functions as Module Workers and the runtime
6088
+ // rejects this shape at upload. Accepting it in dev was a dev/prod parity
6089
+ // trap: a function that ran here would fail \`amba functions deploy\`. Reject
6090
+ // with the same fix the bundler + server hint use so dev and prod agree.
6091
+ process.stderr.write(' Error: Function entrypoint must \`export default { async fetch(req, env, ctx) { … } }\` (the Module Worker shape). Found a default-exported function instead.\\n');
6092
+ process.exit(2);
6093
+ } else {
6094
+ process.stderr.write(' Error: Function entrypoint must \`export default { async fetch(req, env, ctx) { … } }\` (the Module Worker shape). The default export has no \`fetch\` method.\\n');
5437
6095
  process.exit(2);
5438
6096
  }
5439
- handler = mod.default;
5440
6097
  } catch (err) {
5441
6098
  process.stderr.write(' Bundle import failed: ' + (err && err.stack ? err.stack : err) + '\\n');
5442
6099
  process.exit(2);
@@ -5586,8 +6243,8 @@ function parseRateLimitFlags(options) {
5586
6243
  * Output formatting:
5587
6244
  * - `--json`: NDJSON to stdout (one event per line) so `| jq` works.
5588
6245
  * - default: human-readable lines:
5589
- * 2026-05-08T12:34:56.789Z scan-letter-v3 [info] "scanning"
5590
- * 2026-05-08T12:34:57.012Z scan-letter-v3 [exception] TypeError: …
6246
+ * 2026-05-08T12:34:56.789Z scan-letter@v3 [info] "scanning"
6247
+ * 2026-05-08T12:34:57.012Z scan-letter@v3 [exception] TypeError: …
5591
6248
  *
5592
6249
  * Server-side scoping is enforced — the API filters by ScriptName
5593
6250
  * prefix so a developer can never read another tenant's logs.
@@ -5602,7 +6259,8 @@ async function functionsLogsCommand(functionName, options = {}) {
5602
6259
  const res = await getFunctionLogs(projectConfig.projectId, functionName, {
5603
6260
  since: options.since,
5604
6261
  until: options.until,
5605
- limit: options.limit
6262
+ limit: options.limit,
6263
+ requestId: options.requestId
5606
6264
  });
5607
6265
  printEvents(res.data.events, options.json);
5608
6266
  if (res.data.truncated) if (options.json) process.stderr.write(`{"truncated":true}\n`);
@@ -5618,7 +6276,8 @@ async function runTail(projectId, functionName, options) {
5618
6276
  try {
5619
6277
  result = await getFunctionLogs(projectId, functionName, {
5620
6278
  since: new Date(highWaterMs).toISOString(),
5621
- limit: options.limit ?? 200
6279
+ limit: options.limit ?? 200,
6280
+ requestId: options.requestId
5622
6281
  });
5623
6282
  } catch (err) {
5624
6283
  console.error(pc.yellow(` ! tail fetch failed: `) + (err instanceof Error ? err.message : String(err)));
@@ -5640,7 +6299,7 @@ function printEvents(events, asJson) {
5640
6299
  }
5641
6300
  for (const e of ordered) {
5642
6301
  const ts = e.EventTimestampMs ? new Date(e.EventTimestampMs).toISOString() : "????-??-??T??:??:??Z";
5643
- const script = e.ScriptName ?? "<unknown-script>";
6302
+ const script = e.function_name ? `${e.function_name}@v${e.version ?? "?"}` : "<unknown-function>";
5644
6303
  if (e.Logs && e.Logs.length > 0) for (const logLine of e.Logs) {
5645
6304
  const level = renderLevel(logLine.Level);
5646
6305
  const msg = renderMessage(logLine.Message);
@@ -5746,11 +6405,20 @@ async function resolveSecretValue(opts) {
5746
6405
  //#endregion
5747
6406
  //#region src/commands/secrets.ts
5748
6407
  /**
5749
- * `amba secrets ...` — write per-function secrets through the platform
5750
- * API. The CLI sends `{name, value, function}` to the admin endpoint;
5751
- * the server stores the value, validates reserved binding names, and
5752
- * propagates it to the deployed Worker's binding. Sync is eventual —
5753
- * `amba secrets list` shows progress.
6408
+ * `amba secrets ...` — write secrets through the platform API. The CLI
6409
+ * sends `{name, value, function?}` to the admin endpoint; the server
6410
+ * stores the value, validates reserved binding names, and propagates it to
6411
+ * the deployed Worker binding(s). Sync is eventual — `amba secrets list`
6412
+ * shows progress.
6413
+ *
6414
+ * Two scopes:
6415
+ * - `--function <name>` → the secret binds to that one function.
6416
+ * - no `--function` → a PROJECT-WIDE secret, visible to every
6417
+ * function in the project (and to any function deployed later). Set it
6418
+ * once instead of repeating the same value per function.
6419
+ *
6420
+ * Secrets may be set BEFORE the function is deployed. The sync drains once
6421
+ * a deployment lands (and `amba functions deploy` re-syncs at the end).
5754
6422
  */
5755
6423
  async function secretsSetCommand(name, value, options) {
5756
6424
  validateSecretName(name);
@@ -5760,14 +6428,15 @@ async function secretsSetCommand(name, value, options) {
5760
6428
  });
5761
6429
  const projectConfig = await loadProjectConfig();
5762
6430
  if (options.env === "prod") console.log(pc.dim(" Note: --env is informational; prod and dev share one secret namespace per project."));
6431
+ const scopeLabel = options.function ? options.function : "project-wide";
5763
6432
  console.log();
5764
6433
  console.log(pc.bold(` amba secrets set ${pc.cyan(name)}`));
5765
- console.log(pc.dim(` function=${options.function} env=${options.env ?? "dev"}`));
6434
+ console.log(pc.dim(` scope=${scopeLabel} env=${options.env ?? "dev"}`));
5766
6435
  console.log();
5767
6436
  const res = await setSecretViaApi(projectConfig.projectId, {
5768
6437
  name,
5769
6438
  value: resolvedValue,
5770
- function: options.function
6439
+ ...options.function ? { function: options.function } : {}
5771
6440
  });
5772
6441
  console.log(pc.green(" ✓") + ` Secret ${pc.cyan(name)} stored ${pc.dim(`v${res.data.version}`)}`);
5773
6442
  console.log(pc.green(" ✓") + ` Workers Secret sync ${pc.dim(`(status=${res.data.sync_status})`)} queued`);
@@ -5775,17 +6444,22 @@ async function secretsSetCommand(name, value, options) {
5775
6444
  console.log(pc.dim(" Workers Secret will be live within ~30s. Run `amba secrets list` to check sync status."));
5776
6445
  console.log();
5777
6446
  }
5778
- async function secretsListCommand() {
5779
- const res = await listSecretsViaApi((await loadProjectConfig()).projectId);
6447
+ async function secretsListCommand(options = {}) {
6448
+ const res = await listSecretsViaApi((await loadProjectConfig()).projectId, { function: options.function });
5780
6449
  console.log();
6450
+ if (options.function) {
6451
+ console.log(pc.dim(` Secrets for function ${pc.bold(options.function)}:`));
6452
+ console.log();
6453
+ }
5781
6454
  if (res.data.length === 0) {
5782
- console.log(pc.dim(" No secrets configured."));
6455
+ console.log(pc.dim(options.function ? ` No secrets configured for ${options.function}.` : " No secrets configured."));
5783
6456
  console.log();
5784
6457
  return;
5785
6458
  }
5786
6459
  for (const row of res.data) {
5787
6460
  const status = renderStatus(row.sync_status);
5788
- console.log(` ${pc.bold(row.name)} ` + pc.dim(`(${row.function})`) + ` v${row.version} ${status}` + (row.last_error ? pc.dim(` — ${row.last_error}`) : ""));
6461
+ const scope = row.function ?? "project-wide";
6462
+ console.log(` ${pc.bold(row.name)} ` + pc.dim(`(${scope})`) + ` v${row.version} ${status}` + (row.last_error ? pc.dim(` — ${row.last_error}`) : ""));
5789
6463
  }
5790
6464
  console.log();
5791
6465
  }
@@ -5834,7 +6508,7 @@ function renderStatus(status) {
5834
6508
  * Project is resolved via `loadProjectConfig` (AMBA_PROJECT_ID env, then
5835
6509
  * .env / .env.local in the cwd) — same pattern as `amba secrets *`.
5836
6510
  */
5837
- function handleError(err) {
6511
+ function handleError$1(err) {
5838
6512
  if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
5839
6513
  else console.log(pc.red(" ✗") + ` ${err.message}`);
5840
6514
  else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
@@ -5848,7 +6522,7 @@ function handleError(err) {
5848
6522
  * its own request wrapper — same auth flow as `request()` in api-client,
5849
6523
  * intentionally not exported there so we don't grow the public surface.
5850
6524
  */
5851
- async function adminWrite(method, path, body) {
6525
+ async function adminWrite$1(method, path, body) {
5852
6526
  const token = await resolveBearerToken();
5853
6527
  const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
5854
6528
  const res = await fetch(url, {
@@ -5910,7 +6584,7 @@ async function billingStatusCommand() {
5910
6584
  console.log();
5911
6585
  }
5912
6586
  } catch (err) {
5913
- handleError(err);
6587
+ handleError$1(err);
5914
6588
  }
5915
6589
  }
5916
6590
  async function billingUpgradeCommand(input) {
@@ -5921,7 +6595,7 @@ async function billingUpgradeCommand(input) {
5921
6595
  try {
5922
6596
  const { projectId } = await loadProjectConfig();
5923
6597
  const interval = input.interval ?? "month";
5924
- const res = await adminWrite("POST", `/projects/${projectId}/billing/checkout`, {
6598
+ const res = await adminWrite$1("POST", `/projects/${projectId}/billing/checkout`, {
5925
6599
  tier: input.tier,
5926
6600
  interval
5927
6601
  });
@@ -5935,7 +6609,7 @@ async function billingUpgradeCommand(input) {
5935
6609
  console.log(pc.dim(" Subscription status updates automatically once Stripe confirms (a few seconds)."));
5936
6610
  console.log();
5937
6611
  } catch (err) {
5938
- handleError(err);
6612
+ handleError$1(err);
5939
6613
  }
5940
6614
  }
5941
6615
  async function billingPortalCommand() {
@@ -5945,13 +6619,13 @@ async function billingPortalCommand() {
5945
6619
  console.log();
5946
6620
  try {
5947
6621
  const { projectId } = await loadProjectConfig();
5948
- const res = await adminWrite("POST", `/projects/${projectId}/billing/portal`);
6622
+ const res = await adminWrite$1("POST", `/projects/${projectId}/billing/portal`);
5949
6623
  console.log(pc.bold(" Open this URL to manage your subscription:"));
5950
6624
  console.log();
5951
6625
  console.log(` ${pc.cyan(res.data.url)}`);
5952
6626
  console.log();
5953
6627
  } catch (err) {
5954
- handleError(err);
6628
+ handleError$1(err);
5955
6629
  }
5956
6630
  }
5957
6631
  async function billingSetCeilingCommand(input) {
@@ -5961,88 +6635,447 @@ async function billingSetCeilingCommand(input) {
5961
6635
  console.log();
5962
6636
  try {
5963
6637
  const { projectId } = await loadProjectConfig();
5964
- await adminWrite(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
6638
+ await adminWrite$1(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
5965
6639
  if (input.ceiling === null) console.log(pc.green(" ✓") + " Spend ceiling removed (linear overage continues).");
5966
6640
  else console.log(pc.green(" ✓") + ` Spend ceiling set to $${input.ceiling}/mo.`);
5967
6641
  console.log();
5968
6642
  } catch (err) {
5969
- handleError(err);
6643
+ handleError$1(err);
5970
6644
  }
5971
6645
  }
5972
6646
  //#endregion
5973
- //#region src/commands/collections.ts
6647
+ //#region src/commands/payments.ts
5974
6648
  /**
5975
- * `amba collections ...` — thin shells over the admin collection routes.
5976
- *
5977
- * Wire shapes:
5978
- *
5979
- * POST /admin/projects/:p/collections — create (full schema)
5980
- * GET /admin/projects/:p/collections — list
5981
- * PATCH /admin/projects/:p/collections/:name — single-op alter
5982
- * DELETE /admin/projects/:p/collections/:name — drop (requires ?confirm)
5983
- *
5984
- * The CLI's job is to:
5985
- * 1. Validate names / types client-side so an obviously-bad invocation
5986
- * doesn't round-trip.
5987
- * 2. Translate the user-friendly CLI flag shape (`--field name:type`,
5988
- * `--add-field`, `--drop-field`) into the wire shape (single op per
5989
- * PATCH).
5990
- * 3. Block until the server reports success.
5991
- * 4. Surface server errors with a sensible CLI message.
5992
- *
5993
- * Multi-op alters are issued sequentially so the operator audit trail
5994
- * stays one-to-one with developer actions.
6649
+ * `amba payments *` subcommands — CLI access to the Amba Payments (Stripe
6650
+ * Connect platform) surface at `/v1/admin/projects/:id/payments/*`
6651
+ * (tracker #10).
6652
+ *
6653
+ * Amba is the payments rail for the developer's app: the app is the seller,
6654
+ * Amba takes a platform fee, Stripe carries money-transmission. This is the
6655
+ * developer-facing onboarding + config + reporting surface, so naming Stripe
6656
+ * here is by intent (same as `amba billing`).
6657
+ *
6658
+ * amba payments setup — create the connected account +
6659
+ * print the hosted onboarding URL.
6660
+ * amba payments status — onboarding stage + capability flags.
6661
+ * amba payments set-fee <bps|off> — default platform fee (basis points).
6662
+ * amba payments balance — connected-account balance.
6663
+ * amba payments payouts — recent payouts.
6664
+ *
6665
+ * Every live call is gated by the platform's PAYMENTS_CONNECT_LIVE
6666
+ * kill-switch — when off the API returns PAYMENTS_NOT_ENABLED, surfaced
6667
+ * verbatim here. Project resolved via loadProjectConfig (same as billing).
5995
6668
  */
6669
+ function handleError(err) {
6670
+ if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
6671
+ else if (err.errorCode === "PAYMENTS_NOT_ENABLED") console.log(pc.yellow(" •") + " Payments are not enabled on this Amba platform yet. The platform owner must finish setup before payments go live.");
6672
+ else console.log(pc.red(" ✗") + ` ${err.message}`);
6673
+ else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
6674
+ else console.log(pc.red(" ✗") + " Unknown error");
6675
+ console.log();
6676
+ process.exit(1);
6677
+ }
5996
6678
  /**
5997
- * Closed-set type catalog. Matches data's `ColumnType` exactly — keeps
5998
- * the CLI fail-fast path aligned with the DDL emit's accepted shapes.
5999
- *
6000
- * The CLI accepts customer-friendly synonyms (e.g. `int` → `integer`)
6001
- * via `normalizeColumnType` so the `--field` flag stays ergonomic
6002
- * without the API needing to relax its closed catalog.
6679
+ * POST/PUT to the admin API. Mirrors the helper in billing.ts (api-client
6680
+ * doesn't export a generic write for arbitrary paths).
6003
6681
  */
6004
- const ALLOWED_TYPES = new Set([
6005
- "uuid",
6006
- "text",
6007
- "integer",
6008
- "bigint",
6009
- "numeric",
6010
- "boolean",
6011
- "timestamptz",
6012
- "date",
6013
- "jsonb",
6014
- "vector"
6015
- ]);
6016
- const TYPE_SYNONYMS = {
6017
- int: "integer",
6018
- int4: "integer",
6019
- int8: "bigint",
6020
- bool: "boolean",
6021
- json: "jsonb",
6022
- string: "text"
6023
- };
6024
- async function collectionsCreateCommand(name, options) {
6025
- const reason = getReservationReason(name);
6026
- if (reason) throw new Error(`Cannot create collection '${name}': ${reason}`);
6027
- const columns = options.field.map(parseColumnSpec);
6028
- const indexes = options.index.map(parseIndexSpec);
6029
- const projectConfig = await loadProjectConfig();
6030
- console.log();
6031
- console.log(pc.bold(` amba collections create ${pc.cyan(name)}`));
6032
- for (const c of columns) console.log(pc.dim(" ") + c.name + pc.dim(": ") + c.type + (c.nullable ? pc.dim(" NULL") : pc.dim(" NOT NULL")));
6033
- console.log();
6034
- const res = await createCollection(projectConfig.projectId, {
6035
- name,
6036
- columns,
6037
- indexes
6682
+ async function adminWrite(method, path, body) {
6683
+ const token = await resolveBearerToken();
6684
+ const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
6685
+ const res = await fetch(url, {
6686
+ method,
6687
+ headers: {
6688
+ Authorization: `Bearer ${token}`,
6689
+ "Content-Type": "application/json",
6690
+ "User-Agent": "amba-cli/0.1.1"
6691
+ },
6692
+ body: body === void 0 ? void 0 : JSON.stringify(body)
6038
6693
  });
6039
- console.log(pc.green(" ✓") + ` Created — version ${res.data.version}`);
6040
- console.log(pc.dim(` workflow_id: ${res.data.workflow_id}`));
6041
- console.log();
6694
+ if (!res.ok) {
6695
+ let message = `API request failed: ${res.status} ${res.statusText}`;
6696
+ let code;
6697
+ try {
6698
+ const errorBody = await res.json();
6699
+ if (errorBody.error?.message) {
6700
+ message = errorBody.error.message;
6701
+ code = errorBody.error.code;
6702
+ }
6703
+ } catch {}
6704
+ throw new ApiClientError(message, res.status, code);
6705
+ }
6706
+ return await res.json();
6042
6707
  }
6043
- async function collectionsAlterCommand(name, options) {
6044
- 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.");
6045
- const confirmed = new Set(options.confirm ?? []);
6708
+ function printAccount(a) {
6709
+ console.log(` Account: ${pc.dim(a.stripe_account_id)}`);
6710
+ console.log(` Onboarding: ${pc.bold(a.onboarding_status)}`);
6711
+ console.log(` Charges enabled: ${a.charges_enabled ? pc.green("yes") : pc.yellow("no")}`);
6712
+ console.log(` Payouts enabled: ${a.payouts_enabled ? pc.green("yes") : pc.yellow("no")}`);
6713
+ console.log(` Country: ${a.country ?? pc.dim("—")}`);
6714
+ console.log(` Default fee: ${a.default_platform_fee_bps === null ? pc.dim("none") : `${a.default_platform_fee_bps} bps (${(a.default_platform_fee_bps / 100).toFixed(2)}%)`}`);
6715
+ }
6716
+ async function paymentsSetupCommand(input) {
6717
+ console.log();
6718
+ console.log(pc.bold(" amba payments setup"));
6719
+ console.log(pc.dim(" ─────────────────────────────────"));
6720
+ console.log();
6721
+ try {
6722
+ const { projectId } = await loadProjectConfig();
6723
+ printAccount((await adminWrite("POST", `/projects/${projectId}/payments/accounts`, {
6724
+ ...input.country ? { country: input.country } : {},
6725
+ ...input.email ? { email: input.email } : {}
6726
+ })).data);
6727
+ console.log();
6728
+ const link = await adminWrite("POST", `/projects/${projectId}/payments/accounts/link`);
6729
+ console.log(pc.bold(" Open this URL to finish setting up payments:"));
6730
+ console.log();
6731
+ console.log(` ${pc.cyan(link.data.url)}`);
6732
+ console.log();
6733
+ console.log(pc.dim(" Status updates automatically once setup completes."));
6734
+ console.log();
6735
+ } catch (err) {
6736
+ handleError(err);
6737
+ }
6738
+ }
6739
+ async function paymentsStatusCommand() {
6740
+ console.log();
6741
+ console.log(pc.bold(" amba payments status"));
6742
+ console.log(pc.dim(" ─────────────────────────────────"));
6743
+ console.log();
6744
+ try {
6745
+ const { projectId } = await loadProjectConfig();
6746
+ const res = await adminGet(`/projects/${projectId}/payments/accounts`);
6747
+ if (!res.data.connected_account) {
6748
+ console.log(pc.dim(" No payments account yet. Run `amba payments setup`."));
6749
+ console.log();
6750
+ return;
6751
+ }
6752
+ printAccount(res.data.connected_account);
6753
+ console.log();
6754
+ } catch (err) {
6755
+ handleError(err);
6756
+ }
6757
+ }
6758
+ async function paymentsSetFeeCommand(input) {
6759
+ console.log();
6760
+ console.log(pc.bold(" amba payments set-fee"));
6761
+ console.log(pc.dim(" ─────────────────────────────────"));
6762
+ console.log();
6763
+ try {
6764
+ const { projectId } = await loadProjectConfig();
6765
+ await adminWrite("PUT", `/projects/${projectId}/payments/config`, { default_platform_fee_bps: input.bps });
6766
+ if (input.bps === null) console.log(pc.green(" ✓") + " Default platform fee cleared.");
6767
+ else console.log(pc.green(" ✓") + ` Default platform fee set to ${input.bps} bps (${(input.bps / 100).toFixed(2)}%).`);
6768
+ console.log();
6769
+ } catch (err) {
6770
+ handleError(err);
6771
+ }
6772
+ }
6773
+ async function paymentsBalanceCommand() {
6774
+ console.log();
6775
+ console.log(pc.bold(" amba payments balance"));
6776
+ console.log(pc.dim(" ─────────────────────────────────"));
6777
+ console.log();
6778
+ try {
6779
+ const { projectId } = await loadProjectConfig();
6780
+ const res = await adminGet(`/projects/${projectId}/payments/balance`);
6781
+ const fmt = (b) => `${(b.amount / 100).toFixed(2)} ${b.currency.toUpperCase()}`;
6782
+ console.log(` Available: ${res.data.available.length ? res.data.available.map(fmt).join(", ") : pc.dim("—")}`);
6783
+ console.log(` Pending: ${res.data.pending.length ? res.data.pending.map(fmt).join(", ") : pc.dim("—")}`);
6784
+ console.log();
6785
+ } catch (err) {
6786
+ handleError(err);
6787
+ }
6788
+ }
6789
+ async function paymentsPayoutsCommand(input) {
6790
+ console.log();
6791
+ console.log(pc.bold(" amba payments payouts"));
6792
+ console.log(pc.dim(" ─────────────────────────────────"));
6793
+ console.log();
6794
+ try {
6795
+ const { projectId } = await loadProjectConfig();
6796
+ const res = await adminGet(`/projects/${projectId}/payments/payouts${input.limit ? `?limit=${input.limit}` : ""}`);
6797
+ if (res.data.length === 0) {
6798
+ console.log(pc.dim(" No payouts yet."));
6799
+ console.log();
6800
+ return;
6801
+ }
6802
+ for (const p of res.data) {
6803
+ const arrival = (/* @__PURE__ */ new Date(p.arrival_date * 1e3)).toLocaleDateString();
6804
+ console.log(` ${pc.dim(p.id.padEnd(28))} ${(p.amount / 100).toFixed(2)} ${p.currency.toUpperCase().padEnd(4)} ${pc.dim(p.status.padEnd(12))} arrives ${arrival}`);
6805
+ }
6806
+ console.log();
6807
+ } catch (err) {
6808
+ handleError(err);
6809
+ }
6810
+ }
6811
+ //#endregion
6812
+ //#region src/commands/monetization.ts
6813
+ /**
6814
+ * `amba monetization *` subcommands — CLI access to the monetization control
6815
+ * plane behind `/v1/admin/projects/:id/monetization/*`.
6816
+ *
6817
+ * amba monetization plan — three-way diff (declared vs adopted baseline
6818
+ * vs live provider) + store-floor preflight +
6819
+ * drift. READ-ONLY — previews; applies nothing.
6820
+ * amba monetization drift — out-of-band changes to Amba-managed objects.
6821
+ * amba monetization export — snapshot the live config to a declarative
6822
+ * bundle (the `monetization` section), to stdout
6823
+ * or a file.
6824
+ * amba monetization adopt — record the live config as the managed baseline
6825
+ * so the next plan is a no-op. Writes ONLY Amba's
6826
+ * own state — never the provider. Idempotent.
6827
+ * amba monetization apply — push the declared config to the provider.
6828
+ * ADDITIVE only: create entitlements/offerings/
6829
+ * packages/paywall-drafts + attach products.
6830
+ * Refuses if the provider drifted since the plan;
6831
+ * never does destructive changes.
6832
+ *
6833
+ * Project is resolved via `loadProjectConfig` — same pattern as the other
6834
+ * project-scoped CLI commands.
6835
+ */
6836
+ /**
6837
+ * Authenticated write helper (POST). Mirrors the `adminWrite` in
6838
+ * `commands/billing.ts` — kept local so the api-client public surface doesn't
6839
+ * grow a generic POST.
6840
+ */
6841
+ async function adminPost(path, body) {
6842
+ const token = await resolveBearerToken();
6843
+ const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
6844
+ const res = await fetch(url, {
6845
+ method: "POST",
6846
+ headers: {
6847
+ Authorization: `Bearer ${token}`,
6848
+ "Content-Type": "application/json",
6849
+ "User-Agent": "amba-cli/0.1.1"
6850
+ },
6851
+ body: body === void 0 ? void 0 : JSON.stringify(body)
6852
+ });
6853
+ if (!res.ok) {
6854
+ let message = `API request failed: ${res.status} ${res.statusText}`;
6855
+ let code;
6856
+ try {
6857
+ const errorBody = await res.json();
6858
+ if (errorBody.error?.message) {
6859
+ message = errorBody.error.message;
6860
+ code = errorBody.error.code;
6861
+ }
6862
+ } catch {}
6863
+ throw new ApiClientError(message, res.status, code);
6864
+ }
6865
+ return await res.json();
6866
+ }
6867
+ function header(title) {
6868
+ console.log();
6869
+ console.log(pc.bold(` amba monetization ${title}`));
6870
+ console.log(pc.dim(" ─────────────────────────────────"));
6871
+ console.log();
6872
+ }
6873
+ async function monetizationPlanCommand() {
6874
+ const { projectId } = await loadProjectConfig();
6875
+ header("plan");
6876
+ const plan = (await adminGet(`/projects/${projectId}/monetization/plan`)).data;
6877
+ if (plan.clean) {
6878
+ console.log(pc.green(" ✓") + " No changes — declared config matches the live provider.");
6879
+ console.log();
6880
+ return;
6881
+ }
6882
+ if (plan.ops.length > 0) {
6883
+ console.log(pc.bold(" Pending operations (preview — nothing is applied):"));
6884
+ for (const op of plan.ops) console.log(` ${pc.cyan(op.op.padEnd(12))} ${pc.bold(op.object_type)} ${op.identifier} ${pc.dim(`[${op.status}]`)}`);
6885
+ console.log();
6886
+ }
6887
+ if (plan.store_floor.length > 0) {
6888
+ console.log(pc.yellow(" Store-floor preflight — referenced products missing in the provider:"));
6889
+ for (const sf of plan.store_floor) console.log(` ${pc.bold(sf.product_key)} → ${sf.store_identifier} (${sf.store})`);
6890
+ console.log();
6891
+ }
6892
+ if (plan.drift.length > 0) {
6893
+ console.log(pc.yellow(" Drift — managed objects changed out-of-band in the provider:"));
6894
+ for (const d of plan.drift) console.log(` ${pc.bold(d.object_type)} ${d.identifier}`);
6895
+ console.log();
6896
+ }
6897
+ }
6898
+ async function monetizationDriftCommand() {
6899
+ const { projectId } = await loadProjectConfig();
6900
+ header("drift");
6901
+ const data = (await adminGet(`/projects/${projectId}/monetization/drift`)).data;
6902
+ if (!data.has_drift) {
6903
+ console.log(pc.green(" ✓") + " No drift — managed config matches the live provider.");
6904
+ console.log();
6905
+ return;
6906
+ }
6907
+ console.log(pc.yellow(" Drift detected:"));
6908
+ for (const d of data.drift) console.log(` ${pc.bold(d.object_type)} ${d.identifier}`);
6909
+ console.log();
6910
+ }
6911
+ async function monetizationExportCommand(options = {}) {
6912
+ const { projectId } = await loadProjectConfig();
6913
+ const bundle = {
6914
+ version: 1,
6915
+ monetization: (await adminGet(`/projects/${projectId}/monetization/export`)).data.monetization
6916
+ };
6917
+ const json = JSON.stringify(bundle, null, 2);
6918
+ if (options.file) {
6919
+ await writeFile(resolve(options.file), json + "\n", "utf-8");
6920
+ console.log();
6921
+ console.log(pc.green(" ✓") + ` Wrote live monetization config to ${pc.cyan(options.file)}.`);
6922
+ console.log();
6923
+ } else console.log(json);
6924
+ }
6925
+ async function monetizationAdoptCommand(options = {}) {
6926
+ const { projectId } = await loadProjectConfig();
6927
+ header("adopt");
6928
+ if (!options.all) {
6929
+ console.log(" ✗ Pass --all to adopt the entire live config as the managed baseline.");
6930
+ process.exit(1);
6931
+ }
6932
+ const d = (await adminPost(`/projects/${projectId}/monetization/adopt`, { adopt_all: true })).data;
6933
+ console.log(pc.green(" ✓") + ` Adopted ${d.adopted} object(s) as the managed baseline ` + pc.dim(`(${d.entitlements} entitlements · ${d.products} products · ${d.offerings} offerings · ${d.packages} packages · ${d.paywalls} paywalls)`));
6934
+ console.log(pc.dim(" The next `amba monetization plan` is now a clean no-op."));
6935
+ console.log();
6936
+ }
6937
+ async function monetizationApplyCommand(options = {}) {
6938
+ const { projectId } = await loadProjectConfig();
6939
+ header("apply");
6940
+ const plan = (await adminGet(`/projects/${projectId}/monetization/plan`)).data;
6941
+ if (plan.store_floor.length > 0) {
6942
+ console.log(pc.yellow(" Store floor not satisfied — these products must exist in the provider first:"));
6943
+ for (const sf of plan.store_floor) console.log(` ${pc.bold(sf.product_key)} → ${sf.store_identifier} (${sf.store})`);
6944
+ console.log();
6945
+ console.log(" ✗ Configure these in the store, then run plan + apply again.");
6946
+ process.exit(1);
6947
+ }
6948
+ if (plan.clean) {
6949
+ console.log(pc.green(" ✓") + " Nothing to apply — declared config matches the provider.");
6950
+ console.log();
6951
+ return;
6952
+ }
6953
+ if (options.reconcile && options.reconcile !== "amba" && options.reconcile !== "adopt") {
6954
+ console.log(pc.red(" ✗") + " --reconcile must be 'amba' or 'adopt'.");
6955
+ process.exit(1);
6956
+ }
6957
+ const body = { plan_hash: plan.plan_hash };
6958
+ if (options.confirm) body["confirm"] = plan.plan_hash;
6959
+ if (options.allowDetachLive) body["allow_detach_live"] = true;
6960
+ if (options.reconcile) body["reconcile"] = options.reconcile;
6961
+ let res;
6962
+ try {
6963
+ res = await adminPost(`/projects/${projectId}/monetization/apply`, body);
6964
+ } catch (err) {
6965
+ if (err instanceof ApiClientError && err.errorCode === "MONETIZATION_CONFIRM_REQUIRED") {
6966
+ console.log(pc.yellow(" ⚠ This plan includes gated destructive operation(s) — nothing was applied."));
6967
+ console.log(pc.dim(" Review the plan, then re-run with --confirm to execute the gated ops:"));
6968
+ console.log(` ${pc.cyan("amba monetization apply --confirm")}`);
6969
+ console.log();
6970
+ process.exit(1);
6971
+ }
6972
+ if (err instanceof ApiClientError && err.errorCode === "MONETIZATION_INVARIANT") {
6973
+ console.log(pc.red(" ✗ Blocked by the ordering invariant — nothing was applied:"));
6974
+ console.log(pc.dim(` ${err.message}`));
6975
+ console.log();
6976
+ process.exit(1);
6977
+ }
6978
+ throw err;
6979
+ }
6980
+ const d = res.data;
6981
+ if (d.sandbox_stage?.ran) {
6982
+ const tag = d.sandbox_stage.passed ? pc.green("✓ sandbox passed") : pc.red("✗ sandbox failed");
6983
+ console.log(` ${tag} ` + pc.dim(d.sandbox_stage.detail));
6984
+ console.log();
6985
+ }
6986
+ if (d.status === "succeeded") console.log(pc.green(" ✓") + ` Applied ${d.applied} change(s) to the provider ` + pc.dim(`(${d.skipped_existing} already present, ${d.gated_applied} destructive)`));
6987
+ else {
6988
+ console.log(pc.red(" ✗") + ` Apply stopped after a failure (${d.applied} applied, ${d.failed} failed).` + (d.stopped_at ? ` Stopped at: ${d.stopped_at}` : ""));
6989
+ const failed = d.results.find((r) => r.status === "failed");
6990
+ if (failed?.error) console.log(pc.dim(` ${failed.error}`));
6991
+ }
6992
+ if (d.gated.length > 0) {
6993
+ console.log();
6994
+ console.log(pc.yellow(" Gated (destructive — run with --confirm to execute):"));
6995
+ for (const g of d.gated) console.log(` ${pc.bold(g.op.op)} ${g.op.object_type} ${g.op.identifier}`);
6996
+ }
6997
+ if (d.refused.length > 0) {
6998
+ console.log();
6999
+ console.log(pc.yellow(" Not applicable via API (do these in the provider dashboard):"));
7000
+ for (const r of d.refused) console.log(` ${pc.bold(r.op.op)} ${r.op.object_type} ${r.op.identifier}`);
7001
+ }
7002
+ console.log();
7003
+ if (d.status !== "succeeded") process.exit(1);
7004
+ }
7005
+ //#endregion
7006
+ //#region src/commands/collections.ts
7007
+ /**
7008
+ * `amba collections ...` — thin shells over the admin collection routes.
7009
+ *
7010
+ * Wire shapes:
7011
+ *
7012
+ * POST /admin/projects/:p/collections — create (full schema)
7013
+ * GET /admin/projects/:p/collections — list
7014
+ * PATCH /admin/projects/:p/collections/:name — single-op alter
7015
+ * DELETE /admin/projects/:p/collections/:name — drop (requires ?confirm)
7016
+ *
7017
+ * The CLI's job is to:
7018
+ * 1. Validate names / types client-side so an obviously-bad invocation
7019
+ * doesn't round-trip.
7020
+ * 2. Translate the user-friendly CLI flag shape (`--field name:type`,
7021
+ * `--add-field`, `--drop-field`) into the wire shape (single op per
7022
+ * PATCH).
7023
+ * 3. Block until the server reports success.
7024
+ * 4. Surface server errors with a sensible CLI message.
7025
+ *
7026
+ * Multi-op alters are issued sequentially so the operator audit trail
7027
+ * stays one-to-one with developer actions.
7028
+ */
7029
+ /**
7030
+ * Closed-set type catalog. Matches data's `ColumnType` exactly — keeps
7031
+ * the CLI fail-fast path aligned with the DDL emit's accepted shapes.
7032
+ *
7033
+ * The CLI accepts customer-friendly synonyms (e.g. `int` → `integer`)
7034
+ * via `normalizeColumnType` so the `--field` flag stays ergonomic
7035
+ * without the API needing to relax its closed catalog.
7036
+ */
7037
+ const ALLOWED_TYPES = new Set([
7038
+ "uuid",
7039
+ "text",
7040
+ "integer",
7041
+ "bigint",
7042
+ "numeric",
7043
+ "boolean",
7044
+ "timestamptz",
7045
+ "date",
7046
+ "jsonb",
7047
+ "vector"
7048
+ ]);
7049
+ const TYPE_SYNONYMS = {
7050
+ int: "integer",
7051
+ int4: "integer",
7052
+ int8: "bigint",
7053
+ bool: "boolean",
7054
+ json: "jsonb",
7055
+ string: "text"
7056
+ };
7057
+ async function collectionsCreateCommand(name, options) {
7058
+ const reason = getReservationReason(name);
7059
+ if (reason) throw new Error(`Cannot create collection '${name}': ${reason}`);
7060
+ const columns = options.field.map(parseColumnSpec);
7061
+ const indexes = options.index.map(parseIndexSpec);
7062
+ const projectConfig = await loadProjectConfig();
7063
+ console.log();
7064
+ console.log(pc.bold(` amba collections create ${pc.cyan(name)}`));
7065
+ for (const c of columns) console.log(pc.dim(" ") + c.name + pc.dim(": ") + c.type + (c.nullable ? pc.dim(" NULL") : pc.dim(" NOT NULL")));
7066
+ console.log();
7067
+ const res = await createCollection(projectConfig.projectId, {
7068
+ name,
7069
+ columns,
7070
+ indexes
7071
+ });
7072
+ console.log(pc.green(" ✓") + ` Created — version ${res.data.version}`);
7073
+ console.log(pc.dim(` workflow_id: ${res.data.workflow_id}`));
7074
+ console.log();
7075
+ }
7076
+ async function collectionsAlterCommand(name, options) {
7077
+ 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.");
7078
+ const confirmed = new Set(options.confirm ?? []);
6046
7079
  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).`);
6047
7080
  const projectConfig = await loadProjectConfig();
6048
7081
  for (const fieldSpec of options.addField) {
@@ -6268,8 +7301,22 @@ const PG_TYPE_TO_TS = {
6268
7301
  jsonb: "unknown",
6269
7302
  json: "unknown"
6270
7303
  };
7304
+ const ARRAY_UDT_TO_TS = {
7305
+ _text: "string",
7306
+ _varchar: "string",
7307
+ _uuid: "string",
7308
+ _int4: "number",
7309
+ _int8: "number",
7310
+ _int2: "number",
7311
+ _numeric: "number",
7312
+ _float8: "number",
7313
+ _float4: "number",
7314
+ _bool: "boolean"
7315
+ };
6271
7316
  function tsTypeForColumn(col) {
6272
- const base = PG_TYPE_TO_TS[col.data_type] ?? "unknown";
7317
+ let base;
7318
+ if (col.data_type === "ARRAY") base = `${col.udt_name ? ARRAY_UDT_TO_TS[col.udt_name] ?? "unknown" : "unknown"}[]`;
7319
+ else base = PG_TYPE_TO_TS[col.data_type] ?? "unknown";
6273
7320
  if (col.column_name === "deleted_at") return "string | null";
6274
7321
  if (col.is_nullable === "YES") return `${base} | null`;
6275
7322
  return base;
@@ -6391,13 +7438,21 @@ function makeCodegenHttpClient() {
6391
7438
  * directory (mirrors `wrangler pages deploy ./out` semantics). Dynamic
6392
7439
  * logic belongs in `amba functions deploy`, not in a site directory.
6393
7440
  */
6394
- const SITE_NAME_RE = /^[a-z][a-z0-9_-]{0,49}$/;
6395
- const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
7441
+ const SITE_NAME_RE$1 = /^[a-z][a-z0-9_-]{0,49}$/;
7442
+ const HOSTNAME_RE$1 = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
7443
+ const MAX_CERT_CN_LENGTH = 64;
7444
+ const PLATFORM_DOMAINS = ["amba.host", "amba.dev"];
7445
+ function isPlatformDomain(host) {
7446
+ return PLATFORM_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`));
7447
+ }
6396
7448
  function validateSiteName(name) {
6397
- 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).`);
7449
+ if (!SITE_NAME_RE$1.test(name)) throw new Error(`Invalid site name '${name}'. Must match /^[a-z][a-z0-9_-]{0,49}$/ (lowercase ASCII, digits, underscore or hyphen; ≤50 chars).`);
6398
7450
  }
6399
7451
  function validateHostname(host) {
6400
- if (!HOSTNAME_RE.test(host)) throw new Error(`Invalid hostname '${host}'. Must be a DNS-shaped name (e.g. site.example.com).`);
7452
+ const h = host.trim().toLowerCase();
7453
+ if (!HOSTNAME_RE$1.test(h)) throw new Error(`Invalid hostname '${host}'. Must be a DNS-shaped name (e.g. site.example.com).`);
7454
+ if (h.length > MAX_CERT_CN_LENGTH) throw new Error(`Hostname '${host}' is too long for certificate issuance (max ${MAX_CERT_CN_LENGTH} characters). Use a shorter subdomain.`);
7455
+ if (isPlatformDomain(h)) throw new Error(`'${host}' is reserved by the platform. Attach a domain you own, e.g. app.yourdomain.com.`);
6401
7456
  }
6402
7457
  /**
6403
7458
  * Per-deploy size cap. The hosting provider enforces 25 MiB per file +
@@ -6446,7 +7501,7 @@ async function sitesDeployCommand(inputDir, options = {}) {
6446
7501
  if (domains.data.length > 0) {
6447
7502
  console.log();
6448
7503
  console.log(pc.dim(" Domains:"));
6449
- for (const d of domains.data) console.log(` ${pc.bold(d.hostname)} ${formatCertStatus(d.cert_status)}`);
7504
+ for (const d of domains.data) console.log(` ${pc.bold(d.hostname)} ${formatDomainStatus(d)}`);
6450
7505
  }
6451
7506
  console.log();
6452
7507
  }
@@ -6496,32 +7551,51 @@ async function sitesDomainAddCommand(hostname, options) {
6496
7551
  console.log();
6497
7552
  const res = await addSiteDomainViaApi(projectId, options.site, hostname);
6498
7553
  console.log(pc.green(" ✓") + ` Custom hostname registered`);
7554
+ const validation = res.data.ssl_validation ?? [];
7555
+ const ownership = res.data.ownership_verification;
7556
+ const isApex = res.data.is_apex ?? false;
7557
+ if (validation.length > 0 || ownership) {
7558
+ console.log();
7559
+ console.log(pc.dim(" 1. Publish these DNS records to verify ownership:"));
7560
+ for (const rec of validation) if (rec.txt_name && rec.txt_value) console.log(` ${pc.bold("TXT")} ${rec.txt_name} → ${pc.cyan(rec.txt_value)}`);
7561
+ if (ownership?.name && ownership.value) console.log(` ${pc.bold("TXT")} ${ownership.name} → ${pc.cyan(ownership.value)}`);
7562
+ console.log();
7563
+ console.log(pc.dim(" 2. Point your hostname at the Amba target:"));
7564
+ } else {
7565
+ console.log();
7566
+ console.log(pc.dim(" Point your hostname at the Amba target:"));
7567
+ }
7568
+ console.log(` ${pc.bold("CNAME")} ${hostname} → ${pc.cyan(res.data.dns_target)}`);
6499
7569
  console.log();
6500
- console.log(pc.dim(" Point your DNS at:"));
6501
- console.log(` ${pc.bold("CNAME")} ${hostname} → ${pc.cyan(res.data.dns_target)}`);
6502
- console.log();
7570
+ if (isApex) {
7571
+ console.log(pc.dim(` ${hostname} is a root domain — the CNAME above works only if your DNS provider`));
7572
+ console.log(pc.dim(` flattens CNAME-at-root. If it doesn't, A-record apex support is coming soon;`));
7573
+ console.log(pc.dim(` use a subdomain like app.${hostname} in the meantime.`));
7574
+ console.log();
7575
+ }
6503
7576
  if (options.noWait) {
6504
- console.log(pc.yellow(" ! --no-wait — skipping cert poll. Run `amba sites describe` later."));
7577
+ console.log(pc.yellow(" ! --no-wait — skipping status poll. Run `amba sites describe` later."));
6505
7578
  return;
6506
7579
  }
6507
7580
  const timeout = (options.timeout ?? 600) * 1e3;
6508
7581
  const start = Date.now();
6509
- let lastStatus = "";
7582
+ let lastLine = "";
6510
7583
  while (Date.now() - start < timeout) {
6511
7584
  const row = (await listSiteDomains(projectId, options.site)).data.find((d) => d.hostname === hostname);
6512
7585
  if (!row) throw new Error(`Domain ${hostname} disappeared from listing — check API state.`);
6513
- if (row.cert_status !== lastStatus) {
6514
- console.log(pc.dim(` cert_status: ${formatCertStatus(row.cert_status)}`));
6515
- lastStatus = row.cert_status;
7586
+ const line = formatDomainStatus(row);
7587
+ if (line !== lastLine) {
7588
+ console.log(pc.dim(` ${line}`));
7589
+ lastLine = line;
6516
7590
  }
6517
- if (row.cert_status === "active") {
6518
- console.log(pc.green(" ✓") + ` ${hostname} live with valid cert.`);
7591
+ if (row.cert_status === "active" && row.ownership_status === "active") {
7592
+ console.log(pc.green(" ✓") + ` ${hostname} is live with a valid certificate.`);
6519
7593
  return;
6520
7594
  }
6521
- if (row.cert_status === "error") throw new Error(`Cert provisioning failed for ${hostname}. Check that the CNAME points at ${res.data.dns_target}.`);
7595
+ if (row.cert_status === "error" || row.ownership_status === "error") throw new Error(`Domain setup failed for ${hostname}. Confirm the TXT record(s) above are published and the CNAME points at ${res.data.dns_target}.`);
6522
7596
  await sleep(5e3);
6523
7597
  }
6524
- console.log(pc.yellow(` ! Timed out after ${options.timeout ?? 600}s waiting for cert. Re-run \`amba sites describe ${options.site}\` to check status.`));
7598
+ console.log(pc.yellow(` ! Timed out after ${options.timeout ?? 600}s. Re-run \`amba sites describe ${options.site}\` to check status.`));
6525
7599
  }
6526
7600
  async function sitesDomainListCommand(siteName) {
6527
7601
  validateSiteName(siteName);
@@ -6532,7 +7606,7 @@ async function sitesDomainListCommand(siteName) {
6532
7606
  console.log();
6533
7607
  return;
6534
7608
  }
6535
- for (const d of res.data) console.log(` ${pc.bold(d.hostname)} ${formatCertStatus(d.cert_status)}`);
7609
+ for (const d of res.data) console.log(` ${pc.bold(d.hostname)} ${formatDomainStatus(d)}`);
6536
7610
  console.log();
6537
7611
  }
6538
7612
  async function sitesDomainRemoveCommand(hostname, options) {
@@ -6641,13 +7715,502 @@ function formatCertStatus(s) {
6641
7715
  if (s === "error") return pc.red(s);
6642
7716
  return pc.yellow(s);
6643
7717
  }
7718
+ /**
7719
+ * Render BOTH lifecycle states for a domain row. A domain is only live when
7720
+ * ownership AND cert are both `active`, so display paths must show both —
7721
+ * showing cert alone reads "live" while ownership is still pending.
7722
+ */
7723
+ function formatDomainStatus(d) {
7724
+ return `ownership: ${formatCertStatus(d.ownership_status)} cert: ${formatCertStatus(d.cert_status)}`;
7725
+ }
6644
7726
  function sleep(ms) {
6645
7727
  return new Promise((r) => setTimeout(r, ms));
6646
7728
  }
6647
7729
  //#endregion
7730
+ //#region src/commands/domains.ts
7731
+ /**
7732
+ * `amba domains ...` commands — buy a domain through Amba.
7733
+ *
7734
+ * amba domains search <query> find available domains + prices
7735
+ * amba domains check <domain...> authoritative availability + price
7736
+ * amba domains buy <domain> --site s buy + connect to a site
7737
+ * amba domains list domains this project has purchased
7738
+ *
7739
+ * Search + check are free. `buy` surfaces the cost and requires explicit
7740
+ * confirmation (interactive y/N, or `--yes`) before it submits a purchase —
7741
+ * and the API itself only executes a real, paid registration when the
7742
+ * platform has live purchasing enabled. A registered domain is auto-
7743
+ * connected to the chosen site with no DNS setup by the customer.
7744
+ */
7745
+ const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
7746
+ const SITE_NAME_RE = /^[a-z][a-z0-9_-]{0,49}$/;
7747
+ function confirm(question) {
7748
+ const rl = createInterface({
7749
+ input: process.stdin,
7750
+ output: process.stdout
7751
+ });
7752
+ return new Promise((resolve) => {
7753
+ rl.question(question, (answer) => {
7754
+ rl.close();
7755
+ resolve(/^y(es)?$/i.test(answer.trim()));
7756
+ });
7757
+ });
7758
+ }
7759
+ function formatPrice(price, currency) {
7760
+ if (price === null) return pc.dim("price unavailable");
7761
+ return pc.bold(`$${price.toFixed(2)} ${currency}/yr`);
7762
+ }
7763
+ async function domainsSearchCommand(query, options = {}) {
7764
+ const { projectId } = await loadProjectConfig();
7765
+ const res = await searchDomainsViaApi(projectId, query, options.limit);
7766
+ console.log();
7767
+ if (res.data.length === 0) {
7768
+ console.log(pc.dim(` No suggestions for "${query}".`));
7769
+ console.log();
7770
+ return;
7771
+ }
7772
+ for (const s of res.data) {
7773
+ const tag = s.available ? pc.green("available") : pc.red("taken");
7774
+ const premium = s.premium ? pc.yellow(" premium") : "";
7775
+ console.log(` ${pc.bold(s.domain)} ${tag}${premium} ${formatPrice(s.price_usd, s.currency)}`);
7776
+ }
7777
+ console.log();
7778
+ console.log(pc.dim(" Buy one with: amba domains buy <domain> --site <site>"));
7779
+ console.log();
7780
+ }
7781
+ async function domainsCheckCommand(domains) {
7782
+ if (domains.length === 0) throw new Error("Pass at least one domain to check.");
7783
+ if (domains.length > 20) throw new Error("At most 20 domains per check.");
7784
+ const normalised = domains.map((d) => d.toLowerCase());
7785
+ for (const d of normalised) if (!HOSTNAME_RE.test(d)) throw new Error(`Invalid domain '${d}'.`);
7786
+ const { projectId } = await loadProjectConfig();
7787
+ const res = await checkDomainsViaApi(projectId, normalised);
7788
+ console.log();
7789
+ for (const a of res.data) {
7790
+ const tag = a.available ? pc.green("available") : pc.red("taken");
7791
+ const unsupported = a.supported ? "" : pc.yellow(" (extension not supported)");
7792
+ console.log(` ${pc.bold(a.domain)} ${tag}${unsupported} ${formatPrice(a.price_usd, a.currency)}`);
7793
+ }
7794
+ console.log();
7795
+ }
7796
+ async function domainsBuyCommand(domain, options) {
7797
+ const hostname = domain.toLowerCase();
7798
+ if (!HOSTNAME_RE.test(hostname)) throw new Error(`Invalid domain '${domain}'.`);
7799
+ if (!options.site || !SITE_NAME_RE.test(options.site)) throw new Error("--site <name> is required (the site to connect the domain to).");
7800
+ const { projectId } = await loadProjectConfig();
7801
+ console.log();
7802
+ console.log(pc.bold(` amba domains buy ${pc.cyan(hostname)}`));
7803
+ console.log(pc.dim(` → connect to site ${pc.cyan(options.site)}`));
7804
+ console.log();
7805
+ const check = await checkDomainsViaApi(projectId, [hostname]);
7806
+ const availability = check.data.find((d) => d.domain === hostname) ?? check.data[0];
7807
+ if (!availability) throw new Error("Could not determine availability for that domain.");
7808
+ if (!availability.available) throw new Error(`${hostname} is not available to register. Try \`amba domains search\`.`);
7809
+ if (!availability.supported) throw new Error(`${hostname} uses an extension we can't register yet.`);
7810
+ if (availability.price_usd === null) throw new Error(`${hostname} can't be priced right now — try again shortly.`);
7811
+ const price = availability.price_usd;
7812
+ console.log(` Price: ${formatPrice(price, availability.currency)}` + (availability.premium ? pc.yellow(" (premium)") : ""));
7813
+ console.log(pc.dim(` Includes: registration${options.noPrivacy ? "" : " + WHOIS privacy"}${options.noAutoRenew ? "" : " + auto-renew"}, auto-connected to ${options.site} (no DNS setup).`));
7814
+ console.log();
7815
+ if (!options.yes) {
7816
+ if (!await confirm(pc.yellow(` Buy ${hostname} for $${price.toFixed(2)} ${availability.currency}? `) + "(y/N) ")) {
7817
+ console.log(pc.dim(" Cancelled — nothing was purchased."));
7818
+ console.log();
7819
+ return;
7820
+ }
7821
+ }
7822
+ const r = (await purchaseDomainViaApi(projectId, {
7823
+ domain: hostname,
7824
+ site: options.site,
7825
+ accept_price_usd: price,
7826
+ confirm: true,
7827
+ privacy: !options.noPrivacy,
7828
+ auto_renew: !options.noAutoRenew,
7829
+ ...options.years ? { years: options.years } : {}
7830
+ })).data;
7831
+ if (!r.executed) {
7832
+ if (r.gated) {
7833
+ console.log(pc.yellow(" ! Domain purchasing is not enabled on this deployment."));
7834
+ console.log(pc.dim(" The domain is available at the price shown; no charge was made."));
7835
+ } else {
7836
+ console.log(pc.yellow(" ! Purchase not completed."));
7837
+ if (r.message) console.log(pc.dim(` ${r.message}`));
7838
+ }
7839
+ console.log();
7840
+ return;
7841
+ }
7842
+ console.log(pc.green(" ✓") + ` Registered ${pc.bold(hostname)}`);
7843
+ if (r.provisioned) {
7844
+ console.log(pc.green(" ✓") + ` Connected to ${options.site}${r.dns_autoconfigured ? " — no DNS setup needed" : ""}.`);
7845
+ if (r.public_url) console.log(pc.green(" ✓") + ` Live at ${pc.underline(r.public_url)}`);
7846
+ console.log(pc.dim(" The TLS certificate is issuing now (usually a minute or two)."));
7847
+ } else if (r.message) console.log(pc.dim(` ${r.message}`));
7848
+ console.log();
7849
+ }
7850
+ async function domainsListCommand() {
7851
+ const { projectId } = await loadProjectConfig();
7852
+ const res = await listPurchasedDomainsViaApi(projectId);
7853
+ console.log();
7854
+ if (res.data.length === 0) {
7855
+ console.log(pc.dim(" No domains purchased through Amba yet."));
7856
+ console.log(pc.dim(" Buy one with: amba domains buy <domain> --site <site>"));
7857
+ console.log();
7858
+ return;
7859
+ }
7860
+ for (const d of res.data) {
7861
+ const domain = String(d.domain ?? "");
7862
+ const status = String(d.status ?? "");
7863
+ const coloured = status === "active" ? pc.green(status) : status === "failed" ? pc.red(status) : pc.yellow(status);
7864
+ console.log(` ${pc.bold(domain)} ${coloured}`);
7865
+ }
7866
+ console.log();
7867
+ }
7868
+ //#endregion
7869
+ //#region src/commands/iac.ts
7870
+ /**
7871
+ * `amba apply` / `amba diff` / `amba export` — declarative
7872
+ * infrastructure-as-code for a project's reusable configuration.
7873
+ *
7874
+ * A declarative bundle (default `amba.config.json`) captures the entities a
7875
+ * developer hand-builds once and wants to stand up identically elsewhere:
7876
+ * remote configs, collection schemas, content libraries + items, currencies,
7877
+ * achievements, streaks, challenges, leaderboards, and xp_rules. It carries
7878
+ * CONFIGURATION ONLY — no secrets, no per-user data.
7879
+ *
7880
+ * These commands wrap the platform's promotion endpoints:
7881
+ * GET /v1/admin/projects/:p/promotion/export
7882
+ * POST /v1/admin/projects/:p/promotion/import (mode=skip_existing|merge)
7883
+ *
7884
+ * - `amba export [file]` writes the current project's bundle to disk.
7885
+ * - `amba apply [file]` POSTs the local bundle to the import endpoint and
7886
+ * prints a `{ created, updated, skipped }` summary
7887
+ * per section (the server's authoritative result).
7888
+ * - `amba diff [file]` shows what `apply` WOULD change without writing.
7889
+ * The import route has NO server-side dry-run, so
7890
+ * `diff` exports the current project and computes the
7891
+ * created/updated/skipped per section LOCALLY.
7892
+ */
7893
+ /** Default declarative bundle filename when no `[file]` is passed. */
7894
+ const DEFAULT_BUNDLE_FILE = "amba.config.json";
7895
+ /** The ordered list of bundle sections + their natural-key field. */
7896
+ const BUNDLE_SECTIONS = [
7897
+ {
7898
+ key: "remote_configs",
7899
+ naturalKey: "key"
7900
+ },
7901
+ {
7902
+ key: "collections",
7903
+ naturalKey: "name"
7904
+ },
7905
+ {
7906
+ key: "currencies",
7907
+ naturalKey: "code"
7908
+ },
7909
+ {
7910
+ key: "achievements",
7911
+ naturalKey: "key"
7912
+ },
7913
+ {
7914
+ key: "streaks",
7915
+ naturalKey: "key"
7916
+ },
7917
+ {
7918
+ key: "leaderboards",
7919
+ naturalKey: "name"
7920
+ },
7921
+ {
7922
+ key: "xp_rules",
7923
+ naturalKey: "name"
7924
+ },
7925
+ {
7926
+ key: "challenges",
7927
+ naturalKey: "name"
7928
+ },
7929
+ {
7930
+ key: "content_libraries",
7931
+ naturalKey: "name"
7932
+ },
7933
+ {
7934
+ key: "monetization",
7935
+ naturalKey: "key"
7936
+ }
7937
+ ];
7938
+ /**
7939
+ * Read + parse a declarative bundle from disk. JSON only — we deliberately
7940
+ * don't pull in a YAML dependency the CLI doesn't already ship (the package
7941
+ * has no YAML lib), so `.yaml`/`.yml` files are rejected with a clear hint
7942
+ * rather than silently mis-parsed.
7943
+ */
7944
+ async function readBundleFile(file) {
7945
+ const path = resolve(file);
7946
+ if (/\.ya?ml$/i.test(path)) throw new Error(`YAML bundles are not supported. Convert ${file} to JSON (the bundle format is plain JSON).`);
7947
+ let raw;
7948
+ try {
7949
+ raw = await readFile(path, "utf-8");
7950
+ } catch {
7951
+ throw new Error(`Bundle file not found: ${file}. Run \`amba export\` to scaffold one from your current project.`);
7952
+ }
7953
+ let parsed;
7954
+ try {
7955
+ parsed = JSON.parse(raw);
7956
+ } catch (err) {
7957
+ throw new Error(`Failed to parse ${file} as JSON: ${err instanceof Error ? err.message : String(err)}`);
7958
+ }
7959
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Bundle in ${file} must be a JSON object with a \`version\` field.`);
7960
+ const bundle = parsed;
7961
+ if (typeof bundle["version"] !== "number") throw new Error(`Bundle in ${file} is missing a numeric \`version\` field. Re-export it with \`amba export\`.`);
7962
+ return bundle;
7963
+ }
7964
+ /**
7965
+ * Index a section array by its natural key. Definitions missing the key (or
7966
+ * non-object entries) are skipped — they can't be matched and the server
7967
+ * will reject them on apply.
7968
+ */
7969
+ function indexByNaturalKey(items, naturalKey) {
7970
+ const out = /* @__PURE__ */ new Map();
7971
+ if (!Array.isArray(items)) return out;
7972
+ for (const item of items) {
7973
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
7974
+ const rec = item;
7975
+ const k = rec[naturalKey];
7976
+ if (typeof k !== "string" && typeof k !== "number") continue;
7977
+ out.set(String(k), rec);
7978
+ }
7979
+ return out;
7980
+ }
7981
+ /**
7982
+ * Pure section-by-section diff of a desired bundle against the current
7983
+ * project's exported bundle. The classification MIRRORS the server's import
7984
+ * (`apps/api/src/lib/promotion-bundle.ts`) so `amba diff` agrees with what
7985
+ * `amba apply` will report:
7986
+ * - created — natural key absent in current.
7987
+ * - updated — natural key present AND `mode === 'merge'`. The server runs an
7988
+ * UNCONDITIONAL update for every existing key in merge mode and
7989
+ * increments `updated` — it does NOT compare field values, so we
7990
+ * don't either (a local field comparison made `diff` under-report).
7991
+ * - skipped — natural key present under `skip_existing`; OR an existing
7992
+ * collection (always — collections are create-if-absent, never
7993
+ * merged); OR a `remote_configs` row flagged `is_system: true`
7994
+ * (the server always skips those before any existence check); OR
7995
+ * a desired row with no valid natural key (can't be matched
7996
+ * against the export, and the server import dedups by that key —
7997
+ * so it is never a reliable create).
7998
+ *
7999
+ * Within one bundle section, the server walks rows sequentially and each
8000
+ * insert mutates the DB, so a SECOND row sharing a natural key already created
8001
+ * by an EARLIER row in the same section is seen as existing (→ skip under
8002
+ * skip_existing, update under merge) rather than created a second time. We
8003
+ * track already-seen keys per section to mirror that, so duplicate-keyed local
8004
+ * rows don't inflate the `created` total versus what `apply` does.
8005
+ */
8006
+ function computeLocalDiff(desired, current, mode) {
8007
+ const diff = {};
8008
+ for (const { key, naturalKey } of BUNDLE_SECTIONS) {
8009
+ const desiredItems = desired[key];
8010
+ if (!Array.isArray(desiredItems) || desiredItems.length === 0) continue;
8011
+ const currentIndex = indexByNaturalKey(current[key], naturalKey);
8012
+ const section = {
8013
+ created: 0,
8014
+ updated: 0,
8015
+ skipped: 0
8016
+ };
8017
+ const seen = /* @__PURE__ */ new Set();
8018
+ for (const item of desiredItems) {
8019
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
8020
+ const rec = item;
8021
+ if (key === "remote_configs" && rec["is_system"] === true) {
8022
+ section.skipped++;
8023
+ continue;
8024
+ }
8025
+ const k = rec[naturalKey];
8026
+ const matchKey = typeof k === "string" || typeof k === "number" ? String(k) : null;
8027
+ if (matchKey === null) {
8028
+ section.skipped++;
8029
+ continue;
8030
+ }
8031
+ const existing = currentIndex.has(matchKey) || seen.has(matchKey);
8032
+ seen.add(matchKey);
8033
+ if (!existing) {
8034
+ section.created++;
8035
+ continue;
8036
+ }
8037
+ if (key === "collections" || mode === "skip_existing") {
8038
+ section.skipped++;
8039
+ continue;
8040
+ }
8041
+ section.updated++;
8042
+ }
8043
+ diff[key] = section;
8044
+ }
8045
+ const monDiff = computeMonetizationLocalDiff(desired["monetization"], current["monetization"], mode);
8046
+ if (monDiff) diff["monetization"] = monDiff;
8047
+ return diff;
8048
+ }
8049
+ /**
8050
+ * Natural keys per monetization sub-collection. All key on `key` except the
8051
+ * package join, which is unique within an offering — but for diff purposes the
8052
+ * package counts roll into their owning offering, so only the four top-level
8053
+ * collections are counted here.
8054
+ */
8055
+ const MONETIZATION_SUBSECTIONS = [
8056
+ "entitlements",
8057
+ "products",
8058
+ "offerings",
8059
+ "paywalls"
8060
+ ];
8061
+ function computeMonetizationLocalDiff(desired, current, mode) {
8062
+ if (desired === null || typeof desired !== "object" || Array.isArray(desired)) return null;
8063
+ const desiredObj = desired;
8064
+ const currentObj = current !== null && typeof current === "object" && !Array.isArray(current) ? current : {};
8065
+ const section = {
8066
+ created: 0,
8067
+ updated: 0,
8068
+ skipped: 0
8069
+ };
8070
+ let any = false;
8071
+ for (const sub of MONETIZATION_SUBSECTIONS) {
8072
+ const desiredItems = desiredObj[sub];
8073
+ if (!Array.isArray(desiredItems) || desiredItems.length === 0) continue;
8074
+ any = true;
8075
+ const currentIndex = indexByNaturalKey(currentObj[sub], "key");
8076
+ const seen = /* @__PURE__ */ new Set();
8077
+ for (const item of desiredItems) {
8078
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
8079
+ const k = item["key"];
8080
+ const matchKey = typeof k === "string" || typeof k === "number" ? String(k) : null;
8081
+ if (matchKey === null) {
8082
+ section.skipped++;
8083
+ continue;
8084
+ }
8085
+ const existing = currentIndex.has(matchKey) || seen.has(matchKey);
8086
+ seen.add(matchKey);
8087
+ if (!existing) section.created++;
8088
+ else if (mode === "skip_existing") section.skipped++;
8089
+ else section.updated++;
8090
+ }
8091
+ }
8092
+ return any ? section : null;
8093
+ }
8094
+ /** Roll a per-section diff up into grand totals across every section. */
8095
+ function summarizeDiff(diff) {
8096
+ return Object.values(diff).reduce((acc, s) => ({
8097
+ created: acc.created + s.created,
8098
+ updated: acc.updated + s.updated,
8099
+ skipped: acc.skipped + s.skipped
8100
+ }), {
8101
+ created: 0,
8102
+ updated: 0,
8103
+ skipped: 0
8104
+ });
8105
+ }
8106
+ function renderSummary(summary, verb) {
8107
+ let created = 0;
8108
+ let updated = 0;
8109
+ let skipped = 0;
8110
+ const sections = Object.keys(summary);
8111
+ if (sections.length === 0) console.log(pc.dim(" Nothing to " + verb + " — bundle is empty."));
8112
+ for (const section of sections) {
8113
+ const s = summary[section];
8114
+ created += s.created;
8115
+ updated += s.updated;
8116
+ skipped += s.skipped;
8117
+ console.log(` ${pc.bold(section)} ${pc.green(`${s.created} created`)} · ${pc.cyan(`${s.updated} updated`)} · ` + pc.dim(`${s.skipped} skipped`));
8118
+ }
8119
+ console.log();
8120
+ console.log(pc.dim(` Total: ${created} created · ${updated} updated · ${skipped} skipped`));
8121
+ }
8122
+ /**
8123
+ * `amba export [file]` — write the current project's declarative bundle to
8124
+ * disk so it can be checked in, diffed, and re-applied.
8125
+ */
8126
+ async function exportConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
8127
+ const projectId = await requireProjectId(process.cwd(), options.project);
8128
+ const path = resolve(file);
8129
+ console.log();
8130
+ console.log(pc.bold(` amba export ${pc.cyan(file)}`));
8131
+ console.log(pc.dim(" ─────────────────────────────────"));
8132
+ console.log();
8133
+ console.log(pc.dim(` Project: ${projectId}`));
8134
+ console.log(pc.dim(" Fetching current configuration…"));
8135
+ const bundle = (await exportPromotionBundle(projectId)).data;
8136
+ await writeFile(path, JSON.stringify(bundle, null, 2) + "\n", "utf-8");
8137
+ const sectionCount = BUNDLE_SECTIONS.reduce((sum, { key }) => {
8138
+ const v = bundle[key];
8139
+ if (Array.isArray(v)) return sum + v.length;
8140
+ if (key === "monetization" && v !== null && typeof v === "object") {
8141
+ const m = v;
8142
+ return sum + MONETIZATION_SUBSECTIONS.reduce((n, sub) => n + (Array.isArray(m[sub]) ? m[sub].length : 0), 0);
8143
+ }
8144
+ return sum;
8145
+ }, 0);
8146
+ console.log();
8147
+ console.log(pc.green(" ✓") + ` Wrote ${pc.cyan(file)} (${sectionCount} definitions).`);
8148
+ console.log(pc.dim(" Edit it, then ") + pc.cyan("amba diff") + pc.dim(" to preview and ") + pc.cyan("amba apply") + pc.dim(" to apply."));
8149
+ console.log();
8150
+ }
8151
+ /**
8152
+ * `amba apply [file]` — read a local declarative bundle and POST it to the
8153
+ * import endpoint. Prints the server's authoritative `{ created, updated,
8154
+ * skipped }` per section.
8155
+ */
8156
+ async function applyConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
8157
+ const projectId = await requireProjectId(process.cwd(), options.project);
8158
+ const mode = options.mode ?? "skip_existing";
8159
+ const bundle = await readBundleFile(file);
8160
+ console.log();
8161
+ console.log(pc.bold(` amba apply ${pc.cyan(file)}`));
8162
+ console.log(pc.dim(" ─────────────────────────────────"));
8163
+ console.log();
8164
+ console.log(pc.dim(` Project: ${projectId}`));
8165
+ console.log(pc.dim(` Mode: ${mode}`));
8166
+ console.log(pc.dim(" Applying…"));
8167
+ console.log();
8168
+ let res;
8169
+ try {
8170
+ res = await importPromotionBundle(projectId, bundle, mode);
8171
+ } catch (err) {
8172
+ if (err instanceof ApiClientError && (err.statusCode === 400 || err.statusCode === 422)) throw new Error(`${err.message}\n → Re-export a fresh bundle with \`amba export\` if this was hand-edited.`);
8173
+ throw err;
8174
+ }
8175
+ renderSummary(res.data.summary, "apply");
8176
+ console.log();
8177
+ }
8178
+ /**
8179
+ * `amba diff [file]` — show what `apply` WOULD change without writing.
8180
+ *
8181
+ * The import route has no server-side dry-run, so this fetches the current
8182
+ * project's exported bundle and computes the diff LOCALLY. Output mirrors the
8183
+ * `apply` summary shape so the two read identically.
8184
+ */
8185
+ async function diffConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
8186
+ const projectId = await requireProjectId(process.cwd(), options.project);
8187
+ const mode = options.mode ?? "skip_existing";
8188
+ const desired = await readBundleFile(file);
8189
+ console.log();
8190
+ console.log(pc.bold(` amba diff ${pc.cyan(file)}`));
8191
+ console.log(pc.dim(" ─────────────────────────────────"));
8192
+ console.log();
8193
+ console.log(pc.dim(` Project: ${projectId}`));
8194
+ console.log(pc.dim(` Mode: ${mode}`));
8195
+ console.log(pc.dim(" (computed locally — the import route has no server dry-run)"));
8196
+ console.log(pc.dim(" Fetching current configuration…"));
8197
+ console.log();
8198
+ const diff = computeLocalDiff(desired, (await exportPromotionBundle(projectId)).data, mode);
8199
+ renderSummary(diff, "change");
8200
+ const totals = summarizeDiff(diff);
8201
+ console.log();
8202
+ if (totals.created > 0 || totals.updated > 0) console.log(pc.dim(" Run ") + pc.cyan(`amba apply ${file}`) + pc.dim(" to apply these changes."));
8203
+ else if (totals.skipped > 0) if (mode === "skip_existing") {
8204
+ console.log(pc.green(" ✓") + " apply would make no changes (skip_existing leaves existing entities untouched).");
8205
+ console.log(pc.dim(" Skipped entities are NOT compared — re-run with ") + pc.cyan("--mode merge") + pc.dim(" to overwrite them, or `amba export` to see current values."));
8206
+ } else console.log(pc.green(" ✓") + " apply would make no changes.");
8207
+ else console.log(pc.green(" ✓") + " No changes — nothing in the bundle to apply.");
8208
+ console.log();
8209
+ }
8210
+ //#endregion
6648
8211
  //#region src/index.ts
6649
8212
  const program = new Command();
6650
- program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("0.1.0");
8213
+ program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("4.0.4");
6651
8214
  program.option("--token <pat>", "Use a Personal Access Token for headless / CI / agent use (overrides ~/.amba/credentials.json + AMBA_PAT env)");
6652
8215
  program.hook("preAction", (thisCommand) => {
6653
8216
  setBearerOverride(resolveTokenSource({
@@ -6789,8 +8352,8 @@ program.command("schema").description("Schema export commands").command("export"
6789
8352
  }));
6790
8353
  });
6791
8354
  const functions = program.command("functions").description("Customer serverless functions deployed to the edge");
6792
- functions.command("deploy <file>").description("Bundle a function file and deploy to the dispatch namespace").option("--name <name>", "Function name (default: filename without extension)").option("--dry-run", "Bundle and report size without uploading").option("--rate-limit-window <duration>", "Rate-limit window: 60s | 5m | 1h").option("--rate-limit-max <int>", "Rate-limit max requests per window", (v) => Number.parseInt(v, 10)).option("--rate-limit-key <kind>", "Rate-limit bucket key: user_id | ip").action(async (file, opts) => {
6793
- await runAction(() => functionsDeployCommand(file, opts));
8355
+ functions.command("deploy <target>").description("Bundle a function (a single file OR a directory) and deploy to the dispatch namespace. A directory resolves its entry by convention (index.ts/js/mjs) or via --entry.").option("--name <name>", "Function name (default: file name without extension, or the directory leaf name)").option("--entry <file>", "Entry file when <target> is a directory (relative to it). Default: index.ts/js/mjs.").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").option("--public", "Deploy WITHOUT the X-Api-Key gate so external webhook senders can call it. You MUST verify the webhook signature in your handler. Default: private.").action(async (target, opts) => {
8356
+ await runAction(() => functionsDeployCommand(target, opts));
6794
8357
  });
6795
8358
  functions.command("list").description("List active functions for the current project").action(async () => {
6796
8359
  await runAction(functionsListCommand);
@@ -6807,11 +8370,12 @@ functions.command("dev <file>").description("Run a local dev server for your fun
6807
8370
  noWatch: opts.watch === false
6808
8371
  }));
6809
8372
  });
6810
- functions.command("logs <name>").description("Stream log events for a deployed function").option("--since <iso>", "Start of the time range (default: 1 hour ago)").option("--until <iso>", "End of the time range (default: now). Ignored on --tail.").option("--limit <n>", "Max events per fetch (default 100, max 1000)", (v) => parseInt(v, 10)).option("--tail", "Follow new events; polls every 3s. Ctrl+C to stop.").option("--follow", "Alias for --tail (kept for backwards compatibility with v1 log commands).").option("--json", "NDJSON output to stdout (one event per line)").action(async (name, opts) => {
8373
+ 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("--request-id <id>", "Only show events whose logs mention this request id (e.g. r_...). Works with --tail.").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) => {
6811
8374
  await runAction(() => functionsLogsCommand(name, {
6812
8375
  since: opts.since,
6813
8376
  until: opts.until,
6814
8377
  limit: opts.limit,
8378
+ requestId: opts.requestId,
6815
8379
  tail: opts.tail || opts.follow,
6816
8380
  json: opts.json
6817
8381
  }));
@@ -6827,17 +8391,17 @@ consumers.command("unbind <queue-name>").description("Remove the binding for a q
6827
8391
  await runAction(() => functionsConsumersUnbindCommand(queueName));
6828
8392
  });
6829
8393
  const secrets = program.command("secrets").description("Function secrets (GCP canonical, Workers-Secret synced)");
6830
- 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) => {
8394
+ secrets.command("set <name> [value]").description("Store a secret and queue its sync to your deployed function(s). Omit --function for a project-wide secret. Use --from-stdin to keep the value out of shell history.").option("--function <name>", "Function the secret binds to. OMIT this flag to set a PROJECT-WIDE secret — one value visible to every function in the project (and to any function deployed later). With it, the secret is scoped to just that function. Use the same name as the entry passed to 'amba functions deploy'. Secrets may be set before the function is deployed; the sync drains once you deploy.").option("--env <env>", "'dev' | 'prod' — INFORMATIONAL ONLY. Both share one secret namespace per project. Use distinct secret names (e.g. STRIPE_KEY_DEV / STRIPE_KEY_PROD) or two amba projects for real env isolation.").option("--from-stdin", "Read the secret value from stdin instead of the positional arg. Mutually exclusive with <value>. Pipe in: `echo $KEY | amba secrets set NAME --from-stdin`.").action(async (name, value, opts) => {
6831
8395
  await runAction(() => secretsSetCommand(name, value, {
6832
8396
  function: opts.function,
6833
8397
  env: opts.env ?? "dev",
6834
8398
  fromStdin: opts.fromStdin
6835
8399
  }));
6836
8400
  });
6837
- secrets.command("list").description("List secret sync status for the current project").action(async () => {
6838
- await runAction(secretsListCommand);
8401
+ secrets.command("list").description("List secret sync status for the current project").option("--function <name>", "Filter to secrets for a single function (avoids listing all functions at once)").action(async (opts) => {
8402
+ await runAction(() => secretsListCommand({ function: opts.function }));
6839
8403
  });
6840
- secrets.command("unset <name>").description("Remove a secret from GCP Secret Manager (Workers Secret cleared on next deploy)").requiredOption("--function <name>", "Function name the secret binds to").action(async (name, opts) => {
8404
+ secrets.command("unset <name>").description("Remove a secret (Worker binding cleared on next deploy)").option("--function <name>", "Function the secret was bound to. Omit to remove the project-wide secret of this name.").action(async (name, opts) => {
6841
8405
  await runAction(() => secretsUnsetCommand(name, opts));
6842
8406
  });
6843
8407
  const billing = program.command("billing").description("Per-project subscription, headroom, and spend controls");
@@ -6874,6 +8438,60 @@ billing.command("set-ceiling <amount>").description("Cap the monthly bill at <am
6874
8438
  }
6875
8439
  await runAction(() => billingSetCeilingCommand({ ceiling }));
6876
8440
  });
8441
+ const payments = program.command("payments").description("Accept payments in your app (Amba is the rail; your app is the seller)");
8442
+ payments.command("setup").description("Create the connected payments account and print the hosted onboarding URL").option("--country <code>", "Two-letter ISO country code for the seller (e.g. US)").option("--email <email>", "Contact email for the connected account").action(async (opts) => {
8443
+ if (opts.country && !/^[A-Za-z]{2}$/.test(opts.country)) {
8444
+ console.log(" ✗ --country must be a two-letter ISO code (e.g. US)");
8445
+ process.exit(1);
8446
+ }
8447
+ await runAction(() => paymentsSetupCommand({
8448
+ country: opts.country ? opts.country.toUpperCase() : void 0,
8449
+ email: opts.email
8450
+ }));
8451
+ });
8452
+ payments.command("status").description("Show onboarding stage + whether charges/payouts are enabled").action(async () => {
8453
+ await runAction(paymentsStatusCommand);
8454
+ });
8455
+ payments.command("set-fee <bps>").description("Set the default platform fee in basis points (250 = 2.5%), or 'off' to clear").action(async (bps) => {
8456
+ let value;
8457
+ if (bps.toLowerCase() === "off" || bps === "") value = null;
8458
+ else {
8459
+ const parsed = Number(bps);
8460
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 1e4) {
8461
+ console.log(" ✗ bps must be an integer between 0 and 10000, or \"off\"");
8462
+ process.exit(1);
8463
+ }
8464
+ value = parsed;
8465
+ }
8466
+ await runAction(() => paymentsSetFeeCommand({ bps: value }));
8467
+ });
8468
+ payments.command("balance").description("Show the connected account's available and pending balance").action(async () => {
8469
+ await runAction(paymentsBalanceCommand);
8470
+ });
8471
+ payments.command("payouts").description("List recent payouts to the seller bank account").option("--limit <n>", "How many payouts to show (default 10)").action(async (opts) => {
8472
+ const limit = opts.limit ? Number(opts.limit) : void 0;
8473
+ if (limit !== void 0 && (!Number.isInteger(limit) || limit < 1 || limit > 100)) {
8474
+ console.log(" ✗ --limit must be an integer between 1 and 100");
8475
+ process.exit(1);
8476
+ }
8477
+ await runAction(() => paymentsPayoutsCommand({ limit }));
8478
+ });
8479
+ const monetization = program.command("monetization").description("Monetization control plane: plan, drift, export, adopt, and apply your subscription config as Infrastructure-as-Code.");
8480
+ monetization.command("plan").description("Preview the three-way diff (declared vs adopted vs live) + store-floor + drift").action(async () => {
8481
+ await runAction(monetizationPlanCommand);
8482
+ });
8483
+ monetization.command("drift").description("Show managed objects changed out-of-band in the provider").action(async () => {
8484
+ await runAction(monetizationDriftCommand);
8485
+ });
8486
+ monetization.command("export").description("Snapshot the live monetization config to a declarative bundle (stdout or --file)").option("--file <path>", "Write the bundle to this file instead of stdout").action(async (opts) => {
8487
+ await runAction(() => monetizationExportCommand(opts));
8488
+ });
8489
+ monetization.command("adopt").description("Record the live config as the managed baseline (writes Amba state only; idempotent)").option("--all", "Adopt the entire live config").action(async (opts) => {
8490
+ await runAction(() => monetizationAdoptCommand(opts));
8491
+ });
8492
+ monetization.command("apply").description("Apply the declared config to the provider. Additive ops (create entitlements/offerings/packages/paywall-drafts + attach products) apply automatically; destructive ops (detach/archive/remove) need --confirm.").option("--confirm", "Confirm and execute the gated destructive ops (detach/archive/remove). Without it they are reported but not run.").option("--allow-detach-live", "Accept detaching a product a live customer currently resolves (otherwise refused).").option("--reconcile <mode>", "Out-of-band drift mode: 'amba' (default) or 'adopt'.").action(async (opts) => {
8493
+ await runAction(() => monetizationApplyCommand(opts));
8494
+ });
6877
8495
  const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
6878
8496
  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) => {
6879
8497
  await runAction(() => collectionsCreateCommand(name, opts));
@@ -6930,6 +8548,25 @@ sitesDomain.command("remove <hostname>").description("Detach a custom hostname (
6930
8548
  zoneId: opts.zoneId
6931
8549
  }));
6932
8550
  });
8551
+ const domains = program.command("domains").description("Buy a domain through Amba and connect it to a site");
8552
+ domains.command("search <query>").description("Search for available domains + prices (free)").option("--limit <n>", "Max suggestions (1-50, default 20)", (v) => parseInt(v, 10)).action(async (query, opts) => {
8553
+ await runAction(() => domainsSearchCommand(query, { limit: opts.limit }));
8554
+ });
8555
+ domains.command("check <domains...>").description("Authoritative availability + price for specific domains (free)").action(async (domainArgs) => {
8556
+ await runAction(() => domainsCheckCommand(domainArgs));
8557
+ });
8558
+ domains.command("buy <domain>").description("Buy a domain + connect it to a site (surfaces cost, asks to confirm)").requiredOption("--site <name>", "Site name to connect the domain to").option("--yes", "Skip the interactive cost confirmation prompt").option("--no-privacy", "Disable WHOIS privacy (privacy is on by default)").option("--no-auto-renew", "Disable auto-renew (on by default)").option("--years <n>", "Registration length in years (default 1)", (v) => parseInt(v, 10)).action(async (domain, opts) => {
8559
+ await runAction(() => domainsBuyCommand(domain, {
8560
+ site: opts.site,
8561
+ yes: opts.yes,
8562
+ noPrivacy: opts.privacy === false,
8563
+ noAutoRenew: opts.autoRenew === false,
8564
+ years: opts.years
8565
+ }));
8566
+ });
8567
+ domains.command("list").description("List domains this project has purchased through Amba").action(async () => {
8568
+ await runAction(domainsListCommand);
8569
+ });
6933
8570
  const aiProviders = program.command("ai").description("AI gateway — manage provider keys + prompts").command("providers").description("Per-project provider key registration (Anthropic, OpenAI)");
6934
8571
  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) => {
6935
8572
  await runAction(() => aiProvidersAddCommand(provider, opts));
@@ -6940,6 +8577,29 @@ aiProviders.command("list").description("List registered AI providers for the cu
6940
8577
  aiProviders.command("delete <provider>").description("Remove a provider registration (refuses if active prompts reference it)").action(async (provider) => {
6941
8578
  await runAction(() => aiProvidersDeleteCommand(provider));
6942
8579
  });
8580
+ function parseImportMode(raw) {
8581
+ if (raw === void 0) return "skip_existing";
8582
+ if (raw === "merge" || raw === "skip_existing") return raw;
8583
+ console.log(" ✗ --mode must be \"skip_existing\" (default) or \"merge\"");
8584
+ process.exit(1);
8585
+ }
8586
+ program.command("export [file]").description("Write the current project's declarative configuration bundle to disk (default: amba.config.json)").option("--project <id>", "Project id (overrides .env.local)").action(async (file, opts) => {
8587
+ await runAction(() => exportConfigCommand(file, { project: opts.project }));
8588
+ });
8589
+ program.command("diff [file]").description("Show what `amba apply` WOULD change (created/updated/skipped per section) without writing. Computed locally by exporting the current project and comparing — the import route has no server dry-run.").option("--project <id>", "Project id (overrides .env.local)").option("--mode <mode>", "'skip_existing' (default) or 'merge'").action(async (file, opts) => {
8590
+ const mode = parseImportMode(opts.mode);
8591
+ await runAction(() => diffConfigCommand(file, {
8592
+ project: opts.project,
8593
+ mode
8594
+ }));
8595
+ });
8596
+ program.command("apply [file]").description("Apply a declarative configuration bundle (default: amba.config.json) — JSON only. Prints { created, updated, skipped } per section.").option("--project <id>", "Project id (overrides .env.local)").option("--mode <mode>", "'skip_existing' (default — no-op on conflict) or 'merge' (refresh existing definitions)").action(async (file, opts) => {
8597
+ const mode = parseImportMode(opts.mode);
8598
+ await runAction(() => applyConfigCommand(file, {
8599
+ project: opts.project,
8600
+ mode
8601
+ }));
8602
+ });
6943
8603
  program.parse();
6944
8604
  //#endregion
6945
8605
  export {};