@layers/amba 4.0.3 → 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
  *
@@ -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
  }
@@ -672,6 +1123,17 @@ async function deleteFunctionViaApi(projectId, functionName, options) {
672
1123
  async function listSiteDomains(projectId, siteName) {
673
1124
  return request("GET", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/domains`);
674
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
+ }
675
1137
  async function validateApiKey(apiKey) {
676
1138
  const res = await fetch(`${getApiRoot()}/v1/auth/validate`, {
677
1139
  method: "POST",
@@ -2149,6 +2611,14 @@ Tell the user where their credentials live:
2149
2611
 
2150
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"\`.
2151
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
+
2152
2622
  ## Step 1 — Classify the app
2153
2623
 
2154
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:
@@ -2270,7 +2740,7 @@ SKIPPED (low signal — re-run with /amba <feature> if you want them)
2270
2740
  NEEDS YOUR INPUT
2271
2741
  - Apple Sign In: add the "Sign in with Apple" capability in Xcode > Signing & Capabilities.
2272
2742
  - Google Sign In: paste your Google OAuth client ID into amba_projects_update({ google_oauth_client_id: "..." }).
2273
- - APNs / FCM: upload credentials in app.amba.dev before push delivers.
2743
+ - APNs / FCM: configure credentials with amba_integrations_configure (paste the .p8 / service-account JSON inline) before push delivers.
2274
2744
 
2275
2745
  NEXT STEPS
2276
2746
  - Paste AMBA_CLIENT_KEY into your build env (already shown above)
@@ -4081,7 +4551,7 @@ function confirm$1(question) {
4081
4551
  });
4082
4552
  });
4083
4553
  }
4084
- function handleError$1(err) {
4554
+ function handleError$2(err) {
4085
4555
  if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
4086
4556
  else console.log(pc.red(" ✗") + ` ${err.message}`);
4087
4557
  else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
@@ -4131,7 +4601,7 @@ async function projectsListCommand() {
4131
4601
  console.log(pc.dim(` ${projects.length} project${projects.length === 1 ? "" : "s"}`));
4132
4602
  console.log();
4133
4603
  } catch (err) {
4134
- handleError$1(err);
4604
+ handleError$2(err);
4135
4605
  }
4136
4606
  }
4137
4607
  async function projectsCreateCommand(input) {
@@ -4177,7 +4647,7 @@ async function projectsCreateCommand(input) {
4177
4647
  console.log(pc.dim(" Next: ") + pc.cyan(`amba projects show ${id}`));
4178
4648
  console.log();
4179
4649
  } catch (err) {
4180
- handleError$1(err);
4650
+ handleError$2(err);
4181
4651
  }
4182
4652
  }
4183
4653
  async function projectsUpdateCommand(projectId, input) {
@@ -4203,7 +4673,7 @@ async function projectsUpdateCommand(projectId, input) {
4203
4673
  console.log();
4204
4674
  process.exit(1);
4205
4675
  }
4206
- handleError$1(err);
4676
+ handleError$2(err);
4207
4677
  }
4208
4678
  }
4209
4679
  async function projectsShowCommand(projectId) {
@@ -4221,7 +4691,7 @@ async function projectsShowCommand(projectId) {
4221
4691
  console.log();
4222
4692
  process.exit(1);
4223
4693
  }
4224
- handleError$1(err);
4694
+ handleError$2(err);
4225
4695
  }
4226
4696
  }
4227
4697
  async function projectsDeleteCommand(projectId, opts = {}) {
@@ -4246,7 +4716,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
4246
4716
  console.log();
4247
4717
  process.exit(1);
4248
4718
  }
4249
- handleError$1(err);
4719
+ handleError$2(err);
4250
4720
  }
4251
4721
  }
4252
4722
  //#endregion
@@ -4989,6 +5459,47 @@ function parseEnv(content) {
4989
5459
  * empty; customer code is expected to be self-contained.
4990
5460
  */
4991
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
+ /**
4992
5503
  * Modules the bundler treats as `external` by default. The Amba
4993
5504
  * function runtime exposes zero npm packages — there is no "platform
4994
5505
  * stdlib" for customer functions to import. Keep this list empty.
@@ -5011,6 +5522,91 @@ var BundleSizeError = class extends Error {
5011
5522
  }
5012
5523
  };
5013
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
+ /**
5014
5610
  * Bundle a customer function file for upload to a Workers dispatch
5015
5611
  * namespace. Returns the bundled code + metadata; throws
5016
5612
  * `BundleSizeError` when the compressed size exceeds the cap so the CLI
@@ -5043,6 +5639,7 @@ async function bundleFunction(options) {
5043
5639
  const compressedSize = await gzipSizeOf(output.contents);
5044
5640
  const max = options.maxSizeBytes ?? 8388608;
5045
5641
  if (compressedSize > max) throw new BundleSizeError(compressedSize, max);
5642
+ if (options.validateHandlerShape !== false) await assertModuleWorkerShape(code);
5046
5643
  return {
5047
5644
  code,
5048
5645
  sha256: await hashSha256Hex(output.contents),
@@ -5091,16 +5688,19 @@ function formatBytes$1(n) {
5091
5688
  * records the deployment in a single round-trip. The other subcommands
5092
5689
  * are thin shells over the admin API helpers in `api-client.ts`.
5093
5690
  */
5094
- async function functionsDeployCommand(entryPoint, options = {}) {
5691
+ async function functionsDeployCommand(target, options = {}) {
5095
5692
  const projectId = (await loadProjectConfig()).projectId;
5096
- 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;
5097
5697
  validateFunctionName(functionName);
5098
5698
  const rateLimit = parseRateLimitFlags(options);
5099
5699
  console.log();
5100
5700
  console.log(pc.bold(` amba functions deploy ${pc.cyan(functionName)}`));
5101
5701
  console.log(pc.dim(" ─────────────────────────────────"));
5102
5702
  console.log();
5103
- console.log(pc.dim(" Bundling…"));
5703
+ console.log(pc.dim(` Bundling ${entryPoint}…`));
5104
5704
  const bundle = await bundleFunction({ entryPoint });
5105
5705
  printBundleReport(bundle);
5106
5706
  if (options.dryRun) {
@@ -5113,7 +5713,8 @@ async function functionsDeployCommand(entryPoint, options = {}) {
5113
5713
  result = await deployFunctionViaApi(projectId, {
5114
5714
  name: functionName,
5115
5715
  bundleCode: bundle.code,
5116
- rate_limit: rateLimit
5716
+ rate_limit: rateLimit,
5717
+ public: options.public === true ? true : void 0
5117
5718
  });
5118
5719
  } catch (err) {
5119
5720
  if (err instanceof ApiClientError && err.statusCode === 503) {
@@ -5126,6 +5727,7 @@ async function functionsDeployCommand(entryPoint, options = {}) {
5126
5727
  console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version}`)}`);
5127
5728
  console.log(pc.green(" ✓") + ` URL: ${pc.underline(result.fn_url)}`);
5128
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."));
5129
5731
  console.log();
5130
5732
  }
5131
5733
  async function functionsListCommand() {
@@ -5481,11 +6083,15 @@ try {
5481
6083
  return fetchFn(req, env, ctx);
5482
6084
  };
5483
6085
  } else if (typeof mod.default === 'function') {
5484
- // Legacy plain-function shape — still accepted for backward compat but env
5485
- // parameter is not available (secrets only reachable via process.env).
5486
- handler = mod.default;
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);
5487
6093
  } else {
5488
- process.stderr.write(' Error: entry file must export default { async fetch(req, env, ctx) { ... } }\\n');
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');
5489
6095
  process.exit(2);
5490
6096
  }
5491
6097
  } catch (err) {
@@ -5653,7 +6259,8 @@ async function functionsLogsCommand(functionName, options = {}) {
5653
6259
  const res = await getFunctionLogs(projectConfig.projectId, functionName, {
5654
6260
  since: options.since,
5655
6261
  until: options.until,
5656
- limit: options.limit
6262
+ limit: options.limit,
6263
+ requestId: options.requestId
5657
6264
  });
5658
6265
  printEvents(res.data.events, options.json);
5659
6266
  if (res.data.truncated) if (options.json) process.stderr.write(`{"truncated":true}\n`);
@@ -5669,7 +6276,8 @@ async function runTail(projectId, functionName, options) {
5669
6276
  try {
5670
6277
  result = await getFunctionLogs(projectId, functionName, {
5671
6278
  since: new Date(highWaterMs).toISOString(),
5672
- limit: options.limit ?? 200
6279
+ limit: options.limit ?? 200,
6280
+ requestId: options.requestId
5673
6281
  });
5674
6282
  } catch (err) {
5675
6283
  console.error(pc.yellow(` ! tail fetch failed: `) + (err instanceof Error ? err.message : String(err)));
@@ -5900,7 +6508,7 @@ function renderStatus(status) {
5900
6508
  * Project is resolved via `loadProjectConfig` (AMBA_PROJECT_ID env, then
5901
6509
  * .env / .env.local in the cwd) — same pattern as `amba secrets *`.
5902
6510
  */
5903
- function handleError(err) {
6511
+ function handleError$1(err) {
5904
6512
  if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
5905
6513
  else console.log(pc.red(" ✗") + ` ${err.message}`);
5906
6514
  else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
@@ -5914,7 +6522,7 @@ function handleError(err) {
5914
6522
  * its own request wrapper — same auth flow as `request()` in api-client,
5915
6523
  * intentionally not exported there so we don't grow the public surface.
5916
6524
  */
5917
- async function adminWrite(method, path, body) {
6525
+ async function adminWrite$1(method, path, body) {
5918
6526
  const token = await resolveBearerToken();
5919
6527
  const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
5920
6528
  const res = await fetch(url, {
@@ -5976,7 +6584,7 @@ async function billingStatusCommand() {
5976
6584
  console.log();
5977
6585
  }
5978
6586
  } catch (err) {
5979
- handleError(err);
6587
+ handleError$1(err);
5980
6588
  }
5981
6589
  }
5982
6590
  async function billingUpgradeCommand(input) {
@@ -5987,7 +6595,7 @@ async function billingUpgradeCommand(input) {
5987
6595
  try {
5988
6596
  const { projectId } = await loadProjectConfig();
5989
6597
  const interval = input.interval ?? "month";
5990
- const res = await adminWrite("POST", `/projects/${projectId}/billing/checkout`, {
6598
+ const res = await adminWrite$1("POST", `/projects/${projectId}/billing/checkout`, {
5991
6599
  tier: input.tier,
5992
6600
  interval
5993
6601
  });
@@ -6001,7 +6609,7 @@ async function billingUpgradeCommand(input) {
6001
6609
  console.log(pc.dim(" Subscription status updates automatically once Stripe confirms (a few seconds)."));
6002
6610
  console.log();
6003
6611
  } catch (err) {
6004
- handleError(err);
6612
+ handleError$1(err);
6005
6613
  }
6006
6614
  }
6007
6615
  async function billingPortalCommand() {
@@ -6011,13 +6619,13 @@ async function billingPortalCommand() {
6011
6619
  console.log();
6012
6620
  try {
6013
6621
  const { projectId } = await loadProjectConfig();
6014
- const res = await adminWrite("POST", `/projects/${projectId}/billing/portal`);
6622
+ const res = await adminWrite$1("POST", `/projects/${projectId}/billing/portal`);
6015
6623
  console.log(pc.bold(" Open this URL to manage your subscription:"));
6016
6624
  console.log();
6017
6625
  console.log(` ${pc.cyan(res.data.url)}`);
6018
6626
  console.log();
6019
6627
  } catch (err) {
6020
- handleError(err);
6628
+ handleError$1(err);
6021
6629
  }
6022
6630
  }
6023
6631
  async function billingSetCeilingCommand(input) {
@@ -6027,15 +6635,374 @@ async function billingSetCeilingCommand(input) {
6027
6635
  console.log();
6028
6636
  try {
6029
6637
  const { projectId } = await loadProjectConfig();
6030
- await adminWrite(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
6638
+ await adminWrite$1(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
6031
6639
  if (input.ceiling === null) console.log(pc.green(" ✓") + " Spend ceiling removed (linear overage continues).");
6032
6640
  else console.log(pc.green(" ✓") + ` Spend ceiling set to $${input.ceiling}/mo.`);
6033
6641
  console.log();
6642
+ } catch (err) {
6643
+ handleError$1(err);
6644
+ }
6645
+ }
6646
+ //#endregion
6647
+ //#region src/commands/payments.ts
6648
+ /**
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).
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
+ }
6678
+ /**
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).
6681
+ */
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)
6693
+ });
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();
6707
+ }
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();
6034
6807
  } catch (err) {
6035
6808
  handleError(err);
6036
6809
  }
6037
6810
  }
6038
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
6039
7006
  //#region src/commands/collections.ts
6040
7007
  /**
6041
7008
  * `amba collections ...` — thin shells over the admin collection routes.
@@ -6334,8 +7301,22 @@ const PG_TYPE_TO_TS = {
6334
7301
  jsonb: "unknown",
6335
7302
  json: "unknown"
6336
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
+ };
6337
7316
  function tsTypeForColumn(col) {
6338
- 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";
6339
7320
  if (col.column_name === "deleted_at") return "string | null";
6340
7321
  if (col.is_nullable === "YES") return `${base} | null`;
6341
7322
  return base;
@@ -6885,9 +7866,351 @@ async function domainsListCommand() {
6885
7866
  console.log();
6886
7867
  }
6887
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
6888
8211
  //#region src/index.ts
6889
8212
  const program = new Command();
6890
- 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");
6891
8214
  program.option("--token <pat>", "Use a Personal Access Token for headless / CI / agent use (overrides ~/.amba/credentials.json + AMBA_PAT env)");
6892
8215
  program.hook("preAction", (thisCommand) => {
6893
8216
  setBearerOverride(resolveTokenSource({
@@ -7029,8 +8352,8 @@ program.command("schema").description("Schema export commands").command("export"
7029
8352
  }));
7030
8353
  });
7031
8354
  const functions = program.command("functions").description("Customer serverless functions deployed to the edge");
7032
- 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) => {
7033
- 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));
7034
8357
  });
7035
8358
  functions.command("list").description("List active functions for the current project").action(async () => {
7036
8359
  await runAction(functionsListCommand);
@@ -7047,11 +8370,12 @@ functions.command("dev <file>").description("Run a local dev server for your fun
7047
8370
  noWatch: opts.watch === false
7048
8371
  }));
7049
8372
  });
7050
- 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) => {
7051
8374
  await runAction(() => functionsLogsCommand(name, {
7052
8375
  since: opts.since,
7053
8376
  until: opts.until,
7054
8377
  limit: opts.limit,
8378
+ requestId: opts.requestId,
7055
8379
  tail: opts.tail || opts.follow,
7056
8380
  json: opts.json
7057
8381
  }));
@@ -7114,6 +8438,60 @@ billing.command("set-ceiling <amount>").description("Cap the monthly bill at <am
7114
8438
  }
7115
8439
  await runAction(() => billingSetCeilingCommand({ ceiling }));
7116
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
+ });
7117
8495
  const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
7118
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) => {
7119
8497
  await runAction(() => collectionsCreateCommand(name, opts));
@@ -7199,6 +8577,29 @@ aiProviders.command("list").description("List registered AI providers for the cu
7199
8577
  aiProviders.command("delete <provider>").description("Remove a provider registration (refuses if active prompts reference it)").action(async (provider) => {
7200
8578
  await runAction(() => aiProvidersDeleteCommand(provider));
7201
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
+ });
7202
8603
  program.parse();
7203
8604
  //#endregion
7204
8605
  export {};