@layers/amba 4.0.3 → 4.1.0

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
@@ -3,16 +3,497 @@ import { Command } from "commander";
3
3
  import pc from "picocolors";
4
4
  import { access, chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { homedir, tmpdir } from "node:os";
6
- import { basename, dirname, join, relative, resolve } from "node:path";
6
+ import path, { basename, dirname, join, relative, resolve } from "node:path";
7
7
  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";
12
- import { createWriteStream, watch } from "node:fs";
11
+ import { fileURLToPath, pathToFileURL } from "node:url";
12
+ import fs, { 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
+ import process$1 from "node:process";
16
+ new Map([
17
+ {
18
+ type: "user.created",
19
+ plane: "app",
20
+ description: "An app user (end user) was created in the project.",
21
+ payloadExample: {
22
+ app_user_id: "usr_123",
23
+ external_id: "auth0|abc",
24
+ created_at: "2026-06-03T00:00:00Z"
25
+ },
26
+ status: "live"
27
+ },
28
+ {
29
+ type: "user.updated",
30
+ plane: "app",
31
+ description: "An app user's profile or attributes changed.",
32
+ payloadExample: {
33
+ app_user_id: "usr_123",
34
+ changed: ["display_name"]
35
+ },
36
+ status: "live"
37
+ },
38
+ {
39
+ type: "user.deleted",
40
+ plane: "app",
41
+ description: "An app user was deleted.",
42
+ payloadExample: { app_user_id: "usr_123" },
43
+ status: "live"
44
+ },
45
+ {
46
+ type: "user.reset",
47
+ plane: "app",
48
+ description: "An app user's gamification, economy, engagement and participation state was reset to zero (the user row itself was kept).",
49
+ payloadExample: {
50
+ app_user_id: "usr_123",
51
+ cleared: {
52
+ user_xp: 1,
53
+ xp_ledger: 12
54
+ }
55
+ },
56
+ status: "live"
57
+ },
58
+ {
59
+ type: "engagement.push.delivered",
60
+ plane: "app",
61
+ description: "A push notification was delivered to a device.",
62
+ payloadExample: {
63
+ app_user_id: "usr_123",
64
+ campaign_id: "cmp_1",
65
+ delivered_at: "2026-06-03T00:00:00Z"
66
+ },
67
+ status: "live"
68
+ },
69
+ {
70
+ type: "engagement.push.failed",
71
+ plane: "app",
72
+ description: "A push notification failed to deliver.",
73
+ payloadExample: {
74
+ app_user_id: "usr_123",
75
+ campaign_id: "cmp_1",
76
+ reason: "unregistered_token"
77
+ },
78
+ status: "live"
79
+ },
80
+ {
81
+ type: "email.sent",
82
+ plane: "app",
83
+ description: "A transactional email was accepted by the provider for delivery.",
84
+ payloadExample: {
85
+ app_user_id: "usr_123",
86
+ template: "welcome",
87
+ to: "a@b.com"
88
+ },
89
+ status: "live"
90
+ },
91
+ {
92
+ type: "email.failed",
93
+ plane: "app",
94
+ description: "A transactional email failed to send.",
95
+ payloadExample: {
96
+ app_user_id: "usr_123",
97
+ template: "welcome",
98
+ reason: "bounce"
99
+ },
100
+ status: "live"
101
+ },
102
+ {
103
+ type: "email.suppressed",
104
+ plane: "app",
105
+ description: "An email was suppressed (recipient on the suppression list).",
106
+ payloadExample: {
107
+ app_user_id: "usr_123",
108
+ to: "a@b.com",
109
+ reason: "unsubscribed"
110
+ },
111
+ status: "live"
112
+ },
113
+ {
114
+ type: "gamification.xp.granted",
115
+ plane: "app",
116
+ description: "XP was granted to a user.",
117
+ payloadExample: {
118
+ app_user_id: "usr_123",
119
+ amount: 50,
120
+ total_xp: 1250,
121
+ rule: "mission_completed",
122
+ transaction_id: "xpl_abc"
123
+ },
124
+ status: "live"
125
+ },
126
+ {
127
+ type: "gamification.level.up",
128
+ plane: "app",
129
+ description: "A user crossed an XP threshold into a new level.",
130
+ payloadExample: {
131
+ app_user_id: "usr_123",
132
+ level: 7,
133
+ previous_level: 6
134
+ },
135
+ status: "live"
136
+ },
137
+ {
138
+ type: "gamification.achievement.unlocked",
139
+ plane: "app",
140
+ description: "A user unlocked an achievement.",
141
+ payloadExample: {
142
+ app_user_id: "usr_123",
143
+ achievement_id: "first_win",
144
+ unlocked_at: "2026-06-03T00:00:00Z"
145
+ },
146
+ status: "live"
147
+ },
148
+ {
149
+ type: "gamification.streak.advanced",
150
+ plane: "app",
151
+ description: "A user's streak advanced (a qualifying action extended it).",
152
+ payloadExample: {
153
+ app_user_id: "usr_123",
154
+ streak_id: "daily",
155
+ count: 12
156
+ },
157
+ status: "live"
158
+ },
159
+ {
160
+ type: "gamification.streak.broken",
161
+ plane: "app",
162
+ description: "A user's streak reset to zero after a missed window.",
163
+ payloadExample: {
164
+ app_user_id: "usr_123",
165
+ streak_id: "daily",
166
+ previous_count: 12
167
+ },
168
+ status: "live"
169
+ },
170
+ {
171
+ type: "gamification.challenge.completed",
172
+ plane: "app",
173
+ description: "A user completed a challenge.",
174
+ payloadExample: {
175
+ app_user_id: "usr_123",
176
+ challenge_id: "ch_1"
177
+ },
178
+ status: "live"
179
+ },
180
+ {
181
+ type: "economy.currency.granted",
182
+ plane: "app",
183
+ description: "Currency was granted to a user.",
184
+ payloadExample: {
185
+ app_user_id: "usr_123",
186
+ currency: "coins",
187
+ amount: 100,
188
+ balance: 350,
189
+ transaction_id: "ctx_abc"
190
+ },
191
+ status: "live"
192
+ },
193
+ {
194
+ type: "economy.currency.spent",
195
+ plane: "app",
196
+ description: "A user spent currency.",
197
+ payloadExample: {
198
+ app_user_id: "usr_123",
199
+ currency: "coins",
200
+ amount: 100,
201
+ balance: 250,
202
+ transaction_id: "ctx_def"
203
+ },
204
+ status: "live"
205
+ },
206
+ {
207
+ type: "economy.catalog.purchased",
208
+ plane: "app",
209
+ description: "A user purchased a catalog item.",
210
+ payloadExample: {
211
+ app_user_id: "usr_123",
212
+ item_id: "sword",
213
+ price: 100,
214
+ currency: "coins"
215
+ },
216
+ status: "live"
217
+ },
218
+ {
219
+ type: "economy.inventory.granted",
220
+ plane: "app",
221
+ description: "An item was granted into a user inventory.",
222
+ payloadExample: {
223
+ app_user_id: "usr_123",
224
+ item_id: "sword",
225
+ quantity: 1
226
+ },
227
+ status: "live"
228
+ },
229
+ {
230
+ type: "subscription.started",
231
+ plane: "app",
232
+ description: "A user gained an active subscription / entitlement.",
233
+ payloadExample: {
234
+ app_user_id: "usr_123",
235
+ entitlement_id: "pro",
236
+ product_id: "pro_yearly",
237
+ store: "app_store",
238
+ period_type: "normal"
239
+ },
240
+ status: "live"
241
+ },
242
+ {
243
+ type: "subscription.renewed",
244
+ plane: "app",
245
+ description: "An active subscription renewed for another billing period.",
246
+ payloadExample: {
247
+ app_user_id: "usr_123",
248
+ entitlement_id: "pro",
249
+ product_id: "pro_yearly",
250
+ store: "app_store"
251
+ },
252
+ status: "live"
253
+ },
254
+ {
255
+ type: "subscription.expired",
256
+ plane: "app",
257
+ description: "A subscription lapsed / was revoked and access ended.",
258
+ payloadExample: {
259
+ app_user_id: "usr_123",
260
+ entitlement_id: "pro",
261
+ product_id: "pro_yearly"
262
+ },
263
+ status: "live"
264
+ },
265
+ {
266
+ type: "entitlement.granted",
267
+ plane: "app",
268
+ description: "An entitlement became active for a user (any source).",
269
+ payloadExample: {
270
+ app_user_id: "usr_123",
271
+ entitlement_id: "pro",
272
+ product_id: "pro_yearly",
273
+ source: "webhook"
274
+ },
275
+ status: "live"
276
+ },
277
+ {
278
+ type: "entitlement.revoked",
279
+ plane: "app",
280
+ description: "An entitlement became inactive for a user (any source).",
281
+ payloadExample: {
282
+ app_user_id: "usr_123",
283
+ entitlement_id: "pro",
284
+ source: "manual"
285
+ },
286
+ status: "live"
287
+ },
288
+ {
289
+ type: "social.friendship.created",
290
+ plane: "app",
291
+ description: "A friendship was established between two users.",
292
+ payloadExample: {
293
+ app_user_id: "usr_123",
294
+ friend_id: "usr_456"
295
+ },
296
+ status: "live"
297
+ },
298
+ {
299
+ type: "social.message.sent",
300
+ plane: "app",
301
+ description: "A message was sent in a conversation.",
302
+ payloadExample: {
303
+ conversation_id: "cnv_1",
304
+ sender_id: "usr_123"
305
+ },
306
+ status: "live"
307
+ },
308
+ {
309
+ type: "social.review.created",
310
+ plane: "app",
311
+ description: "A review was submitted.",
312
+ payloadExample: {
313
+ app_user_id: "usr_123",
314
+ rating: 5,
315
+ target_id: "item_1"
316
+ },
317
+ status: "live"
318
+ },
319
+ {
320
+ type: "content.published",
321
+ plane: "app",
322
+ description: "A content item was published / went live.",
323
+ payloadExample: {
324
+ item_id: "post_1",
325
+ library: "news"
326
+ },
327
+ status: "live"
328
+ },
329
+ {
330
+ type: "moderation.flagged",
331
+ plane: "app",
332
+ description: "User content entered the moderation queue.",
333
+ payloadExample: {
334
+ queue_id: "mq_1",
335
+ content_type: "message",
336
+ reason: "auto_flag"
337
+ },
338
+ status: "live"
339
+ },
340
+ {
341
+ type: "moderation.rejected",
342
+ plane: "app",
343
+ description: "Moderated content was rejected.",
344
+ payloadExample: {
345
+ queue_id: "mq_1",
346
+ moderator: "auto"
347
+ },
348
+ status: "live"
349
+ },
350
+ {
351
+ type: "project.provisioning.started",
352
+ plane: "control",
353
+ description: "Project provisioning began (tenant DB + keys being created).",
354
+ payloadExample: { project_id: "prj_1" },
355
+ status: "live"
356
+ },
357
+ {
358
+ type: "project.provisioned",
359
+ plane: "control",
360
+ description: "The project finished provisioning and is ready to use.",
361
+ payloadExample: {
362
+ project_id: "prj_1",
363
+ provisioned_at: "2026-06-03T00:00:00Z"
364
+ },
365
+ status: "live"
366
+ },
367
+ {
368
+ type: "project.provisioning.failed",
369
+ plane: "control",
370
+ description: "Project provisioning failed and was compensated.",
371
+ payloadExample: {
372
+ project_id: "prj_1",
373
+ reason: "neon_quota"
374
+ },
375
+ status: "live"
376
+ },
377
+ {
378
+ type: "site.deploy.completed",
379
+ plane: "control",
380
+ description: "A static site deploy completed and is serving.",
381
+ payloadExample: {
382
+ site: "marketing",
383
+ deployment_id: "dep_1",
384
+ url: "https://x.app.amba.host",
385
+ file_count: 4,
386
+ serving_configured: true
387
+ },
388
+ status: "live"
389
+ },
390
+ {
391
+ type: "site.deploy.failed",
392
+ plane: "control",
393
+ description: "A static site deploy failed.",
394
+ payloadExample: {
395
+ project_id: "prj_1",
396
+ site: "marketing",
397
+ reason: "storage_write_failed"
398
+ },
399
+ status: "live"
400
+ },
401
+ {
402
+ type: "domain.registered",
403
+ plane: "control",
404
+ description: "A domain was registered through Amba.",
405
+ payloadExample: {
406
+ domain: "myapp.com",
407
+ site: "marketing"
408
+ },
409
+ status: "live"
410
+ },
411
+ {
412
+ type: "domain.cert.issued",
413
+ plane: "control",
414
+ description: "The TLS certificate for an attached domain became active.",
415
+ payloadExample: {
416
+ project_id: "prj_1",
417
+ domain: "app.myapp.com"
418
+ },
419
+ status: "live"
420
+ },
421
+ {
422
+ type: "domain.connected",
423
+ plane: "control",
424
+ description: "A domain is fully connected to a site and serving.",
425
+ payloadExample: {
426
+ project_id: "prj_1",
427
+ domain: "myapp.com",
428
+ site: "marketing"
429
+ },
430
+ status: "live"
431
+ },
432
+ {
433
+ type: "billing.subscription.updated",
434
+ plane: "control",
435
+ description: "The project's subscription state changed.",
436
+ payloadExample: {
437
+ project_id: "prj_1",
438
+ tier: "pro",
439
+ status: "active"
440
+ },
441
+ status: "live"
442
+ },
443
+ {
444
+ type: "billing.invoice.paid",
445
+ plane: "control",
446
+ description: "An invoice was paid.",
447
+ payloadExample: {
448
+ project_id: "prj_1",
449
+ invoice_id: "in_1",
450
+ amount_usd: 49
451
+ },
452
+ status: "live"
453
+ },
454
+ {
455
+ type: "billing.payment_failed",
456
+ plane: "control",
457
+ description: "A payment attempt failed.",
458
+ payloadExample: {
459
+ project_id: "prj_1",
460
+ invoice_id: "in_1",
461
+ reason: "card_declined"
462
+ },
463
+ status: "live"
464
+ },
465
+ {
466
+ type: "billing.ceiling_warning",
467
+ plane: "control",
468
+ description: "The project's current-period usage cost crossed 80% of its spend ceiling. Fires at most once per billing period.",
469
+ payloadExample: {
470
+ project_id: "prj_1",
471
+ threshold_pct: 80,
472
+ ceiling_usd: 100,
473
+ current_overage_usd: 82.4,
474
+ pct_consumed: .824,
475
+ billing_period: "2026-06-01",
476
+ resets_at: "2026-07-01T00:00:00.000Z"
477
+ },
478
+ status: "live"
479
+ },
480
+ {
481
+ type: "billing.ceiling_reached",
482
+ plane: "control",
483
+ description: "The project's current-period usage cost reached its spend ceiling — metered writes now return 402 (read-only mode) until the period resets or the ceiling changes. Fires at most once per billing period.",
484
+ payloadExample: {
485
+ project_id: "prj_1",
486
+ threshold_pct: 100,
487
+ ceiling_usd: 100,
488
+ current_overage_usd: 101.1,
489
+ pct_consumed: 1.011,
490
+ billing_period: "2026-06-01",
491
+ resets_at: "2026-07-01T00:00:00.000Z",
492
+ read_only: true
493
+ },
494
+ status: "live"
495
+ }
496
+ ].map((e) => [e.type, e]));
16
497
  /**
17
498
  * Shared email-shape validation.
18
499
  *
@@ -529,7 +1010,8 @@ async function deployFunctionViaApi(projectId, input) {
529
1010
  fd.set("script", new Blob([input.bundleCode], { type: "application/javascript+module" }), `${input.name}.js`);
530
1011
  const metadata = {
531
1012
  name: input.name,
532
- ...input.rate_limit !== void 0 ? { rate_limit: input.rate_limit } : {}
1013
+ ...input.rate_limit !== void 0 ? { rate_limit: input.rate_limit } : {},
1014
+ ...input.public === true ? { public: true } : {}
533
1015
  };
534
1016
  fd.set("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }), "metadata.json");
535
1017
  return request("POST", `/projects/${projectId}/functions/deploy`, fd);
@@ -545,6 +1027,8 @@ async function getFunctionLogs(projectId, functionName, options = {}) {
545
1027
  if (options.since) params.set("since", options.since);
546
1028
  if (options.until) params.set("until", options.until);
547
1029
  if (options.limit !== void 0) params.set("limit", String(options.limit));
1030
+ if (options.requestId) params.set("request_id", options.requestId);
1031
+ if (options.sinceRequestId) params.set("since_request_id", options.sinceRequestId);
548
1032
  const qs = params.toString();
549
1033
  return request("GET", `/projects/${projectId}/functions/${functionName}/logs${qs ? `?${qs}` : ""}`);
550
1034
  }
@@ -672,6 +1156,17 @@ async function deleteFunctionViaApi(projectId, functionName, options) {
672
1156
  async function listSiteDomains(projectId, siteName) {
673
1157
  return request("GET", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/domains`);
674
1158
  }
1159
+ /** GET the current project's declarative configuration bundle. */
1160
+ async function exportPromotionBundle(projectId) {
1161
+ return request("GET", `/projects/${projectId}/promotion/export`);
1162
+ }
1163
+ /** POST a declarative bundle to import it into the project. */
1164
+ async function importPromotionBundle(projectId, bundle, mode) {
1165
+ return request("POST", `/projects/${projectId}/promotion/import`, {
1166
+ bundle,
1167
+ mode
1168
+ });
1169
+ }
675
1170
  async function validateApiKey(apiKey) {
676
1171
  const res = await fetch(`${getApiRoot()}/v1/auth/validate`, {
677
1172
  method: "POST",
@@ -2149,6 +2644,14 @@ Tell the user where their credentials live:
2149
2644
 
2150
2645
  **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
2646
 
2647
+ **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):
2648
+
2649
+ \`\`\`bash
2650
+ npx @layers/amba init
2651
+ \`\`\`
2652
+
2653
+ 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.
2654
+
2152
2655
  ## Step 1 — Classify the app
2153
2656
 
2154
2657
  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 +2773,7 @@ SKIPPED (low signal — re-run with /amba <feature> if you want them)
2270
2773
  NEEDS YOUR INPUT
2271
2774
  - Apple Sign In: add the "Sign in with Apple" capability in Xcode > Signing & Capabilities.
2272
2775
  - 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.
2776
+ - APNs / FCM: configure credentials with amba_integrations_configure (paste the .p8 / service-account JSON inline) before push delivers.
2274
2777
 
2275
2778
  NEXT STEPS
2276
2779
  - Paste AMBA_CLIENT_KEY into your build env (already shown above)
@@ -4081,7 +4584,7 @@ function confirm$1(question) {
4081
4584
  });
4082
4585
  });
4083
4586
  }
4084
- function handleError$1(err) {
4587
+ function handleError$2(err) {
4085
4588
  if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
4086
4589
  else console.log(pc.red(" ✗") + ` ${err.message}`);
4087
4590
  else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
@@ -4131,7 +4634,7 @@ async function projectsListCommand() {
4131
4634
  console.log(pc.dim(` ${projects.length} project${projects.length === 1 ? "" : "s"}`));
4132
4635
  console.log();
4133
4636
  } catch (err) {
4134
- handleError$1(err);
4637
+ handleError$2(err);
4135
4638
  }
4136
4639
  }
4137
4640
  async function projectsCreateCommand(input) {
@@ -4177,7 +4680,7 @@ async function projectsCreateCommand(input) {
4177
4680
  console.log(pc.dim(" Next: ") + pc.cyan(`amba projects show ${id}`));
4178
4681
  console.log();
4179
4682
  } catch (err) {
4180
- handleError$1(err);
4683
+ handleError$2(err);
4181
4684
  }
4182
4685
  }
4183
4686
  async function projectsUpdateCommand(projectId, input) {
@@ -4203,7 +4706,7 @@ async function projectsUpdateCommand(projectId, input) {
4203
4706
  console.log();
4204
4707
  process.exit(1);
4205
4708
  }
4206
- handleError$1(err);
4709
+ handleError$2(err);
4207
4710
  }
4208
4711
  }
4209
4712
  async function projectsShowCommand(projectId) {
@@ -4221,7 +4724,7 @@ async function projectsShowCommand(projectId) {
4221
4724
  console.log();
4222
4725
  process.exit(1);
4223
4726
  }
4224
- handleError$1(err);
4727
+ handleError$2(err);
4225
4728
  }
4226
4729
  }
4227
4730
  async function projectsDeleteCommand(projectId, opts = {}) {
@@ -4246,7 +4749,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
4246
4749
  console.log();
4247
4750
  process.exit(1);
4248
4751
  }
4249
- handleError$1(err);
4752
+ handleError$2(err);
4250
4753
  }
4251
4754
  }
4252
4755
  //#endregion
@@ -4989,6 +5492,47 @@ function parseEnv(content) {
4989
5492
  * empty; customer code is expected to be self-contained.
4990
5493
  */
4991
5494
  /**
5495
+ * Conventional entry-point filenames probed (in order) when
5496
+ * `amba functions deploy <dir>` is pointed at a directory rather than a
5497
+ * single file. Mirrors the Node / bundler convention (`index.*`) so a
5498
+ * developer can `amba functions deploy ./my-fn` with an `index.ts` inside.
5499
+ */
5500
+ const FUNCTION_ENTRY_CANDIDATES = [
5501
+ "index.ts",
5502
+ "index.js",
5503
+ "index.mjs"
5504
+ ];
5505
+ /**
5506
+ * Resolve the bundler entry point from a deploy target that may be either a
5507
+ * single file OR a directory.
5508
+ *
5509
+ * - File → returned as-is (single-file back-compat path).
5510
+ * - Dir → resolved by convention to `index.{ts,js,mjs}`, or to an
5511
+ * explicit `entry` (relative to the directory) when provided.
5512
+ *
5513
+ * Throws an actionable error when the target doesn't exist, or when a
5514
+ * directory has no resolvable entry point (and no `--entry` was passed).
5515
+ */
5516
+ async function resolveFunctionEntry(target, explicitEntry) {
5517
+ const st = await stat(target).catch(() => null);
5518
+ if (!st) throw new Error(`Deploy target not found: ${target}`);
5519
+ if (st.isFile()) {
5520
+ 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}.`);
5521
+ return target;
5522
+ }
5523
+ if (!st.isDirectory()) throw new Error(`Deploy target is neither a file nor a directory: ${target}`);
5524
+ if (explicitEntry) {
5525
+ const candidate = join(target, explicitEntry);
5526
+ if (!(await stat(candidate).catch(() => null))?.isFile()) throw new Error(`--entry file not found: ${candidate}`);
5527
+ return candidate;
5528
+ }
5529
+ for (const name of FUNCTION_ENTRY_CANDIDATES) {
5530
+ const candidate = join(target, name);
5531
+ if ((await stat(candidate).catch(() => null))?.isFile()) return candidate;
5532
+ }
5533
+ 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).`);
5534
+ }
5535
+ /**
4992
5536
  * Modules the bundler treats as `external` by default. The Amba
4993
5537
  * function runtime exposes zero npm packages — there is no "platform
4994
5538
  * stdlib" for customer functions to import. Keep this list empty.
@@ -5011,6 +5555,91 @@ var BundleSizeError = class extends Error {
5011
5555
  }
5012
5556
  };
5013
5557
  /**
5558
+ * The single source-of-truth message for the wrong-handler-shape failure.
5559
+ * Shared between the CLI bundler (pre-upload), the local `dev` runner, and
5560
+ * the server-side deploy hint so dev / deploy / prod all describe the fix
5561
+ * identically. Customer functions run as Module Workers; the legacy
5562
+ * service-worker shape (`export default async function (req) { … }`) is
5563
+ * rejected by the runtime at upload, so we reject it before the round-trip.
5564
+ */
5565
+ 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.";
5566
+ /** Message used when the entry file has no `export default` at all. */
5567
+ 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.";
5568
+ /** Message used when the default export is present but has no `fetch` method. */
5569
+ 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.";
5570
+ /**
5571
+ * Thrown when the bundled entry's `export default` is not the Module Worker
5572
+ * shape (`{ fetch(req, env, ctx) }`). Raised BEFORE any upload so a wrong
5573
+ * handler shape fails fast on the developer's machine with an actionable
5574
+ * message instead of an opaque upstream rejection at deploy time.
5575
+ */
5576
+ var HandlerShapeError = class extends Error {
5577
+ kind;
5578
+ constructor(message, kind) {
5579
+ super(message);
5580
+ this.kind = kind;
5581
+ this.name = "HandlerShapeError";
5582
+ }
5583
+ };
5584
+ /**
5585
+ * Inspect an already-imported module namespace and assert it has the
5586
+ * Module Worker handler shape: a default export that is an object (or
5587
+ * function-with-properties) carrying a callable `fetch`.
5588
+ *
5589
+ * Pure + side-effect free — it only reads the shape of `mod.default`, never
5590
+ * invokes `fetch`. This is the unit-testable core; `assertModuleWorkerShape`
5591
+ * wraps the import-from-code plumbing around it.
5592
+ *
5593
+ * Throws `HandlerShapeError` on:
5594
+ * - no default export (`missing-default`)
5595
+ * - a default-exported plain function — the legacy service-worker shape
5596
+ * (`plain-function`)
5597
+ * - a default export with no callable `fetch` (`no-fetch`)
5598
+ */
5599
+ function assertHandlerShape(mod) {
5600
+ const def = mod.default;
5601
+ if (def === void 0 || def === null) throw new HandlerShapeError(HANDLER_SHAPE_MISSING_DEFAULT_MESSAGE, "missing-default");
5602
+ if (typeof def === "function") {
5603
+ if (typeof def.fetch !== "function") throw new HandlerShapeError(HANDLER_SHAPE_FIX_MESSAGE, "plain-function");
5604
+ return;
5605
+ }
5606
+ if (typeof def !== "object") throw new HandlerShapeError(HANDLER_SHAPE_NO_FETCH_MESSAGE, "no-fetch");
5607
+ if (typeof def.fetch !== "function") throw new HandlerShapeError(HANDLER_SHAPE_NO_FETCH_MESSAGE, "no-fetch");
5608
+ }
5609
+ /**
5610
+ * Validate that the bundled customer code exports the Module Worker handler
5611
+ * shape, by importing it in isolation and inspecting `mod.default`.
5612
+ *
5613
+ * Mechanism: write the bundle to a throwaway temp `.mjs` and dynamic-import
5614
+ * it, then hand the namespace to `assertHandlerShape`. This is the same
5615
+ * import-and-inspect approach the local `dev` runner uses
5616
+ * (`functions.ts` DEV_RUNNER_SOURCE) — reused here so the pre-upload check
5617
+ * and the dev runner agree on what "valid shape" means. Importing runs the
5618
+ * module's top-level code (unavoidable to read the export), but the handler
5619
+ * itself is NEVER invoked — we only read the shape of the exported object.
5620
+ *
5621
+ * The temp file is always cleaned up (best-effort) before returning.
5622
+ */
5623
+ async function assertModuleWorkerShape(code) {
5624
+ const dir = await mkdtemp(join(tmpdir(), "amba-fn-shape-"));
5625
+ const filePath = join(dir, "fn.mjs");
5626
+ try {
5627
+ await writeFile(filePath, code, "utf8");
5628
+ let mod;
5629
+ try {
5630
+ mod = await import(pathToFileURL(filePath).href);
5631
+ } catch (err) {
5632
+ throw new Error(`Failed to load the function entrypoint while validating its handler shape: ${err instanceof Error ? err.message : String(err)}`);
5633
+ }
5634
+ assertHandlerShape(mod);
5635
+ } finally {
5636
+ await rm(dir, {
5637
+ recursive: true,
5638
+ force: true
5639
+ }).catch(() => {});
5640
+ }
5641
+ }
5642
+ /**
5014
5643
  * Bundle a customer function file for upload to a Workers dispatch
5015
5644
  * namespace. Returns the bundled code + metadata; throws
5016
5645
  * `BundleSizeError` when the compressed size exceeds the cap so the CLI
@@ -5043,6 +5672,7 @@ async function bundleFunction(options) {
5043
5672
  const compressedSize = await gzipSizeOf(output.contents);
5044
5673
  const max = options.maxSizeBytes ?? 8388608;
5045
5674
  if (compressedSize > max) throw new BundleSizeError(compressedSize, max);
5675
+ if (options.validateHandlerShape !== false) await assertModuleWorkerShape(code);
5046
5676
  return {
5047
5677
  code,
5048
5678
  sha256: await hashSha256Hex(output.contents),
@@ -5091,16 +5721,19 @@ function formatBytes$1(n) {
5091
5721
  * records the deployment in a single round-trip. The other subcommands
5092
5722
  * are thin shells over the admin API helpers in `api-client.ts`.
5093
5723
  */
5094
- async function functionsDeployCommand(entryPoint, options = {}) {
5724
+ async function functionsDeployCommand(target, options = {}) {
5095
5725
  const projectId = (await loadProjectConfig()).projectId;
5096
- const functionName = options.name ?? basename(entryPoint).replace(/\.[^.]+$/, "");
5726
+ const targetIsDir = (await stat(target).catch(() => null))?.isDirectory() ?? false;
5727
+ const entryPoint = await resolveFunctionEntry(target, options.entry);
5728
+ const defaultName = targetIsDir ? basename(target.replace(/[/\\]+$/, "")) : basename(entryPoint).replace(/\.[^.]+$/, "");
5729
+ const functionName = options.name ?? defaultName;
5097
5730
  validateFunctionName(functionName);
5098
5731
  const rateLimit = parseRateLimitFlags(options);
5099
5732
  console.log();
5100
5733
  console.log(pc.bold(` amba functions deploy ${pc.cyan(functionName)}`));
5101
5734
  console.log(pc.dim(" ─────────────────────────────────"));
5102
5735
  console.log();
5103
- console.log(pc.dim(" Bundling…"));
5736
+ console.log(pc.dim(` Bundling ${entryPoint}…`));
5104
5737
  const bundle = await bundleFunction({ entryPoint });
5105
5738
  printBundleReport(bundle);
5106
5739
  if (options.dryRun) {
@@ -5113,7 +5746,8 @@ async function functionsDeployCommand(entryPoint, options = {}) {
5113
5746
  result = await deployFunctionViaApi(projectId, {
5114
5747
  name: functionName,
5115
5748
  bundleCode: bundle.code,
5116
- rate_limit: rateLimit
5749
+ rate_limit: rateLimit,
5750
+ public: options.public === true ? true : void 0
5117
5751
  });
5118
5752
  } catch (err) {
5119
5753
  if (err instanceof ApiClientError && err.statusCode === 503) {
@@ -5126,6 +5760,7 @@ async function functionsDeployCommand(entryPoint, options = {}) {
5126
5760
  console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version}`)}`);
5127
5761
  console.log(pc.green(" ✓") + ` URL: ${pc.underline(result.fn_url)}`);
5128
5762
  if (rateLimit) console.log(pc.dim(` Rate limit: ${rateLimit.max} per ${rateLimit.window} (key=${rateLimit.key}) — enforced pre-dispatch`));
5763
+ if (options.public === true) console.log(pc.yellow(" ! Public") + pc.dim(" — no X-Api-Key required. Verify the webhook signature inside your handler."));
5129
5764
  console.log();
5130
5765
  }
5131
5766
  async function functionsListCommand() {
@@ -5481,11 +6116,15 @@ try {
5481
6116
  return fetchFn(req, env, ctx);
5482
6117
  };
5483
6118
  } 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;
6119
+ // Legacy service-worker shape (\`export default async function (req) { ... }\`).
6120
+ // REJECTED — production runs functions as Module Workers and the runtime
6121
+ // rejects this shape at upload. Accepting it in dev was a dev/prod parity
6122
+ // trap: a function that ran here would fail \`amba functions deploy\`. Reject
6123
+ // with the same fix the bundler + server hint use so dev and prod agree.
6124
+ 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');
6125
+ process.exit(2);
5487
6126
  } else {
5488
- process.stderr.write(' Error: entry file must export default { async fetch(req, env, ctx) { ... } }\\n');
6127
+ 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
6128
  process.exit(2);
5490
6129
  }
5491
6130
  } catch (err) {
@@ -5634,6 +6273,10 @@ function parseRateLimitFlags(options) {
5634
6273
  * - `--tail`: print the last hour's events, then poll every 3s for
5635
6274
  * new events past the highest seen `EventTimestampMs`. Ctrl+C to stop.
5636
6275
  *
6276
+ * `--since-request-id <r_...>` anchors the range start at the oldest event
6277
+ * mentioning that request id ("show me everything from this request
6278
+ * onward") — the server resolves the anchor; works in both modes.
6279
+ *
5637
6280
  * Output formatting:
5638
6281
  * - `--json`: NDJSON to stdout (one event per line) so `| jq` works.
5639
6282
  * - default: human-readable lines:
@@ -5653,8 +6296,11 @@ async function functionsLogsCommand(functionName, options = {}) {
5653
6296
  const res = await getFunctionLogs(projectConfig.projectId, functionName, {
5654
6297
  since: options.since,
5655
6298
  until: options.until,
5656
- limit: options.limit
6299
+ limit: options.limit,
6300
+ requestId: options.requestId,
6301
+ sinceRequestId: options.sinceRequestId
5657
6302
  });
6303
+ if (res.data.anchor && !options.json) console.error(pc.dim(` Anchored at ${res.data.anchor} (--since-request-id).`));
5658
6304
  printEvents(res.data.events, options.json);
5659
6305
  if (res.data.truncated) if (options.json) process.stderr.write(`{"truncated":true}\n`);
5660
6306
  else console.error(pc.yellow(` ! Result truncated at limit; narrow --since / --until or raise --limit.`));
@@ -5662,20 +6308,33 @@ async function functionsLogsCommand(functionName, options = {}) {
5662
6308
  async function runTail(projectId, functionName, options) {
5663
6309
  let sinceMs = options.since ? Date.parse(options.since) : Date.now() - 3600 * 1e3;
5664
6310
  if (Number.isNaN(sinceMs)) throw new Error("--since must be a valid ISO 8601 timestamp");
5665
- if (!options.json) console.error(pc.dim(` Tailing ${functionName} — Ctrl+C to stop. Initial backfill from ${new Date(sinceMs).toISOString()}.`));
6311
+ if (!options.json) console.error(pc.dim(options.sinceRequestId ? ` Tailing ${functionName} — Ctrl+C to stop. Anchoring backfill at request ${options.sinceRequestId}.` : ` Tailing ${functionName} — Ctrl+C to stop. Initial backfill from ${new Date(sinceMs).toISOString()}.`));
5666
6312
  let highWaterMs = sinceMs;
6313
+ let firstFetch = true;
5667
6314
  for (;;) {
5668
6315
  let result;
6316
+ const anchored = firstFetch && options.sinceRequestId !== void 0;
5669
6317
  try {
5670
6318
  result = await getFunctionLogs(projectId, functionName, {
5671
- since: new Date(highWaterMs).toISOString(),
5672
- limit: options.limit ?? 200
6319
+ ...anchored ? { sinceRequestId: options.sinceRequestId } : { since: new Date(highWaterMs).toISOString() },
6320
+ ...anchored && options.since ? { since: options.since } : {},
6321
+ limit: options.limit ?? 200,
6322
+ requestId: options.requestId
5673
6323
  });
5674
6324
  } catch (err) {
6325
+ if (anchored) throw err;
5675
6326
  console.error(pc.yellow(` ! tail fetch failed: `) + (err instanceof Error ? err.message : String(err)));
5676
6327
  await sleep$1(TAIL_POLL_MS);
5677
6328
  continue;
5678
6329
  }
6330
+ if (anchored) {
6331
+ firstFetch = false;
6332
+ if (result.data.anchor) {
6333
+ if (!options.json) console.error(pc.dim(` Anchored at ${result.data.anchor} (--since-request-id).`));
6334
+ const anchorMs = Date.parse(result.data.anchor);
6335
+ if (!Number.isNaN(anchorMs)) highWaterMs = anchorMs;
6336
+ }
6337
+ }
5679
6338
  if (result.data.events.length > 0) {
5680
6339
  printEvents(result.data.events, options.json);
5681
6340
  highWaterMs = result.data.events.reduce((m, e) => Math.max(m, e.EventTimestampMs ?? 0), highWaterMs) + 1;
@@ -5900,7 +6559,7 @@ function renderStatus(status) {
5900
6559
  * Project is resolved via `loadProjectConfig` (AMBA_PROJECT_ID env, then
5901
6560
  * .env / .env.local in the cwd) — same pattern as `amba secrets *`.
5902
6561
  */
5903
- function handleError(err) {
6562
+ function handleError$1(err) {
5904
6563
  if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
5905
6564
  else console.log(pc.red(" ✗") + ` ${err.message}`);
5906
6565
  else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
@@ -5914,7 +6573,7 @@ function handleError(err) {
5914
6573
  * its own request wrapper — same auth flow as `request()` in api-client,
5915
6574
  * intentionally not exported there so we don't grow the public surface.
5916
6575
  */
5917
- async function adminWrite(method, path, body) {
6576
+ async function adminWrite$1(method, path, body) {
5918
6577
  const token = await resolveBearerToken();
5919
6578
  const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
5920
6579
  const res = await fetch(url, {
@@ -5940,6 +6599,11 @@ async function adminWrite(method, path, body) {
5940
6599
  }
5941
6600
  return await res.json();
5942
6601
  }
6602
+ /** Append "· $X.XX" to a usage line when the meter accrued cost this period. */
6603
+ function costSuffix(cost) {
6604
+ if (!cost || cost.cost_usd <= 0) return "";
6605
+ return ` ${pc.yellow(`$${cost.cost_usd.toFixed(2)}`)}`;
6606
+ }
5943
6607
  function formatAxis(label, axis, isStorage) {
5944
6608
  const used = axis.used === null ? "—" : isStorage ? axis.used >= 1024 ? `${(axis.used / 1024).toFixed(2)} GB` : `${axis.used} MB` : axis.used.toLocaleString();
5945
6609
  const limit = axis.limit === null ? "unlimited" : isStorage ? axis.limit >= 1024 ? `${(axis.limit / 1024).toFixed(0)} GB` : `${axis.limit} MB` : axis.limit.toLocaleString();
@@ -5957,18 +6621,29 @@ async function billingStatusCommand() {
5957
6621
  console.log(` Tier: ${pc.bold(s.tier)}`);
5958
6622
  console.log(` Subscription: ${s.subscription_status ?? pc.dim("—")}`);
5959
6623
  console.log(` Next bill anchor: ${s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : pc.dim("—")}`);
5960
- console.log(` Spend ceiling: ${s.ceiling_usd === null ? pc.dim("no cap") : `$${s.ceiling_usd}`}`);
6624
+ const pctCeiling = s.ceiling_pct_consumed === null || s.ceiling_pct_consumed === void 0 ? null : Math.round(s.ceiling_pct_consumed * 100);
6625
+ console.log(` Spend ceiling: ${s.ceiling_usd === null ? pc.dim("no cap") : `$${s.ceiling_usd}${pctCeiling === null ? "" : pc.dim(` (${pctCeiling}% consumed)`)}`}`);
5961
6626
  console.log(` Spend mode: ${s.mode}`);
5962
6627
  console.log(` Projected overage: $${s.projected_overage_usd_this_month.toFixed(2)}`);
6628
+ if (s.projected_end_of_period_overage_usd !== void 0 && s.period) console.log(` End-of-period est.: $${s.projected_end_of_period_overage_usd.toFixed(2)} ${pc.dim(`(period resets ${new Date(s.period.resets_at).toLocaleDateString()})`)}`);
5963
6629
  if (s.paused_at) console.log(` ${pc.yellow("Paused at:")} ${s.paused_at} ${pc.dim("(wakes on next request)")}`);
6630
+ if (s.enforcement?.read_only) {
6631
+ console.log();
6632
+ console.log(pc.red(" READ-ONLY MODE: ") + "spend ceiling reached — metered writes return 402 until the period resets or the ceiling is raised.");
6633
+ } else if (s.enforcement && s.enforcement.over_quota_meters.length > 0) {
6634
+ console.log();
6635
+ console.log(pc.yellow(" Over quota: ") + s.enforcement.over_quota_meters.join(", ") + pc.dim(" — writes on these meters are refused on throttle mode (402; new signups 429 at the MAU quota)."));
6636
+ }
5964
6637
  console.log();
5965
6638
  console.log(pc.bold(" Usage — rolling 30 days"));
5966
- console.log(formatAxis("Monthly active users", s.headroom.mau, false));
5967
- console.log(formatAxis("Engagement events", s.headroom.engagement_events, false));
5968
- console.log(formatAxis("Telemetry events", s.headroom.telemetry_events, false));
5969
- console.log(formatAxis("Push deliveries", s.headroom.push, false));
5970
- console.log(formatAxis("Database storage", s.headroom.db_storage_mb, true));
5971
- console.log(formatAxis("Media storage", s.headroom.media_storage_mb, true));
6639
+ const cost = s.usage_cost_by_meter ?? {};
6640
+ console.log(formatAxis("Monthly active users", s.headroom.mau, false) + costSuffix(cost.mau));
6641
+ console.log(formatAxis("Engagement events", s.headroom.engagement_events, false) + costSuffix(cost.engagement_events));
6642
+ console.log(formatAxis("Telemetry events", s.headroom.telemetry_events, false) + costSuffix(cost.telemetry_events));
6643
+ console.log(formatAxis("Push deliveries", s.headroom.push, false) + costSuffix(cost.push));
6644
+ console.log(formatAxis("Database storage", s.headroom.db_storage_mb, true) + costSuffix(cost.db_storage_mb));
6645
+ console.log(formatAxis("Media storage", s.headroom.media_storage_mb, true) + costSuffix(cost.media_storage_mb));
6646
+ if (s.headroom.app_mcp_tool_calls) console.log(formatAxis("Agent tool calls", s.headroom.app_mcp_tool_calls, false) + costSuffix(cost.app_mcp_tool_calls));
5972
6647
  console.log();
5973
6648
  if (s.human_action_required !== "none") {
5974
6649
  const action = s.human_action_required.replaceAll("_", " ");
@@ -5976,7 +6651,7 @@ async function billingStatusCommand() {
5976
6651
  console.log();
5977
6652
  }
5978
6653
  } catch (err) {
5979
- handleError(err);
6654
+ handleError$1(err);
5980
6655
  }
5981
6656
  }
5982
6657
  async function billingUpgradeCommand(input) {
@@ -5987,7 +6662,7 @@ async function billingUpgradeCommand(input) {
5987
6662
  try {
5988
6663
  const { projectId } = await loadProjectConfig();
5989
6664
  const interval = input.interval ?? "month";
5990
- const res = await adminWrite("POST", `/projects/${projectId}/billing/checkout`, {
6665
+ const res = await adminWrite$1("POST", `/projects/${projectId}/billing/checkout`, {
5991
6666
  tier: input.tier,
5992
6667
  interval
5993
6668
  });
@@ -6001,7 +6676,7 @@ async function billingUpgradeCommand(input) {
6001
6676
  console.log(pc.dim(" Subscription status updates automatically once Stripe confirms (a few seconds)."));
6002
6677
  console.log();
6003
6678
  } catch (err) {
6004
- handleError(err);
6679
+ handleError$1(err);
6005
6680
  }
6006
6681
  }
6007
6682
  async function billingPortalCommand() {
@@ -6011,13 +6686,13 @@ async function billingPortalCommand() {
6011
6686
  console.log();
6012
6687
  try {
6013
6688
  const { projectId } = await loadProjectConfig();
6014
- const res = await adminWrite("POST", `/projects/${projectId}/billing/portal`);
6689
+ const res = await adminWrite$1("POST", `/projects/${projectId}/billing/portal`);
6015
6690
  console.log(pc.bold(" Open this URL to manage your subscription:"));
6016
6691
  console.log();
6017
6692
  console.log(` ${pc.cyan(res.data.url)}`);
6018
6693
  console.log();
6019
6694
  } catch (err) {
6020
- handleError(err);
6695
+ handleError$1(err);
6021
6696
  }
6022
6697
  }
6023
6698
  async function billingSetCeilingCommand(input) {
@@ -6027,92 +6702,513 @@ async function billingSetCeilingCommand(input) {
6027
6702
  console.log();
6028
6703
  try {
6029
6704
  const { projectId } = await loadProjectConfig();
6030
- await adminWrite(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
6705
+ await adminWrite$1(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
6031
6706
  if (input.ceiling === null) console.log(pc.green(" ✓") + " Spend ceiling removed (linear overage continues).");
6032
6707
  else console.log(pc.green(" ✓") + ` Spend ceiling set to $${input.ceiling}/mo.`);
6033
6708
  console.log();
6034
6709
  } catch (err) {
6035
- handleError(err);
6710
+ handleError$1(err);
6036
6711
  }
6037
6712
  }
6038
6713
  //#endregion
6039
- //#region src/commands/collections.ts
6714
+ //#region src/commands/payments.ts
6040
6715
  /**
6041
- * `amba collections ...` — thin shells over the admin collection routes.
6042
- *
6043
- * Wire shapes:
6044
- *
6045
- * POST /admin/projects/:p/collections — create (full schema)
6046
- * GET /admin/projects/:p/collections — list
6047
- * PATCH /admin/projects/:p/collections/:name — single-op alter
6048
- * DELETE /admin/projects/:p/collections/:name — drop (requires ?confirm)
6049
- *
6050
- * The CLI's job is to:
6051
- * 1. Validate names / types client-side so an obviously-bad invocation
6052
- * doesn't round-trip.
6053
- * 2. Translate the user-friendly CLI flag shape (`--field name:type`,
6054
- * `--add-field`, `--drop-field`) into the wire shape (single op per
6055
- * PATCH).
6056
- * 3. Block until the server reports success.
6057
- * 4. Surface server errors with a sensible CLI message.
6058
- *
6059
- * Multi-op alters are issued sequentially so the operator audit trail
6060
- * stays one-to-one with developer actions.
6716
+ * `amba payments *` subcommands — CLI access to the Amba Payments (Stripe
6717
+ * Connect platform) surface at `/v1/admin/projects/:id/payments/*`
6718
+ * (tracker #10).
6719
+ *
6720
+ * Amba is the payments rail for the developer's app: the app is the seller,
6721
+ * Amba takes a platform fee, Stripe carries money-transmission. This is the
6722
+ * developer-facing onboarding + config + reporting surface, so naming Stripe
6723
+ * here is by intent (same as `amba billing`).
6724
+ *
6725
+ * amba payments setup — create the connected account +
6726
+ * print the hosted onboarding URL.
6727
+ * amba payments status — onboarding stage + capability flags.
6728
+ * amba payments set-fee <bps|off> — default platform fee (basis points).
6729
+ * amba payments balance — connected-account balance.
6730
+ * amba payments payouts — recent payouts.
6731
+ *
6732
+ * Every live call is gated by the platform's PAYMENTS_CONNECT_LIVE
6733
+ * kill-switch — when off the API returns PAYMENTS_NOT_ENABLED, surfaced
6734
+ * verbatim here. Project resolved via loadProjectConfig (same as billing).
6061
6735
  */
6736
+ function handleError(err) {
6737
+ if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
6738
+ 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.");
6739
+ else console.log(pc.red(" ✗") + ` ${err.message}`);
6740
+ else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
6741
+ else console.log(pc.red(" ✗") + " Unknown error");
6742
+ console.log();
6743
+ process.exit(1);
6744
+ }
6062
6745
  /**
6063
- * Closed-set type catalog. Matches data's `ColumnType` exactly — keeps
6064
- * the CLI fail-fast path aligned with the DDL emit's accepted shapes.
6065
- *
6066
- * The CLI accepts customer-friendly synonyms (e.g. `int` → `integer`)
6067
- * via `normalizeColumnType` so the `--field` flag stays ergonomic
6068
- * without the API needing to relax its closed catalog.
6746
+ * POST/PUT to the admin API. Mirrors the helper in billing.ts (api-client
6747
+ * doesn't export a generic write for arbitrary paths).
6069
6748
  */
6070
- const ALLOWED_TYPES = new Set([
6071
- "uuid",
6072
- "text",
6073
- "integer",
6074
- "bigint",
6075
- "numeric",
6076
- "boolean",
6077
- "timestamptz",
6078
- "date",
6079
- "jsonb",
6080
- "vector"
6081
- ]);
6082
- const TYPE_SYNONYMS = {
6083
- int: "integer",
6084
- int4: "integer",
6085
- int8: "bigint",
6086
- bool: "boolean",
6087
- json: "jsonb",
6088
- string: "text"
6089
- };
6090
- async function collectionsCreateCommand(name, options) {
6091
- const reason = getReservationReason(name);
6092
- if (reason) throw new Error(`Cannot create collection '${name}': ${reason}`);
6093
- const columns = options.field.map(parseColumnSpec);
6094
- const indexes = options.index.map(parseIndexSpec);
6095
- const projectConfig = await loadProjectConfig();
6096
- console.log();
6097
- console.log(pc.bold(` amba collections create ${pc.cyan(name)}`));
6098
- for (const c of columns) console.log(pc.dim(" ") + c.name + pc.dim(": ") + c.type + (c.nullable ? pc.dim(" NULL") : pc.dim(" NOT NULL")));
6099
- console.log();
6100
- const res = await createCollection(projectConfig.projectId, {
6101
- name,
6102
- columns,
6103
- indexes
6749
+ async function adminWrite(method, path, body) {
6750
+ const token = await resolveBearerToken();
6751
+ const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
6752
+ const res = await fetch(url, {
6753
+ method,
6754
+ headers: {
6755
+ Authorization: `Bearer ${token}`,
6756
+ "Content-Type": "application/json",
6757
+ "User-Agent": "amba-cli/0.1.1"
6758
+ },
6759
+ body: body === void 0 ? void 0 : JSON.stringify(body)
6104
6760
  });
6105
- console.log(pc.green(" ✓") + ` Created — version ${res.data.version}`);
6106
- console.log(pc.dim(` workflow_id: ${res.data.workflow_id}`));
6107
- console.log();
6761
+ if (!res.ok) {
6762
+ let message = `API request failed: ${res.status} ${res.statusText}`;
6763
+ let code;
6764
+ try {
6765
+ const errorBody = await res.json();
6766
+ if (errorBody.error?.message) {
6767
+ message = errorBody.error.message;
6768
+ code = errorBody.error.code;
6769
+ }
6770
+ } catch {}
6771
+ throw new ApiClientError(message, res.status, code);
6772
+ }
6773
+ return await res.json();
6108
6774
  }
6109
- async function collectionsAlterCommand(name, options) {
6110
- 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.");
6111
- const confirmed = new Set(options.confirm ?? []);
6112
- 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).`);
6113
- const projectConfig = await loadProjectConfig();
6114
- for (const fieldSpec of options.addField) {
6115
- const column = parseColumnSpec(fieldSpec);
6775
+ function printAccount(a) {
6776
+ console.log(` Account: ${pc.dim(a.stripe_account_id)}`);
6777
+ console.log(` Onboarding: ${pc.bold(a.onboarding_status)}`);
6778
+ console.log(` Charges enabled: ${a.charges_enabled ? pc.green("yes") : pc.yellow("no")}`);
6779
+ console.log(` Payouts enabled: ${a.payouts_enabled ? pc.green("yes") : pc.yellow("no")}`);
6780
+ console.log(` Country: ${a.country ?? pc.dim("—")}`);
6781
+ 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)}%)`}`);
6782
+ }
6783
+ async function paymentsSetupCommand(input) {
6784
+ console.log();
6785
+ console.log(pc.bold(" amba payments setup"));
6786
+ console.log(pc.dim(" ─────────────────────────────────"));
6787
+ console.log();
6788
+ try {
6789
+ const { projectId } = await loadProjectConfig();
6790
+ printAccount((await adminWrite("POST", `/projects/${projectId}/payments/accounts`, {
6791
+ ...input.country ? { country: input.country } : {},
6792
+ ...input.email ? { email: input.email } : {}
6793
+ })).data);
6794
+ console.log();
6795
+ const link = await adminWrite("POST", `/projects/${projectId}/payments/accounts/link`);
6796
+ console.log(pc.bold(" Open this URL to finish setting up payments:"));
6797
+ console.log();
6798
+ console.log(` ${pc.cyan(link.data.url)}`);
6799
+ console.log();
6800
+ console.log(pc.dim(" Status updates automatically once setup completes."));
6801
+ console.log();
6802
+ } catch (err) {
6803
+ handleError(err);
6804
+ }
6805
+ }
6806
+ async function paymentsStatusCommand() {
6807
+ console.log();
6808
+ console.log(pc.bold(" amba payments status"));
6809
+ console.log(pc.dim(" ─────────────────────────────────"));
6810
+ console.log();
6811
+ try {
6812
+ const { projectId } = await loadProjectConfig();
6813
+ const res = await adminGet(`/projects/${projectId}/payments/accounts`);
6814
+ if (!res.data.connected_account) {
6815
+ console.log(pc.dim(" No payments account yet. Run `amba payments setup`."));
6816
+ console.log();
6817
+ return;
6818
+ }
6819
+ printAccount(res.data.connected_account);
6820
+ console.log();
6821
+ } catch (err) {
6822
+ handleError(err);
6823
+ }
6824
+ }
6825
+ async function paymentsSetFeeCommand(input) {
6826
+ console.log();
6827
+ console.log(pc.bold(" amba payments set-fee"));
6828
+ console.log(pc.dim(" ─────────────────────────────────"));
6829
+ console.log();
6830
+ try {
6831
+ const { projectId } = await loadProjectConfig();
6832
+ await adminWrite("PUT", `/projects/${projectId}/payments/config`, { default_platform_fee_bps: input.bps });
6833
+ if (input.bps === null) console.log(pc.green(" ✓") + " Default platform fee cleared.");
6834
+ else console.log(pc.green(" ✓") + ` Default platform fee set to ${input.bps} bps (${(input.bps / 100).toFixed(2)}%).`);
6835
+ console.log();
6836
+ } catch (err) {
6837
+ handleError(err);
6838
+ }
6839
+ }
6840
+ async function paymentsBalanceCommand() {
6841
+ console.log();
6842
+ console.log(pc.bold(" amba payments balance"));
6843
+ console.log(pc.dim(" ─────────────────────────────────"));
6844
+ console.log();
6845
+ try {
6846
+ const { projectId } = await loadProjectConfig();
6847
+ const res = await adminGet(`/projects/${projectId}/payments/balance`);
6848
+ const fmt = (b) => `${(b.amount / 100).toFixed(2)} ${b.currency.toUpperCase()}`;
6849
+ console.log(` Available: ${res.data.available.length ? res.data.available.map(fmt).join(", ") : pc.dim("—")}`);
6850
+ console.log(` Pending: ${res.data.pending.length ? res.data.pending.map(fmt).join(", ") : pc.dim("—")}`);
6851
+ console.log();
6852
+ } catch (err) {
6853
+ handleError(err);
6854
+ }
6855
+ }
6856
+ async function paymentsPayoutsCommand(input) {
6857
+ console.log();
6858
+ console.log(pc.bold(" amba payments payouts"));
6859
+ console.log(pc.dim(" ─────────────────────────────────"));
6860
+ console.log();
6861
+ try {
6862
+ const { projectId } = await loadProjectConfig();
6863
+ const res = await adminGet(`/projects/${projectId}/payments/payouts${input.limit ? `?limit=${input.limit}` : ""}`);
6864
+ if (res.data.length === 0) {
6865
+ console.log(pc.dim(" No payouts yet."));
6866
+ console.log();
6867
+ return;
6868
+ }
6869
+ for (const p of res.data) {
6870
+ const arrival = (/* @__PURE__ */ new Date(p.arrival_date * 1e3)).toLocaleDateString();
6871
+ 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}`);
6872
+ }
6873
+ console.log();
6874
+ } catch (err) {
6875
+ handleError(err);
6876
+ }
6877
+ }
6878
+ //#endregion
6879
+ //#region src/commands/monetization.ts
6880
+ /**
6881
+ * `amba monetization *` subcommands — CLI access to the monetization control
6882
+ * plane behind `/v1/admin/projects/:id/monetization/*`.
6883
+ *
6884
+ * amba monetization plan — three-way diff (declared vs adopted baseline
6885
+ * vs live provider) + store-floor preflight +
6886
+ * drift. READ-ONLY — previews; applies nothing.
6887
+ * amba monetization drift — out-of-band changes to Amba-managed objects.
6888
+ * amba monetization export — snapshot the live config to a declarative
6889
+ * bundle (the `monetization` section), to stdout
6890
+ * or a file.
6891
+ * amba monetization adopt — record the live config as the managed baseline
6892
+ * so the next plan is a no-op. Writes ONLY Amba's
6893
+ * own state — never the provider. Idempotent.
6894
+ * amba monetization apply — push the declared config to the provider.
6895
+ * DURABLE: the API returns a pollable
6896
+ * operation_id and the CLI polls it to a
6897
+ * terminal state. Additive changes apply
6898
+ * automatically; destructive changes run only
6899
+ * behind --confirm (which approves the exact
6900
+ * plan_hash — a pending apply awaiting
6901
+ * confirmation is approved by re-running with
6902
+ * --confirm). STORE-PRODUCT steps (creating
6903
+ * declared products in the provider and, for
6904
+ * App Store products with push intent, in App
6905
+ * Store Connect) ALWAYS wait for an explicit
6906
+ * approval of the started apply — an up-front
6907
+ * --confirm never auto-runs them. Refuses if
6908
+ * the provider drifted since the plan.
6909
+ *
6910
+ * Project is resolved via `loadProjectConfig` — same pattern as the other
6911
+ * project-scoped CLI commands.
6912
+ */
6913
+ /**
6914
+ * Authenticated write helper (POST). Mirrors the `adminWrite` in
6915
+ * `commands/billing.ts` — kept local so the api-client public surface doesn't
6916
+ * grow a generic POST.
6917
+ */
6918
+ async function adminPost(path, body) {
6919
+ const token = await resolveBearerToken();
6920
+ const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
6921
+ const res = await fetch(url, {
6922
+ method: "POST",
6923
+ headers: {
6924
+ Authorization: `Bearer ${token}`,
6925
+ "Content-Type": "application/json",
6926
+ "User-Agent": "amba-cli/0.1.1"
6927
+ },
6928
+ body: body === void 0 ? void 0 : JSON.stringify(body)
6929
+ });
6930
+ if (!res.ok) {
6931
+ let message = `API request failed: ${res.status} ${res.statusText}`;
6932
+ let code;
6933
+ try {
6934
+ const errorBody = await res.json();
6935
+ if (errorBody.error?.message) {
6936
+ message = errorBody.error.message;
6937
+ code = errorBody.error.code;
6938
+ }
6939
+ } catch {}
6940
+ throw new ApiClientError(message, res.status, code);
6941
+ }
6942
+ return await res.json();
6943
+ }
6944
+ function header(title) {
6945
+ console.log();
6946
+ console.log(pc.bold(` amba monetization ${title}`));
6947
+ console.log(pc.dim(" ─────────────────────────────────"));
6948
+ console.log();
6949
+ }
6950
+ async function monetizationPlanCommand() {
6951
+ const { projectId } = await loadProjectConfig();
6952
+ header("plan");
6953
+ const plan = (await adminGet(`/projects/${projectId}/monetization/plan`)).data;
6954
+ if (plan.clean) {
6955
+ console.log(pc.green(" ✓") + " No changes — declared config matches the live provider.");
6956
+ console.log();
6957
+ return;
6958
+ }
6959
+ if (plan.ops.length > 0) {
6960
+ console.log(pc.bold(" Pending operations (preview — nothing is applied):"));
6961
+ 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}]`)}`);
6962
+ console.log();
6963
+ }
6964
+ if (plan.store_floor.length > 0) {
6965
+ console.log(pc.yellow(" Store-floor preflight — blocking gaps apply can neither resolve nor create:"));
6966
+ for (const sf of plan.store_floor) {
6967
+ console.log(` ${pc.bold(sf.product_key)} → ${sf.store_identifier} (${sf.store})`);
6968
+ console.log(pc.dim(` ${sf.reason}`));
6969
+ }
6970
+ console.log();
6971
+ }
6972
+ printHumanFloor(plan.human_floor);
6973
+ if (plan.drift.length > 0) {
6974
+ console.log(pc.yellow(" Drift — managed objects changed out-of-band in the provider:"));
6975
+ for (const d of plan.drift) console.log(` ${pc.bold(d.object_type)} ${d.identifier}`);
6976
+ console.log();
6977
+ }
6978
+ }
6979
+ /** Print the store-side human-floor checklist (exact console locations). */
6980
+ function printHumanFloor(items) {
6981
+ if (!items || items.length === 0) return;
6982
+ console.log(pc.yellow(" Store checklist — steps no API can do (with exact locations):"));
6983
+ for (const h of items) {
6984
+ console.log(` ${pc.bold("□")} ${h.title}${h.product_key ? pc.dim(` (${h.product_key})`) : ""}`);
6985
+ console.log(pc.dim(` ${h.detail}`));
6986
+ console.log(pc.dim(` where: ${h.location}`));
6987
+ }
6988
+ console.log();
6989
+ }
6990
+ async function monetizationDriftCommand() {
6991
+ const { projectId } = await loadProjectConfig();
6992
+ header("drift");
6993
+ const data = (await adminGet(`/projects/${projectId}/monetization/drift`)).data;
6994
+ if (!data.has_drift) {
6995
+ console.log(pc.green(" ✓") + " No drift — managed config matches the live provider.");
6996
+ console.log();
6997
+ return;
6998
+ }
6999
+ console.log(pc.yellow(" Drift detected:"));
7000
+ for (const d of data.drift) console.log(` ${pc.bold(d.object_type)} ${d.identifier}`);
7001
+ console.log();
7002
+ }
7003
+ async function monetizationExportCommand(options = {}) {
7004
+ const { projectId } = await loadProjectConfig();
7005
+ const bundle = {
7006
+ version: 1,
7007
+ monetization: (await adminGet(`/projects/${projectId}/monetization/export`)).data.monetization
7008
+ };
7009
+ const json = JSON.stringify(bundle, null, 2);
7010
+ if (options.file) {
7011
+ await writeFile(resolve(options.file), json + "\n", "utf-8");
7012
+ console.log();
7013
+ console.log(pc.green(" ✓") + ` Wrote live monetization config to ${pc.cyan(options.file)}.`);
7014
+ console.log();
7015
+ } else console.log(json);
7016
+ }
7017
+ async function monetizationAdoptCommand(options = {}) {
7018
+ const { projectId } = await loadProjectConfig();
7019
+ header("adopt");
7020
+ if (!options.all) {
7021
+ console.log(" ✗ Pass --all to adopt the entire live config as the managed baseline.");
7022
+ process.exit(1);
7023
+ }
7024
+ const d = (await adminPost(`/projects/${projectId}/monetization/adopt`, { adopt_all: true })).data;
7025
+ 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)`));
7026
+ console.log(pc.dim(" The next `amba monetization plan` is now a clean no-op."));
7027
+ console.log();
7028
+ }
7029
+ async function monetizationApplyCommand(options = {}) {
7030
+ const { projectId } = await loadProjectConfig();
7031
+ header("apply");
7032
+ const plan = (await adminGet(`/projects/${projectId}/monetization/plan`)).data;
7033
+ if (plan.store_floor.length > 0) {
7034
+ console.log(pc.yellow(" Store floor not satisfied — apply can neither resolve nor create these:"));
7035
+ for (const sf of plan.store_floor) {
7036
+ console.log(` ${pc.bold(sf.product_key)} → ${sf.store_identifier} (${sf.store})`);
7037
+ console.log(pc.dim(` ${sf.reason}`));
7038
+ }
7039
+ console.log();
7040
+ console.log(" ✗ Fix the declaration (or connect the app), then run plan + apply again.");
7041
+ process.exit(1);
7042
+ }
7043
+ if (plan.clean) {
7044
+ console.log(pc.green(" ✓") + " Nothing to apply — declared config matches the provider.");
7045
+ console.log();
7046
+ return;
7047
+ }
7048
+ if (options.reconcile && options.reconcile !== "amba" && options.reconcile !== "adopt") {
7049
+ console.log(pc.red(" ✗") + " --reconcile must be 'amba' or 'adopt'.");
7050
+ process.exit(1);
7051
+ }
7052
+ const body = { plan_hash: plan.plan_hash };
7053
+ if (options.confirm) body["confirm"] = plan.plan_hash;
7054
+ if (options.allowDetachLive) body["allow_detach_live"] = true;
7055
+ if (options.reconcile) body["reconcile"] = options.reconcile;
7056
+ let start;
7057
+ try {
7058
+ start = (await adminPost(`/projects/${projectId}/monetization/apply`, body)).data;
7059
+ } catch (err) {
7060
+ if (err instanceof ApiClientError && err.errorCode === "MONETIZATION_APPLY_IN_PROGRESS") {
7061
+ console.log(pc.yellow(" ⚠ An apply is already in progress for this project."));
7062
+ console.log(pc.dim(" Wait for it to finish, or re-run with --confirm to approve a pending confirmation."));
7063
+ console.log();
7064
+ process.exit(1);
7065
+ }
7066
+ throw err;
7067
+ }
7068
+ if (start.refused.length > 0) {
7069
+ console.log(pc.yellow(" Not applicable via API (do these in the provider dashboard):"));
7070
+ for (const r of start.refused) console.log(` ${pc.bold(r.op)} ${r.object_type} ${r.identifier}`);
7071
+ console.log();
7072
+ }
7073
+ if (start.status === "succeeded") {
7074
+ console.log(pc.green(" ✓") + " Nothing to apply — declared config matches the provider.");
7075
+ console.log();
7076
+ return;
7077
+ }
7078
+ if (start.confirm_required) {
7079
+ const storeSteps = start.gated.filter((g) => g.store === true);
7080
+ console.log(pc.yellow(" ⚠ This plan includes gated operation(s) — they are NOT applied yet."));
7081
+ for (const g of start.gated) {
7082
+ const tag = g.store === true ? pc.magenta(" [store]") : "";
7083
+ console.log(` ${pc.bold(g.op)} ${g.object_type} ${g.identifier}${tag}`);
7084
+ }
7085
+ console.log();
7086
+ if (storeSteps.length > 0) console.log(pc.dim(" Store catalog steps require an explicit approval of THIS apply — an up-front --confirm never auto-runs them."));
7087
+ printHumanFloor(start.human_floor);
7088
+ console.log(pc.dim(" Review the plan, then approve the pending apply:"));
7089
+ console.log(` ${pc.cyan("amba monetization apply --confirm")}`);
7090
+ console.log();
7091
+ console.log(pc.dim(` operation: ${start.operation_id}`));
7092
+ console.log();
7093
+ process.exit(1);
7094
+ }
7095
+ console.log(pc.dim(` Applying… (operation ${start.operation_id} — safe to Ctrl-C; the apply continues)`));
7096
+ const op = await pollApplyOperation(projectId, start.operation_id);
7097
+ if (op.result?.sandbox_stage?.ran) {
7098
+ const s = op.result.sandbox_stage;
7099
+ const tag = s.passed ? pc.green("✓ sandbox passed") : pc.red("✗ sandbox failed");
7100
+ console.log(` ${tag} ` + pc.dim(s.detail));
7101
+ console.log();
7102
+ }
7103
+ if (op.status === "succeeded") {
7104
+ const r = op.result ?? {};
7105
+ console.log(pc.green(" ✓") + ` Applied ${r.applied ?? 0} change(s) to the provider ` + pc.dim(`(${r.skipped_existing ?? 0} already present, ${r.gated_applied ?? 0} destructive)`));
7106
+ console.log();
7107
+ return;
7108
+ }
7109
+ console.log(pc.red(" ✗") + " Apply did not complete.");
7110
+ if (op.failed_reason) console.log(pc.dim(` ${op.failed_reason}`));
7111
+ console.log();
7112
+ process.exit(1);
7113
+ }
7114
+ /** Poll GET /operations/:id until the apply reaches a terminal state. */
7115
+ async function pollApplyOperation(projectId, operationId) {
7116
+ const POLL_INTERVAL_MS = 3e3;
7117
+ const deadline = Date.now() + 900 * 1e3;
7118
+ for (;;) {
7119
+ const op = (await adminGet(`/projects/${projectId}/operations/${operationId}`)).data;
7120
+ if (op.status === "succeeded" || op.status === "failed") return op;
7121
+ if (Date.now() >= deadline) {
7122
+ console.log(pc.yellow(` ⚠ Still running server-side (operation ${operationId}).`) + pc.dim(" Re-run `amba monetization plan` later — a clean plan means it finished."));
7123
+ process.exit(1);
7124
+ }
7125
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
7126
+ }
7127
+ }
7128
+ //#endregion
7129
+ //#region src/commands/collections.ts
7130
+ /**
7131
+ * `amba collections ...` — thin shells over the admin collection routes.
7132
+ *
7133
+ * Wire shapes:
7134
+ *
7135
+ * POST /admin/projects/:p/collections — create (full schema)
7136
+ * GET /admin/projects/:p/collections — list
7137
+ * PATCH /admin/projects/:p/collections/:name — single-op alter
7138
+ * DELETE /admin/projects/:p/collections/:name — drop (requires ?confirm)
7139
+ *
7140
+ * The CLI's job is to:
7141
+ * 1. Validate names / types client-side so an obviously-bad invocation
7142
+ * doesn't round-trip.
7143
+ * 2. Translate the user-friendly CLI flag shape (`--field name:type`,
7144
+ * `--add-field`, `--drop-field`) into the wire shape (single op per
7145
+ * PATCH).
7146
+ * 3. Block until the server reports success.
7147
+ * 4. Surface server errors with a sensible CLI message.
7148
+ *
7149
+ * Multi-op alters are issued sequentially so the operator audit trail
7150
+ * stays one-to-one with developer actions.
7151
+ */
7152
+ /**
7153
+ * Closed-set type catalog. Matches data's `ColumnType` exactly — keeps
7154
+ * the CLI fail-fast path aligned with the DDL emit's accepted shapes.
7155
+ *
7156
+ * The CLI accepts customer-friendly synonyms (e.g. `int` → `integer`)
7157
+ * via `normalizeColumnType` so the `--field` flag stays ergonomic
7158
+ * without the API needing to relax its closed catalog.
7159
+ */
7160
+ const ALLOWED_TYPES = new Set([
7161
+ "uuid",
7162
+ "text",
7163
+ "integer",
7164
+ "bigint",
7165
+ "numeric",
7166
+ "boolean",
7167
+ "timestamptz",
7168
+ "date",
7169
+ "jsonb",
7170
+ "vector",
7171
+ "text[]",
7172
+ "integer[]",
7173
+ "bigint[]",
7174
+ "numeric[]",
7175
+ "boolean[]",
7176
+ "uuid[]"
7177
+ ]);
7178
+ const TYPE_SYNONYMS = {
7179
+ int: "integer",
7180
+ int4: "integer",
7181
+ int8: "bigint",
7182
+ bool: "boolean",
7183
+ json: "jsonb",
7184
+ string: "text"
7185
+ };
7186
+ async function collectionsCreateCommand(name, options) {
7187
+ const reason = getReservationReason(name);
7188
+ if (reason) throw new Error(`Cannot create collection '${name}': ${reason}`);
7189
+ const columns = options.field.map(parseColumnSpec);
7190
+ const indexes = options.index.map(parseIndexSpec);
7191
+ const projectConfig = await loadProjectConfig();
7192
+ console.log();
7193
+ console.log(pc.bold(` amba collections create ${pc.cyan(name)}`));
7194
+ for (const c of columns) console.log(pc.dim(" ") + c.name + pc.dim(": ") + c.type + (c.nullable ? pc.dim(" NULL") : pc.dim(" NOT NULL")));
7195
+ console.log();
7196
+ const res = await createCollection(projectConfig.projectId, {
7197
+ name,
7198
+ columns,
7199
+ indexes
7200
+ });
7201
+ console.log(pc.green(" ✓") + ` Created — version ${res.data.version}`);
7202
+ console.log(pc.dim(` workflow_id: ${res.data.workflow_id}`));
7203
+ console.log();
7204
+ }
7205
+ async function collectionsAlterCommand(name, options) {
7206
+ 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.");
7207
+ const confirmed = new Set(options.confirm ?? []);
7208
+ 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).`);
7209
+ const projectConfig = await loadProjectConfig();
7210
+ for (const fieldSpec of options.addField) {
7211
+ const column = parseColumnSpec(fieldSpec);
6116
7212
  const res = await alterCollection(projectConfig.projectId, name, { add_column: column });
6117
7213
  console.log(pc.green(" ✓") + ` add column ${pc.cyan(column.name)} (${column.type}) — version ${res.data.version}`);
6118
7214
  }
@@ -6144,7 +7240,10 @@ async function collectionsDropCommand(name, options = {}) {
6144
7240
  }
6145
7241
  function normalizeColumnType(raw) {
6146
7242
  const baseRaw = (raw.split("(")[0] ?? raw).trim().toLowerCase();
6147
- const base = TYPE_SYNONYMS[baseRaw] ?? baseRaw;
7243
+ const isArray = baseRaw.endsWith("[]");
7244
+ const elementRaw = isArray ? baseRaw.slice(0, -2) : baseRaw;
7245
+ const element = TYPE_SYNONYMS[elementRaw] ?? elementRaw;
7246
+ const base = isArray ? `${element}[]` : element;
6148
7247
  if (!ALLOWED_TYPES.has(base)) throw new Error(`Unknown type '${raw}'. Allowed: ${[...ALLOWED_TYPES].join(", ")}.`);
6149
7248
  return base;
6150
7249
  }
@@ -6154,7 +7253,11 @@ function normalizeColumnType(raw) {
6154
7253
  * Accepted grammars:
6155
7254
  *
6156
7255
  * 1. **Plain column:** `name:type[:nullable]`
6157
- * Examples: `letter_id:uuid`, `parsed:jsonb:nullable`.
7256
+ * Examples: `letter_id:uuid`, `parsed:jsonb:nullable`, `tags:text[]`,
7257
+ * `scores:integer[]:nullable`. Array types (`text[]`, `integer[]`,
7258
+ * `bigint[]`, `numeric[]`, `boolean[]`, `uuid[]`) follow the same
7259
+ * grammar as scalars and unlock the contains/containedBy/overlaps
7260
+ * query operators.
6158
7261
  *
6159
7262
  * 2. **Foreign key:** `name:type:fk(<table>[.<column>][:onDelete])`
6160
7263
  * Examples:
@@ -6181,12 +7284,14 @@ function normalizeColumnType(raw) {
6181
7284
  * left-to-right: the third segment is the FK / vector / nullable
6182
7285
  * marker; if it's an FK or vector, the optional fourth segment is
6183
7286
  * `nullable`. Plain columns put `nullable` in slot 3.
7287
+ *
7288
+ * Exported for tests.
6184
7289
  */
6185
7290
  function parseColumnSpec(spec) {
6186
7291
  const parts = spec.replace(/^([a-z_][a-z0-9_]*):vector\((\d+)\)/i, "$1:vector:$2").replace(/fk\(([^)]+)\)/i, (_match, inner) => {
6187
7292
  return `fk(${inner.replace(/:/g, "|")})`;
6188
7293
  }).split(":");
6189
- if (parts.length < 2 || parts.length > 4) throw new Error(`Invalid field spec '${spec}'. Grammar:\n name:type[:nullable] (plain)\n name:type:fk(table[.col][:onDelete])[:nullable] (foreign key)\n name:vector:<dim>[:nullable] (vector / pgvector)\nExamples: user_id:uuid, parsed:jsonb:nullable, plan_id:uuid:fk(plans),\n embedding:vector:1536, comment_id:uuid:fk(comments.id:set_null).`);
7294
+ if (parts.length < 2 || parts.length > 4) throw new Error(`Invalid field spec '${spec}'. Grammar:\n name:type[:nullable] (plain)\n name:type:fk(table[.col][:onDelete])[:nullable] (foreign key)\n name:vector:<dim>[:nullable] (vector / pgvector)\nExamples: user_id:uuid, tags:text[], parsed:jsonb:nullable, plan_id:uuid:fk(plans),\n embedding:vector:1536, comment_id:uuid:fk(comments.id:set_null).`);
6190
7295
  const colName = parts[0];
6191
7296
  const type = parts[1];
6192
7297
  const slot3 = parts[2];
@@ -6210,85 +7315,1265 @@ function parseColumnSpec(spec) {
6210
7315
  if (slot4 !== "nullable") throw new Error(`Invalid trailing token '${slot4}' on vector column '${colName}'. Only 'nullable' is allowed after the dimension.`);
6211
7316
  out.nullable = true;
6212
7317
  }
6213
- return out;
7318
+ return out;
7319
+ }
7320
+ if (slot3 === "nullable") {
7321
+ out.nullable = true;
7322
+ if (slot4 !== void 0) throw new Error(`Invalid trailing token '${slot4}' on column '${colName}'. 'nullable' must be the last segment for plain fields.`);
7323
+ return out;
7324
+ }
7325
+ if (slot3.startsWith("fk(") && slot3.endsWith(")")) {
7326
+ out.references = parseFkSpec(slot3.slice(3, -1).replace(/\|/g, ":"));
7327
+ if (slot4 !== void 0) {
7328
+ if (slot4 !== "nullable") throw new Error(`Invalid trailing token '${slot4}' on column '${colName}'. Only 'nullable' is allowed after fk(...).`);
7329
+ out.nullable = true;
7330
+ }
7331
+ return out;
7332
+ }
7333
+ throw new Error(`Invalid third segment '${slot3}' on column '${colName}'. Expected 'nullable', fk(...), or — for vector columns — a positive integer dimension.`);
7334
+ }
7335
+ /**
7336
+ * Parse the `fk(...)` body into the API's `references` shape.
7337
+ * Supported inner grammars:
7338
+ * - `<table>` → `{ table }` (FK to `<table>(id)`, no ON DELETE clause)
7339
+ * - `<table>.<column>` → `{ table, column }`
7340
+ * - `<table>:<onDelete>` → `{ table, onDelete }`
7341
+ * - `<table>.<column>:<onDelete>` → `{ table, column, onDelete }`
7342
+ *
7343
+ * `onDelete` accepts case-insensitive `cascade | restrict | set_null | no_action`
7344
+ * (and the `set null` / `no action` SQL spellings) — translated to the
7345
+ * canonical uppercased form the API expects.
7346
+ */
7347
+ function parseFkSpec(inner) {
7348
+ const [tablePart, onDelete] = inner.includes(":") ? inner.split(":") : [inner, void 0];
7349
+ const trimmedTable = tablePart.trim();
7350
+ if (trimmedTable.length === 0) throw new Error(`Invalid fk(...) — table name is required.`);
7351
+ const dotIdx = trimmedTable.indexOf(".");
7352
+ let table;
7353
+ let column;
7354
+ if (dotIdx === -1) table = trimmedTable;
7355
+ else {
7356
+ table = trimmedTable.slice(0, dotIdx);
7357
+ column = trimmedTable.slice(dotIdx + 1);
7358
+ }
7359
+ if (!/^[a-z_][a-z0-9_]*$/.test(table)) throw new Error(`Invalid fk table name '${table}'.`);
7360
+ if (column !== void 0 && !/^[a-z_][a-z0-9_]*$/.test(column)) throw new Error(`Invalid fk column name '${column}'.`);
7361
+ const out = { table };
7362
+ if (column !== void 0) out.column = column;
7363
+ if (onDelete !== void 0) out.onDelete = normalizeOnDelete(onDelete);
7364
+ return out;
7365
+ }
7366
+ function normalizeOnDelete(raw) {
7367
+ switch (raw.trim().toLowerCase().replace(/_/g, " ")) {
7368
+ case "cascade": return "CASCADE";
7369
+ case "restrict": return "RESTRICT";
7370
+ case "set null": return "SET NULL";
7371
+ case "no action": return "NO ACTION";
7372
+ default: throw new Error(`Invalid onDelete '${raw}' in fk(...). Supported: cascade | restrict | set_null | no_action.`);
7373
+ }
7374
+ }
7375
+ /**
7376
+ * Parse an index spec into the wire shape data's API expects.
7377
+ *
7378
+ * Input syntax (CLI-friendly): `"col1 [asc|desc], col2 [asc|desc]"`.
7379
+ * Wire shape (per data's contract): `{ columns: ['col1 desc', 'col2 asc'] }` —
7380
+ * direction is embedded in each column string and parsed by the DDL emit.
7381
+ *
7382
+ * Bare column names (no direction) are passed through verbatim; the
7383
+ * default sort direction is the DDL emit's responsibility.
7384
+ *
7385
+ * Exported for tests.
7386
+ */
7387
+ function parseIndexSpec(spec) {
7388
+ const cols = [];
7389
+ for (const part of spec.split(",")) {
7390
+ const trimmed = part.trim();
7391
+ if (!trimmed) throw new Error(`Invalid index spec '${spec}'.`);
7392
+ const m = /^([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+(asc|desc))?$/i.exec(trimmed);
7393
+ if (!m) throw new Error(`Invalid index column '${trimmed}'. Expected '<col>' or '<col> asc|desc'.`);
7394
+ const colName = m[1];
7395
+ const direction = m[2]?.toLowerCase();
7396
+ cols.push(direction ? `${colName} ${direction}` : colName);
7397
+ }
7398
+ return { columns: cols };
7399
+ }
7400
+ //#endregion
7401
+ //#region src/ship/config.ts
7402
+ /**
7403
+ * `amba ship` config — the typed, hand-validated launch config.
7404
+ *
7405
+ * One file per repo (default `amba.ship.json`) describes how to take this Expo
7406
+ * app from code-complete to live on the App Store and Google Play. It carries
7407
+ * NON-SECRET identifiers and the NAME of the env var holding the Expo token —
7408
+ * never secret values (the App Store Connect `.p8` and Play service-account
7409
+ * JSON live in `eas.json`, owned by the app repo, exactly where the build runs).
7410
+ *
7411
+ * Validation is hand-rolled (the CLI deliberately ships no `zod` — see
7412
+ * `_internal/shared.ts` for the same return-all-errors-at-once pattern). The
7413
+ * field names are the canonical contract; `validateShipConfig` reports every
7414
+ * problem in one pass and enforces the cross-field invariants.
7415
+ *
7416
+ * Monetization is NOT described here: the App Store / Play product catalog is
7417
+ * Amba's monetization control plane (`amba monetization`, the durable Track H
7418
+ * apply against the provider). `ship` only flips `monetization.enabled` to
7419
+ * decide whether to verify that catalog is in sync before building.
7420
+ */
7421
+ /** The ordered launch phases. `ship` runs them in this sequence. */
7422
+ const SHIP_PHASES = [
7423
+ "preflight",
7424
+ "monetization",
7425
+ "build",
7426
+ "submit",
7427
+ "metadata",
7428
+ "release"
7429
+ ];
7430
+ const SHIP_PLATFORMS = ["ios", "android"];
7431
+ /** Google Play release tracks `ship` can submit to. */
7432
+ const PLAY_TRACKS = [
7433
+ "internal",
7434
+ "alpha",
7435
+ "beta",
7436
+ "production"
7437
+ ];
7438
+ /** Default config filename when no `--config <path>` is passed. */
7439
+ const DEFAULT_SHIP_CONFIG_FILE = "amba.ship.json";
7440
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
7441
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
7442
+ const REVERSE_DNS_RE = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/;
7443
+ function asObject(value) {
7444
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
7445
+ return value;
7446
+ }
7447
+ /**
7448
+ * Validate an untrusted parsed JSON value into a typed `ShipConfig`,
7449
+ * collecting EVERY problem (not just the first) so a misconfigured file
7450
+ * surfaces all its issues in one run. Returns the typed config on success.
7451
+ */
7452
+ function validateShipConfig(raw) {
7453
+ const errors = [];
7454
+ const root = asObject(raw);
7455
+ if (!root) return {
7456
+ ok: false,
7457
+ errors: ["config must be a JSON object"]
7458
+ };
7459
+ if (typeof root["version"] !== "number") errors.push("version must be a number");
7460
+ const app = asObject(root["app"]);
7461
+ if (!app) errors.push("app must be an object");
7462
+ else {
7463
+ const slug = app["slug"];
7464
+ if (typeof slug !== "string" || !SLUG_RE.test(slug)) errors.push("app.slug must be lowercase letters, digits and dashes");
7465
+ if (typeof app["name"] !== "string" || app["name"].trim().length === 0) errors.push("app.name must be a non-empty string");
7466
+ if (typeof app["version"] !== "string" || !SEMVER_RE.test(app["version"])) errors.push("app.version must be semver MAJOR.MINOR.PATCH");
7467
+ if (typeof app["bundleId"] !== "string" || !REVERSE_DNS_RE.test(app["bundleId"])) errors.push("app.bundleId must be a reverse-DNS identifier like ai.uselayers.unbury");
7468
+ if (typeof app["androidPackage"] !== "string" || !REVERSE_DNS_RE.test(app["androidPackage"])) errors.push("app.androidPackage must be a reverse-DNS identifier like ai.uselayers.unbury");
7469
+ }
7470
+ const build = asObject(root["build"]);
7471
+ if (!build) errors.push("build must be an object");
7472
+ else {
7473
+ if (typeof build["profile"] !== "string" || build["profile"].trim().length === 0) errors.push("build.profile must be a non-empty string (an eas.json build profile)");
7474
+ const platforms = build["platforms"];
7475
+ if (!Array.isArray(platforms) || platforms.length === 0) errors.push("build.platforms must be a non-empty array of \"ios\" | \"android\"");
7476
+ else {
7477
+ const seen = /* @__PURE__ */ new Set();
7478
+ for (const p of platforms) if (p !== "ios" && p !== "android") errors.push(`build.platforms entries must be "ios" or "android" (got ${JSON.stringify(p)})`);
7479
+ else if (seen.has(p)) errors.push(`build.platforms has a duplicate entry "${p}"`);
7480
+ else seen.add(p);
7481
+ }
7482
+ }
7483
+ const ios = asObject(root["ios"]);
7484
+ if (!ios) errors.push("ios must be an object");
7485
+ else if (ios["ascAppId"] !== null && typeof ios["ascAppId"] !== "string") errors.push("ios.ascAppId must be a string or null");
7486
+ else if (typeof ios["ascAppId"] === "string" && ios["ascAppId"].trim().length === 0) errors.push("ios.ascAppId must be a non-empty string when set (or null)");
7487
+ const android = asObject(root["android"]);
7488
+ if (!android) errors.push("android must be an object");
7489
+ else if (!PLAY_TRACKS.includes(android["track"])) errors.push(`android.track must be one of ${PLAY_TRACKS.join(" | ")}`);
7490
+ const monetization = asObject(root["monetization"]);
7491
+ if (!monetization) errors.push("monetization must be an object");
7492
+ else if (typeof monetization["enabled"] !== "boolean") errors.push("monetization.enabled must be a boolean");
7493
+ const secrets = asObject(root["secrets"]);
7494
+ if (!secrets) errors.push("secrets must be an object");
7495
+ else if (typeof secrets["expoTokenEnvVar"] !== "string" || secrets["expoTokenEnvVar"].trim().length === 0) errors.push("secrets.expoTokenEnvVar must be a non-empty env var NAME (e.g. \"EXPO_TOKEN\")");
7496
+ if (errors.length > 0) return {
7497
+ ok: false,
7498
+ errors
7499
+ };
7500
+ const a = app;
7501
+ const b = build;
7502
+ return {
7503
+ ok: true,
7504
+ config: {
7505
+ version: root["version"],
7506
+ app: {
7507
+ slug: a["slug"],
7508
+ name: a["name"].trim(),
7509
+ version: a["version"],
7510
+ bundleId: a["bundleId"],
7511
+ androidPackage: a["androidPackage"]
7512
+ },
7513
+ build: {
7514
+ profile: b["profile"].trim(),
7515
+ platforms: b["platforms"].slice()
7516
+ },
7517
+ ios: { ascAppId: ios["ascAppId"] },
7518
+ android: { track: android["track"] },
7519
+ monetization: { enabled: monetization["enabled"] },
7520
+ secrets: { expoTokenEnvVar: secrets["expoTokenEnvVar"].trim() }
7521
+ }
7522
+ };
7523
+ }
7524
+ /**
7525
+ * A starter config written by `amba ship init`. Placeholders are intentionally
7526
+ * obvious so the validator's invariants don't pass on an unedited file's IDs
7527
+ * (the reverse-DNS / semver placeholders DO validate so `--phase preflight`
7528
+ * works immediately; the operator swaps in their real values).
7529
+ */
7530
+ function shipConfigTemplate(slug) {
7531
+ return {
7532
+ version: 1,
7533
+ app: {
7534
+ slug,
7535
+ name: "My App",
7536
+ version: "1.0.0",
7537
+ bundleId: `com.example.${slug.replace(/-/g, "")}`,
7538
+ androidPackage: `com.example.${slug.replace(/-/g, "")}`
7539
+ },
7540
+ build: {
7541
+ profile: "production",
7542
+ platforms: ["ios", "android"]
7543
+ },
7544
+ ios: { ascAppId: null },
7545
+ android: { track: "internal" },
7546
+ monetization: { enabled: false },
7547
+ secrets: { expoTokenEnvVar: "EXPO_TOKEN" }
7548
+ };
7549
+ }
7550
+ //#endregion
7551
+ //#region src/ship/exec.ts
7552
+ /**
7553
+ * The external-command boundary for `amba ship`.
7554
+ *
7555
+ * Every shell-out (always `eas`) goes through an `Executor`, so the phase
7556
+ * logic — which args get built, how `--json` output is parsed, which failures
7557
+ * map to which manual gate — is unit-tested against a fake executor with NO
7558
+ * live cloud, no EAS account, and no Apple/Google credentials. The default
7559
+ * `createNodeExecutor()` is a thin, shell-free wrapper around
7560
+ * `node:child_process.spawn` (mirrors the launch toolkit's `lib/exec`).
7561
+ */
7562
+ /**
7563
+ * Redact secret-shaped tokens from a string before it is logged. A backstop,
7564
+ * not a license — `ship` never puts secret values on a command line (EAS reads
7565
+ * its token from the env and its store creds from eas.json). Covers Expo /
7566
+ * common access-token shapes and `Bearer`/`sk_` prefixes.
7567
+ */
7568
+ function redactSecrets(text) {
7569
+ return text.replace(/\b(Bearer)\s+[A-Za-z0-9._-]+/gi, "$1 ***").replace(/\b(sk|pk|rcb)_[A-Za-z0-9]+/g, "$1_***").replace(/\bamb_dpat_[A-Za-z0-9_-]+/g, "amb_dpat_***").replace(/\b[A-Za-z0-9_-]*EXPO_TOKEN[A-Za-z0-9_-]*=\S+/g, "EXPO_TOKEN=***");
7570
+ }
7571
+ /** A backing executor over `node:child_process.spawn`. Never invokes a shell. */
7572
+ function createNodeExecutor(globalDryRun = false) {
7573
+ return { run(cmd, args, opts = {}) {
7574
+ const dryRun = opts.dryRun ?? globalDryRun;
7575
+ const printable = redactSecrets([cmd, ...args].join(" "));
7576
+ if (dryRun) return Promise.resolve({
7577
+ command: printable,
7578
+ code: 0,
7579
+ stdout: "",
7580
+ stderr: "",
7581
+ dryRun: true
7582
+ });
7583
+ const stream = opts.stream ?? false;
7584
+ return new Promise((resolve, reject) => {
7585
+ const child = spawn(cmd, args, {
7586
+ cwd: opts.cwd ?? process$1.cwd(),
7587
+ env: opts.env ? {
7588
+ ...process$1.env,
7589
+ ...opts.env
7590
+ } : process$1.env,
7591
+ shell: false
7592
+ });
7593
+ let stdout = "";
7594
+ let stderr = "";
7595
+ let timedOut = false;
7596
+ let timer;
7597
+ if (opts.timeoutMs && opts.timeoutMs > 0) timer = setTimeout(() => {
7598
+ timedOut = true;
7599
+ child.kill("SIGKILL");
7600
+ }, opts.timeoutMs);
7601
+ child.stdout?.on("data", (chunk) => {
7602
+ const s = chunk.toString();
7603
+ stdout += s;
7604
+ if (stream) process$1.stdout.write(redactSecrets(s));
7605
+ });
7606
+ child.stderr?.on("data", (chunk) => {
7607
+ const s = chunk.toString();
7608
+ stderr += s;
7609
+ if (stream) process$1.stderr.write(redactSecrets(s));
7610
+ });
7611
+ child.on("error", (err) => {
7612
+ if (timer) clearTimeout(timer);
7613
+ const hint = err.code === "ENOENT" ? `Command "${cmd}" not found. Is it installed and on PATH?` : err.message;
7614
+ reject(/* @__PURE__ */ new Error(`Failed to start "${printable}": ${hint}`));
7615
+ });
7616
+ child.on("close", (code) => {
7617
+ if (timer) clearTimeout(timer);
7618
+ if (timedOut) {
7619
+ reject(/* @__PURE__ */ new Error(`Command timed out after ${opts.timeoutMs}ms: ${printable}\n stderr tail: ${redactSecrets(stderr.slice(-400))}`));
7620
+ return;
7621
+ }
7622
+ if ((code ?? -1) !== 0) {
7623
+ reject(/* @__PURE__ */ new Error(`Command failed (exit ${code ?? -1}): ${printable}\n stderr tail: ${redactSecrets(stderr.slice(-400) || stdout.slice(-400) || "(no output)")}`));
7624
+ return;
7625
+ }
7626
+ resolve({
7627
+ command: printable,
7628
+ code: code ?? 0,
7629
+ stdout,
7630
+ stderr,
7631
+ dryRun: false
7632
+ });
7633
+ });
7634
+ });
7635
+ } };
7636
+ }
7637
+ /**
7638
+ * Run a command and report success/failure WITHOUT throwing — for read-only
7639
+ * "is this CLI installed/authed?" probes where a non-zero exit is data, not a
7640
+ * fatal error.
7641
+ */
7642
+ async function probe(exec, cmd, args, opts = {}) {
7643
+ try {
7644
+ return {
7645
+ ok: true,
7646
+ result: await exec.run(cmd, args, {
7647
+ ...opts,
7648
+ dryRun: false
7649
+ })
7650
+ };
7651
+ } catch {
7652
+ return { ok: false };
7653
+ }
7654
+ }
7655
+ //#endregion
7656
+ //#region src/ship/state.ts
7657
+ /**
7658
+ * `amba ship` per-app idempotency state.
7659
+ *
7660
+ * Each app's launch progress is a JSON file at `.amba/ship-state/<slug>.json`
7661
+ * recording, per phase, whether it is done / failed / blocked-by-a-human-gate /
7662
+ * in-progress, when, and an optional structured `note` (e.g. the build ids a
7663
+ * later phase reads back). The orchestrator uses it to skip already-done phases
7664
+ * and to resume the "clear a gate → re-run" loop.
7665
+ *
7666
+ * PURITY RULE: this module NEVER reads the clock itself — callers pass an
7667
+ * ISO-8601 timestamp into every mutation so it stays deterministically
7668
+ * testable (the orchestrator owns the clock). The file is purely an
7669
+ * optimization/audit log: deleting it is safe because every phase re-detects
7670
+ * real cloud state before mutating.
7671
+ */
7672
+ const STATE_DIR = path.join(".amba", "ship-state");
7673
+ /** Path to the state file for a slug, under `<baseDir>/.amba/ship-state/`. */
7674
+ function shipStatePath(slug, baseDir = process.cwd()) {
7675
+ return path.join(baseDir, STATE_DIR, `${slug}.json`);
7676
+ }
7677
+ /** Load state for a slug, or a fresh empty state if none exists. Pure read. */
7678
+ function loadShipState(slug, baseDir = process.cwd()) {
7679
+ const file = shipStatePath(slug, baseDir);
7680
+ if (!fs.existsSync(file)) return {
7681
+ slug,
7682
+ stateVersion: 1,
7683
+ phases: {}
7684
+ };
7685
+ let parsed;
7686
+ try {
7687
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
7688
+ } catch (err) {
7689
+ throw new Error(`Corrupt ship state at ${file}: ${err instanceof Error ? err.message : String(err)}. Delete it to start fresh (phases re-detect cloud state).`);
7690
+ }
7691
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Corrupt ship state at ${file}: expected a JSON object. Delete it to start fresh.`);
7692
+ const state = parsed;
7693
+ if (state.slug !== slug) throw new Error(`State file ${file} is for slug "${state.slug}", expected "${slug}".`);
7694
+ if (!state.phases || typeof state.phases !== "object") state.phases = {};
7695
+ return state;
7696
+ }
7697
+ /** Write state atomically (write temp, then rename) to avoid partial files. */
7698
+ function saveShipState(state, baseDir = process.cwd()) {
7699
+ const file = shipStatePath(state.slug, baseDir);
7700
+ fs.mkdirSync(path.dirname(file), { recursive: true });
7701
+ const tmp = `${file}.tmp`;
7702
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
7703
+ fs.renameSync(tmp, file);
7704
+ }
7705
+ /**
7706
+ * Return a NEW state with one phase updated. Pure: does not touch disk and does
7707
+ * not read the clock — the caller passes `timestamp`. Use `saveShipState` to
7708
+ * persist.
7709
+ */
7710
+ function withPhase(state, phase, status, timestamp, note) {
7711
+ return {
7712
+ ...state,
7713
+ phases: {
7714
+ ...state.phases,
7715
+ [phase]: {
7716
+ status,
7717
+ updatedAt: timestamp,
7718
+ ...note !== void 0 ? { note } : {}
7719
+ }
7720
+ }
7721
+ };
7722
+ }
7723
+ /** True if a phase is recorded as done. Used by the orchestrator for idempotency. */
7724
+ function isPhaseDone(state, phase) {
7725
+ return state.phases[phase]?.status === "done";
7726
+ }
7727
+ //#endregion
7728
+ //#region src/ship/phases.ts
7729
+ const EAS = "eas";
7730
+ const EXEC_TIMEOUT_MS = 10 * 6e4;
7731
+ function easBuildArgs(platform, profile) {
7732
+ return [
7733
+ "build",
7734
+ "--platform",
7735
+ platform,
7736
+ "--profile",
7737
+ profile,
7738
+ "--non-interactive",
7739
+ "--json",
7740
+ "--no-wait"
7741
+ ];
7742
+ }
7743
+ function easSubmitArgs(input) {
7744
+ const selector = input.buildId ? ["--id", input.buildId] : ["--latest"];
7745
+ const track = input.track ? ["--track", input.track] : [];
7746
+ return [
7747
+ "submit",
7748
+ "--platform",
7749
+ input.platform,
7750
+ "--profile",
7751
+ input.profile,
7752
+ ...selector,
7753
+ ...track,
7754
+ "--non-interactive",
7755
+ "--json",
7756
+ "--no-wait"
7757
+ ];
7758
+ }
7759
+ function easBuildViewArgs(buildId) {
7760
+ return [
7761
+ "build:view",
7762
+ buildId,
7763
+ "--json",
7764
+ "--non-interactive"
7765
+ ];
7766
+ }
7767
+ function easMetadataArgs(profile) {
7768
+ return [
7769
+ "metadata:push",
7770
+ "--profile",
7771
+ profile,
7772
+ "--non-interactive"
7773
+ ];
7774
+ }
7775
+ /** Parse the first JSON value out of CLI stdout, tolerating a leaked banner. */
7776
+ function parseLooseJson(raw) {
7777
+ const trimmed = (raw || "").trim();
7778
+ if (!trimmed) return null;
7779
+ try {
7780
+ return JSON.parse(trimmed);
7781
+ } catch {
7782
+ const start = trimmed.search(/[[{]/);
7783
+ if (start === -1) return null;
7784
+ try {
7785
+ return JSON.parse(trimmed.slice(start));
7786
+ } catch {
7787
+ return null;
7788
+ }
7789
+ }
7790
+ }
7791
+ /** Pull the build object for `platform` out of `eas build --json` stdout. */
7792
+ function extractBuild(raw, platform) {
7793
+ const parsed = parseLooseJson(raw);
7794
+ if (parsed === null) throw new Error("eas build --json produced no parseable output. Is the Expo token set and the project linked (eas init)?");
7795
+ const builds = Array.isArray(parsed) ? parsed : [parsed];
7796
+ const want = platform.toUpperCase();
7797
+ const match = builds.find((b) => (b.platform ?? "").toUpperCase() === want);
7798
+ if (match?.id) return match;
7799
+ const sole = builds.length === 1 ? builds[0] : void 0;
7800
+ if (sole?.id && (!sole.platform || sole.platform.toUpperCase() === want)) return sole;
7801
+ throw new Error(`eas build --json did not return a build id for ${platform}.`);
7802
+ }
7803
+ function artifactUrlOf(b) {
7804
+ return b.artifacts?.applicationArchiveUrl ?? b.artifacts?.buildUrl ?? null;
7805
+ }
7806
+ /** Parse the submission object for `platform` out of `eas submit --json` stdout. */
7807
+ function parseSubmission(raw, platform) {
7808
+ const parsed = parseLooseJson(raw);
7809
+ if (parsed === null) return { platform };
7810
+ const subs = Array.isArray(parsed) ? parsed : [parsed];
7811
+ const want = platform.toUpperCase();
7812
+ const match = subs.find((s) => (s.platform ?? "").toUpperCase() === want);
7813
+ if (match) return match;
7814
+ const sole = subs.length === 1 ? subs[0] : void 0;
7815
+ if (sole && (!sole.platform || sole.platform.toUpperCase() === want)) return sole;
7816
+ return { platform };
7817
+ }
7818
+ function diagnoseBuildFailure(message) {
7819
+ const o = (message || "").toLowerCase();
7820
+ if (o.includes("not found") && o.includes("eas")) return "eas CLI not installed. Install it with `npm i -g eas-cli`, then re-run.";
7821
+ if (o.includes("not logged in") || o.includes("expo_token") || o.includes("authenticate")) return "Auth: set your Expo token env var (non-interactive) or run `eas login`.";
7822
+ if (o.includes("eas.json") || o.includes("build profile")) return "Missing/invalid eas.json or the build profile. Add a build.<profile> entry to eas.json.";
7823
+ if (o.includes("project") && o.includes("id") || o.includes("eas init")) return "Project not linked. Run `eas init` once (writes extra.eas.projectId into app.json), then re-run.";
7824
+ if (o.includes("credential") || o.includes("keystore") || o.includes("distribution certificate") || o.includes("provisioning profile")) return "Missing signing credentials. Under --non-interactive EAS will not create them — run `eas credentials` once.";
7825
+ return "See the eas build prerequisites (eas.json build profile, linked project, signing credentials).";
7826
+ }
7827
+ /** A submit failure that needs a human store action, or null for a plain error. */
7828
+ function diagnoseSubmitGate(platform, message) {
7829
+ const o = (message || "").toLowerCase();
7830
+ if (platform === "ios") {
7831
+ if (o.includes("ascappid") || o.includes("app store connect") && o.includes("not found")) return "The App Store Connect app record is missing. Create it at appstoreconnect.apple.com → Apps → +, then set ios.ascAppId. EAS Submit cannot create it (eas-cli #2418).";
7832
+ if (o.includes("export compliance") || o.includes("encryption")) return "The first upload needs an export-compliance answer in App Store Connect (appstoreconnect.apple.com).";
7833
+ } else {
7834
+ if (o.includes("manually at least once") || o.includes("upload your app manually")) return "Google requires the Play app + first release to be created/published ONCE in Play Console (play.google.com/console) before API submissions work.";
7835
+ if (o.includes("service account") || o.includes("permission") || o.includes("androidpublisher")) return "The Play service account lacks access, or the Play Developer API is disabled — grant it in Play Console and enable androidpublisher.googleapis.com.";
7836
+ }
7837
+ return null;
7838
+ }
7839
+ function readNote(ctx, phase) {
7840
+ const raw = ctx.getNote(phase);
7841
+ if (!raw) return null;
7842
+ try {
7843
+ return JSON.parse(raw);
7844
+ } catch {
7845
+ return null;
7846
+ }
7847
+ }
7848
+ const TERMINAL_OK = "finished";
7849
+ const TERMINAL_BAD = new Set(["errored", "canceled"]);
7850
+ /**
7851
+ * The env to pass to every `eas` child. EAS reads the token from `EXPO_TOKEN`
7852
+ * specifically, so a config that names a DIFFERENT env var
7853
+ * (`secrets.expoTokenEnvVar`) must map that value onto `EXPO_TOKEN` — otherwise
7854
+ * a non-default name passes preflight while `eas build`/`submit` run
7855
+ * unauthenticated.
7856
+ */
7857
+ function easEnv(ctx) {
7858
+ const varName = ctx.config.secrets.expoTokenEnvVar;
7859
+ const token = ctx.env[varName];
7860
+ if (varName !== "EXPO_TOKEN" && token) return {
7861
+ ...ctx.env,
7862
+ EXPO_TOKEN: token
7863
+ };
7864
+ return ctx.env;
7865
+ }
7866
+ /** Config-derived store gates (no external call) — surfaced by preflight. */
7867
+ function storeGateWarnings(ctx) {
7868
+ const warnings = [];
7869
+ if (ctx.platforms.includes("ios") && !ctx.config.ios.ascAppId) warnings.push("ios.ascAppId is null — create the App Store Connect app record (appstoreconnect.apple.com → Apps → +) and set it before the submit phase.");
7870
+ if (ctx.platforms.includes("android")) warnings.push("Android: the Play app + first release must be created/published once in Play Console before API submissions work.");
7871
+ return warnings;
7872
+ }
7873
+ const runPreflight = async (ctx) => {
7874
+ const tokenVar = ctx.config.secrets.expoTokenEnvVar;
7875
+ if (ctx.dryRun) {
7876
+ ctx.log("would verify: eas installed + authenticated");
7877
+ const warnings = storeGateWarnings(ctx);
7878
+ for (const w of warnings) ctx.log(`warning: ${w}`);
7879
+ return {
7880
+ status: "done",
7881
+ detail: "dry-run: eas probes not executed"
7882
+ };
7883
+ }
7884
+ const warnings = [];
7885
+ if (!(await probe(ctx.exec, EAS, ["--version"])).ok) return {
7886
+ status: "failed",
7887
+ detail: "eas CLI not found on PATH. Install it with `npm i -g eas-cli`, then re-run preflight."
7888
+ };
7889
+ ctx.log("eas CLI installed");
7890
+ const tokenSet = (ctx.env[tokenVar] ?? "").trim().length > 0;
7891
+ if ((await probe(ctx.exec, EAS, ["whoami"], { env: easEnv(ctx) })).ok) ctx.log(`eas authenticated ${tokenSet ? `(via ${tokenVar})` : "(via stored CLI session)"}`);
7892
+ else if (tokenSet) ctx.log(`${tokenVar} is set (eas whoami unavailable — token used at build time)`);
7893
+ else warnings.push(`Not authenticated with EAS: set ${tokenVar} (CI) or run \`eas login\`. Build will fail without it.`);
7894
+ warnings.push(...storeGateWarnings(ctx));
7895
+ for (const w of warnings) ctx.log(`warning: ${w}`);
7896
+ return {
7897
+ status: "done",
7898
+ detail: warnings.length === 0 ? "all checks passed" : `${warnings.length} warning(s)`
7899
+ };
7900
+ };
7901
+ const runMonetization = async (ctx) => {
7902
+ if (!ctx.config.monetization.enabled) return {
7903
+ status: "skipped",
7904
+ detail: "monetization disabled in config"
7905
+ };
7906
+ const projectId = await ctx.resolveProjectId();
7907
+ if (!projectId) return {
7908
+ status: "skipped",
7909
+ detail: "no Amba project linked — run `amba init` to manage the store product catalog"
7910
+ };
7911
+ if (ctx.dryRun) return {
7912
+ status: "done",
7913
+ detail: `would verify the monetization catalog for project ${projectId}`
7914
+ };
7915
+ let plan;
7916
+ try {
7917
+ plan = (await ctx.adminGet(`/projects/${projectId}/monetization/plan`)).data;
7918
+ } catch (err) {
7919
+ if (err instanceof ApiClientError && (err.errorCode === "MONETIZATION_NOT_CONFIGURED" || err.errorCode === "MONETIZATION_NOT_MIGRATED")) return {
7920
+ status: "skipped",
7921
+ detail: "no monetization configured for this project (nothing to verify)"
7922
+ };
7923
+ return {
7924
+ status: "failed",
7925
+ detail: `could not verify monetization: ${err instanceof Error ? err.message : String(err)}. Fix API access (or set monetization.enabled=false) and re-run.`
7926
+ };
7927
+ }
7928
+ if (plan.clean) return {
7929
+ status: "done",
7930
+ detail: "store product catalog is in sync"
7931
+ };
7932
+ const gates = [];
7933
+ for (const op of plan.ops) gates.push(`${op.op} ${op.object_type} ${op.identifier}`);
7934
+ for (const sf of plan.store_floor) gates.push(`store floor: ${sf.product_key} → ${sf.store_identifier} (${sf.store}) — ${sf.reason}`);
7935
+ for (const h of plan.human_floor ?? []) gates.push(`${h.title} — ${h.detail} (${h.location})`);
7936
+ gates.push("Apply with `amba monetization apply`.");
7937
+ return {
7938
+ status: "gate",
7939
+ detail: `monetization catalog has ${plan.ops.length} pending change(s)`,
7940
+ gates
7941
+ };
7942
+ };
7943
+ const runBuild = async (ctx) => {
7944
+ const { profile } = ctx.config.build;
7945
+ const tokenVar = ctx.config.secrets.expoTokenEnvVar;
7946
+ if ((ctx.env[tokenVar] ?? "").trim().length === 0) ctx.log(`warning: ${tokenVar} not set — eas build needs it for non-interactive CI auth.`);
7947
+ const builds = { ...readNote(ctx, "build")?.builds };
7948
+ for (const platform of ctx.platforms) {
7949
+ const existing = builds[platform];
7950
+ if (existing && existing.appVersion === ctx.config.app.version && existing.profile === profile && !TERMINAL_BAD.has((existing.status || "").toLowerCase()) && !ctx.force) {
7951
+ ctx.log(`${platform}: reusing build ${existing.buildId} (${existing.status}) — pass --force to rebuild`);
7952
+ continue;
7953
+ }
7954
+ ctx.log(`${platform}: triggering eas ${profile} build…`);
7955
+ let stdout = "";
7956
+ let isDry = false;
7957
+ try {
7958
+ const res = await ctx.exec.run(EAS, easBuildArgs(platform, profile), {
7959
+ env: easEnv(ctx),
7960
+ timeoutMs: EXEC_TIMEOUT_MS,
7961
+ dryRun: ctx.dryRun
7962
+ });
7963
+ stdout = res.stdout;
7964
+ isDry = res.dryRun;
7965
+ } catch (err) {
7966
+ return {
7967
+ status: "failed",
7968
+ detail: `${platform} build failed: ${diagnoseBuildFailure(err instanceof Error ? err.message : String(err))}`,
7969
+ note: JSON.stringify({
7970
+ profile,
7971
+ appVersion: ctx.config.app.version,
7972
+ builds
7973
+ })
7974
+ };
7975
+ }
7976
+ const record = isDry ? {
7977
+ buildId: `dry-run-${platform}`,
7978
+ status: "dry-run",
7979
+ platform,
7980
+ artifactUrl: null,
7981
+ buildPageUrl: null,
7982
+ appVersion: ctx.config.app.version,
7983
+ profile,
7984
+ triggeredAt: ctx.now()
7985
+ } : (() => {
7986
+ const b = extractBuild(stdout, platform);
7987
+ return {
7988
+ buildId: b.id,
7989
+ status: b.status ?? "in-queue",
7990
+ platform,
7991
+ artifactUrl: artifactUrlOf(b),
7992
+ buildPageUrl: b.buildDetailsPageUrl ?? null,
7993
+ appVersion: b.appVersion ?? ctx.config.app.version,
7994
+ profile,
7995
+ triggeredAt: ctx.now()
7996
+ };
7997
+ })();
7998
+ builds[platform] = record;
7999
+ ctx.log(`${platform}: queued build ${record.buildId} (${record.status})`);
8000
+ ctx.checkpoint("build", "in-progress", JSON.stringify({
8001
+ profile,
8002
+ appVersion: ctx.config.app.version,
8003
+ builds
8004
+ }));
8005
+ }
8006
+ const note = {
8007
+ profile,
8008
+ appVersion: ctx.config.app.version,
8009
+ builds
8010
+ };
8011
+ return {
8012
+ status: "done",
8013
+ detail: Object.values(builds).map((r) => `${r.platform}=${r.buildId}(${r.status})`).join(" "),
8014
+ note: JSON.stringify(note)
8015
+ };
8016
+ };
8017
+ const runSubmit = async (ctx) => {
8018
+ const { profile } = ctx.config.build;
8019
+ const prior = readNote(ctx, "submit");
8020
+ const buildNote = readNote(ctx, "build");
8021
+ const submissions = { ...prior?.submissions };
8022
+ const gates = [];
8023
+ let failure = null;
8024
+ for (const platform of ctx.platforms) {
8025
+ if (platform === "ios" && !ctx.config.ios.ascAppId) {
8026
+ gates.push("iOS: create the App Store Connect app record (appstoreconnect.apple.com → Apps → +) and set ios.ascAppId, then re-run submit.");
8027
+ continue;
8028
+ }
8029
+ const buildId = buildNote?.builds?.[platform]?.buildId ?? null;
8030
+ if (!buildId) ctx.log(`${platform}: no recorded build id — submitting --latest (run the build phase first for a deterministic mapping)`);
8031
+ const existing = submissions[platform];
8032
+ if (existing && existing.buildId === buildId && !TERMINAL_BAD.has((existing.status || "").toLowerCase()) && !ctx.force) {
8033
+ ctx.log(`${platform}: already submitted for build ${buildId ?? "(latest)"} (${existing.status}) — pass --force to resubmit`);
8034
+ continue;
8035
+ }
8036
+ ctx.log(`${platform}: submitting to the store…`);
8037
+ const track = platform === "android" ? ctx.config.android.track : void 0;
8038
+ let stdout = "";
8039
+ let isDry = false;
8040
+ try {
8041
+ const res = await ctx.exec.run(EAS, easSubmitArgs({
8042
+ platform,
8043
+ profile,
8044
+ buildId,
8045
+ track
8046
+ }), {
8047
+ env: easEnv(ctx),
8048
+ timeoutMs: EXEC_TIMEOUT_MS,
8049
+ dryRun: ctx.dryRun
8050
+ });
8051
+ stdout = res.stdout;
8052
+ isDry = res.dryRun;
8053
+ } catch (err) {
8054
+ const msg = err instanceof Error ? err.message : String(err);
8055
+ const gate = diagnoseSubmitGate(platform, msg);
8056
+ if (gate) {
8057
+ gates.push(`${platform}: ${gate}`);
8058
+ continue;
8059
+ }
8060
+ failure = `${platform} submit failed: ${msg}`;
8061
+ break;
8062
+ }
8063
+ const record = isDry ? {
8064
+ submissionId: `dry-run-${platform}`,
8065
+ status: "dry-run",
8066
+ platform,
8067
+ buildId,
8068
+ detailsPageUrl: null,
8069
+ submittedAt: ctx.now()
8070
+ } : (() => {
8071
+ const s = parseSubmission(stdout, platform);
8072
+ return {
8073
+ submissionId: s.id ?? null,
8074
+ status: s.status ?? "in-queue",
8075
+ platform,
8076
+ buildId: s.buildId ?? buildId,
8077
+ detailsPageUrl: s.submissionDetailsPageUrl ?? null,
8078
+ submittedAt: ctx.now()
8079
+ };
8080
+ })();
8081
+ submissions[platform] = record;
8082
+ ctx.log(`${platform}: submitted (${record.status})`);
8083
+ ctx.checkpoint("submit", "in-progress", JSON.stringify({
8084
+ profile,
8085
+ submissions
8086
+ }));
8087
+ }
8088
+ const note = JSON.stringify({
8089
+ profile,
8090
+ submissions
8091
+ });
8092
+ if (failure) return {
8093
+ status: "failed",
8094
+ detail: failure,
8095
+ note
8096
+ };
8097
+ if (gates.length > 0) return {
8098
+ status: "gate",
8099
+ detail: `${gates.length} platform(s) need a store action`,
8100
+ note,
8101
+ gates
8102
+ };
8103
+ return {
8104
+ status: "done",
8105
+ detail: Object.values(submissions).map((r) => `${r.platform}=${r.status}`).join(" "),
8106
+ note
8107
+ };
8108
+ };
8109
+ const runMetadata = async (ctx) => {
8110
+ const reminders = [];
8111
+ if (ctx.platforms.includes("ios")) reminders.push("iOS: capture screenshots and complete the App Privacy questionnaire in App Store Connect.");
8112
+ if (ctx.platforms.includes("android")) reminders.push("Android: complete the Play listing (graphics, Data safety, Content rating) in Play Console.");
8113
+ if (ctx.platforms.includes("ios")) {
8114
+ ctx.log("pushing App Store listing metadata…");
8115
+ try {
8116
+ await ctx.exec.run(EAS, easMetadataArgs(ctx.config.build.profile), {
8117
+ env: easEnv(ctx),
8118
+ timeoutMs: EXEC_TIMEOUT_MS,
8119
+ dryRun: ctx.dryRun
8120
+ });
8121
+ ctx.log("App Store listing metadata pushed");
8122
+ } catch (err) {
8123
+ return {
8124
+ status: "gate",
8125
+ detail: `eas metadata:push did not run: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
8126
+ gates: ["Configure the App Store listing (eas.json metadata) or complete it in App Store Connect.", ...reminders]
8127
+ };
8128
+ }
8129
+ }
8130
+ for (const r of reminders) ctx.log(`reminder: ${r}`);
8131
+ return {
8132
+ status: "done",
8133
+ detail: "listing metadata pushed; complete the store-side checklist",
8134
+ gates: reminders
8135
+ };
8136
+ };
8137
+ /** Poll a build to a terminal state via `eas build:view`. Returns the last status. */
8138
+ async function waitForBuild(ctx, buildId) {
8139
+ if (ctx.dryRun) return TERMINAL_OK;
8140
+ const deadline = Date.now() + ctx.releaseMaxWaitMs;
8141
+ let last = "unknown";
8142
+ for (;;) {
8143
+ const res = await probe(ctx.exec, EAS, easBuildViewArgs(buildId), { env: easEnv(ctx) });
8144
+ if (res.ok && res.result) {
8145
+ if (res.result.dryRun) return TERMINAL_OK;
8146
+ const parsed = parseLooseJson(res.result.stdout);
8147
+ last = ((Array.isArray(parsed) ? parsed[0] : parsed)?.status ?? last ?? "unknown").toLowerCase();
8148
+ }
8149
+ if (last === TERMINAL_OK || TERMINAL_BAD.has(last)) return last;
8150
+ if (Date.now() >= deadline) return last;
8151
+ ctx.log(`build ${buildId}: ${last} — re-checking…`);
8152
+ await ctx.sleep(ctx.releasePollMs);
8153
+ }
8154
+ }
8155
+ const runRelease = async (ctx) => {
8156
+ const profile = ctx.config.build.profile;
8157
+ const prior = readNote(ctx, "release");
8158
+ const buildNote = readNote(ctx, "build");
8159
+ const submitNote = readNote(ctx, "submit");
8160
+ const releases = { ...prior?.releases };
8161
+ const gates = [];
8162
+ let failure = null;
8163
+ for (const platform of ctx.platforms) {
8164
+ const buildRec = buildNote?.builds?.[platform];
8165
+ if (!buildRec?.buildId) {
8166
+ gates.push(`${platform}: no recorded build — run \`amba ship --phase build\` first.`);
8167
+ releases[platform] = {
8168
+ platform,
8169
+ buildId: null,
8170
+ buildStatus: "unknown",
8171
+ releaseState: "pending-build",
8172
+ gate: true,
8173
+ detail: "No recorded build."
8174
+ };
8175
+ continue;
8176
+ }
8177
+ ctx.log(`${platform}: waiting for build ${buildRec.buildId} to finish…`);
8178
+ const buildStatus = await waitForBuild(ctx, buildRec.buildId);
8179
+ if (TERMINAL_BAD.has(buildStatus)) {
8180
+ failure = `${platform}: build ${buildRec.buildId} is ${buildStatus}; re-run the build phase.`;
8181
+ releases[platform] = {
8182
+ platform,
8183
+ buildId: buildRec.buildId,
8184
+ buildStatus,
8185
+ releaseState: "blocked",
8186
+ gate: false,
8187
+ detail: failure
8188
+ };
8189
+ break;
8190
+ }
8191
+ if (buildStatus !== TERMINAL_OK) {
8192
+ gates.push(`${platform}: build ${buildRec.buildId} still ${buildStatus} — re-run release once it finishes.`);
8193
+ releases[platform] = {
8194
+ platform,
8195
+ buildId: buildRec.buildId,
8196
+ buildStatus,
8197
+ releaseState: "pending-build",
8198
+ gate: true,
8199
+ detail: `Build still ${buildStatus}.`
8200
+ };
8201
+ continue;
8202
+ }
8203
+ if (platform === "ios") {
8204
+ gates.push("iOS: in App Store Connect, attach the build to a version, then \"Add for Review\" → \"Submit for Review\" (appstoreconnect.apple.com).");
8205
+ releases[platform] = {
8206
+ platform,
8207
+ buildId: buildRec.buildId,
8208
+ buildStatus,
8209
+ releaseState: "in-testflight",
8210
+ gate: true,
8211
+ detail: "Uploaded to App Store Connect / TestFlight; Submit-for-Review is manual."
8212
+ };
8213
+ continue;
8214
+ }
8215
+ const priorAndroid = releases[platform];
8216
+ if (priorAndroid && priorAndroid.buildId === buildRec.buildId && priorAndroid.releaseState === "promoted-to-production" && !ctx.force) {
8217
+ ctx.log("android: build already promoted to production — pass --force to re-promote");
8218
+ continue;
8219
+ }
8220
+ if (ctx.config.android.track === "production") {
8221
+ if (!submitNote?.submissions?.["android"]) {
8222
+ gates.push("android: nothing submitted to Play yet — run `amba ship --phase submit` first (your track is \"production\").");
8223
+ releases[platform] = {
8224
+ platform,
8225
+ buildId: buildRec.buildId,
8226
+ buildStatus,
8227
+ releaseState: "blocked",
8228
+ gate: true,
8229
+ detail: "No submission recorded; run the submit phase before release."
8230
+ };
8231
+ continue;
8232
+ }
8233
+ releases[platform] = {
8234
+ platform,
8235
+ buildId: buildRec.buildId,
8236
+ buildStatus,
8237
+ releaseState: "promoted-to-production",
8238
+ gate: false,
8239
+ detail: "Submitted directly to the production track."
8240
+ };
8241
+ ctx.log("android: already on the production track");
8242
+ continue;
8243
+ }
8244
+ ctx.log(`android: promoting build ${buildRec.buildId} to the production track…`);
8245
+ try {
8246
+ await ctx.exec.run(EAS, easSubmitArgs({
8247
+ platform: "android",
8248
+ profile,
8249
+ buildId: buildRec.buildId,
8250
+ track: "production"
8251
+ }), {
8252
+ env: easEnv(ctx),
8253
+ timeoutMs: EXEC_TIMEOUT_MS,
8254
+ dryRun: ctx.dryRun
8255
+ });
8256
+ releases[platform] = {
8257
+ platform,
8258
+ buildId: buildRec.buildId,
8259
+ buildStatus,
8260
+ releaseState: "promoted-to-production",
8261
+ gate: false,
8262
+ detail: "Promoted internal → production via eas submit."
8263
+ };
8264
+ ctx.log("android: promotion to production queued");
8265
+ } catch (err) {
8266
+ const msg = err instanceof Error ? err.message : String(err);
8267
+ const gate = diagnoseSubmitGate("android", msg);
8268
+ releases[platform] = {
8269
+ platform,
8270
+ buildId: buildRec.buildId,
8271
+ buildStatus,
8272
+ releaseState: "blocked",
8273
+ gate: gate !== null,
8274
+ detail: gate ?? msg
8275
+ };
8276
+ if (gate) gates.push(`android: ${gate}`);
8277
+ else failure = `android promotion failed: ${msg}`;
8278
+ if (failure) break;
8279
+ }
6214
8280
  }
6215
- if (slot3 === "nullable") {
6216
- out.nullable = true;
6217
- if (slot4 !== void 0) throw new Error(`Invalid trailing token '${slot4}' on column '${colName}'. 'nullable' must be the last segment for plain fields.`);
6218
- return out;
8281
+ const note = JSON.stringify({
8282
+ profile,
8283
+ releases
8284
+ });
8285
+ if (failure) return {
8286
+ status: "failed",
8287
+ detail: failure,
8288
+ note
8289
+ };
8290
+ if (gates.length > 0) return {
8291
+ status: "gate",
8292
+ detail: `${gates.length} platform(s) need a store action`,
8293
+ note,
8294
+ gates
8295
+ };
8296
+ return {
8297
+ status: "done",
8298
+ detail: Object.values(releases).map((r) => `${r.platform}=${r.releaseState}`).join(" "),
8299
+ note
8300
+ };
8301
+ };
8302
+ const PHASE_RUNNERS = {
8303
+ preflight: runPreflight,
8304
+ monetization: runMonetization,
8305
+ build: runBuild,
8306
+ submit: runSubmit,
8307
+ metadata: runMetadata,
8308
+ release: runRelease
8309
+ };
8310
+ //#endregion
8311
+ //#region src/ship/orchestrator.ts
8312
+ /** Phases that ship a specific binary version (invalidated on a version bump). */
8313
+ const VERSION_SCOPED_PHASES = [
8314
+ "build",
8315
+ "submit",
8316
+ "metadata",
8317
+ "release"
8318
+ ];
8319
+ /** The canonical run order, filtered to the requested subset. */
8320
+ function orderedPhases(requested) {
8321
+ const want = new Set(requested);
8322
+ return SHIP_PHASES.filter((p) => want.has(p));
8323
+ }
8324
+ async function defaultResolveProjectId() {
8325
+ try {
8326
+ const { projectId } = await loadProjectConfig();
8327
+ return projectId;
8328
+ } catch {
8329
+ return null;
6219
8330
  }
6220
- if (slot3.startsWith("fk(") && slot3.endsWith(")")) {
6221
- out.references = parseFkSpec(slot3.slice(3, -1).replace(/\|/g, ":"));
6222
- if (slot4 !== void 0) {
6223
- if (slot4 !== "nullable") throw new Error(`Invalid trailing token '${slot4}' on column '${colName}'. Only 'nullable' is allowed after fk(...).`);
6224
- out.nullable = true;
8331
+ }
8332
+ async function runShip(opts) {
8333
+ const slug = opts.config.app.slug;
8334
+ const baseDir = opts.stateBaseDir ?? process.cwd();
8335
+ const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
8336
+ const log = opts.log ?? (() => {});
8337
+ const runners = opts.runners ?? PHASE_RUNNERS;
8338
+ let state = loadShipState(slug, baseDir);
8339
+ if (state.appVersion !== void 0 && state.appVersion !== opts.config.app.version) {
8340
+ const phases = { ...state.phases };
8341
+ for (const p of VERSION_SCOPED_PHASES) delete phases[p];
8342
+ state = {
8343
+ ...state,
8344
+ phases
8345
+ };
8346
+ }
8347
+ state = {
8348
+ ...state,
8349
+ appVersion: opts.config.app.version
8350
+ };
8351
+ const persist = (phase, status, note) => {
8352
+ state = withPhase(state, phase, status, now(), note);
8353
+ if (!opts.dryRun) saveShipState(state, baseDir);
8354
+ };
8355
+ const ctx = {
8356
+ config: opts.config,
8357
+ exec: opts.exec,
8358
+ platforms: opts.platforms,
8359
+ dryRun: opts.dryRun,
8360
+ force: opts.force,
8361
+ env: opts.env ?? process.env,
8362
+ now,
8363
+ log,
8364
+ getNote: (phase) => state.phases[phase]?.note ?? null,
8365
+ checkpoint: (phase, status, note) => persist(phase, status, note),
8366
+ resolveProjectId: opts.resolveProjectId ?? defaultResolveProjectId,
8367
+ adminGet: opts.adminGet ?? adminGet,
8368
+ sleep: opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
8369
+ releasePollMs: opts.releasePollMs ?? 3e4,
8370
+ releaseMaxWaitMs: opts.releaseMaxWaitMs ?? 45 * 6e4
8371
+ };
8372
+ const rows = [];
8373
+ let blocked = false;
8374
+ let failed = false;
8375
+ for (const phase of orderedPhases(opts.phases)) {
8376
+ if (!opts.force && isPhaseDone(state, phase)) {
8377
+ const rec = state.phases[phase];
8378
+ rows.push({
8379
+ phase,
8380
+ status: "skipped",
8381
+ detail: `already done ${rec?.updatedAt ?? ""}`.trim()
8382
+ });
8383
+ continue;
8384
+ }
8385
+ if ((blocked || failed) && !opts.dryRun) {
8386
+ rows.push({
8387
+ phase,
8388
+ status: "pending",
8389
+ detail: "not reached (earlier phase needs action)"
8390
+ });
8391
+ continue;
8392
+ }
8393
+ const outcome = await runners[phase](ctx);
8394
+ if (opts.dryRun) {
8395
+ const status = outcome.status === "done" ? "dry-run" : outcome.status;
8396
+ rows.push({
8397
+ phase,
8398
+ status,
8399
+ detail: outcome.detail,
8400
+ ...outcome.gates ? { gates: outcome.gates } : {}
8401
+ });
8402
+ if (outcome.status === "failed") failed = true;
8403
+ continue;
8404
+ }
8405
+ switch (outcome.status) {
8406
+ case "done":
8407
+ persist(phase, "done", outcome.note);
8408
+ rows.push({
8409
+ phase,
8410
+ status: "done",
8411
+ detail: outcome.detail,
8412
+ ...outcome.gates ? { gates: outcome.gates } : {}
8413
+ });
8414
+ break;
8415
+ case "skipped":
8416
+ rows.push({
8417
+ phase,
8418
+ status: "skipped",
8419
+ detail: outcome.detail
8420
+ });
8421
+ break;
8422
+ case "gate":
8423
+ persist(phase, "gate", outcome.note);
8424
+ rows.push({
8425
+ phase,
8426
+ status: "gate",
8427
+ detail: outcome.detail,
8428
+ ...outcome.gates ? { gates: outcome.gates } : {}
8429
+ });
8430
+ blocked = true;
8431
+ break;
8432
+ case "failed":
8433
+ persist(phase, "failed", outcome.note);
8434
+ rows.push({
8435
+ phase,
8436
+ status: "failed",
8437
+ detail: outcome.detail
8438
+ });
8439
+ failed = true;
8440
+ break;
6225
8441
  }
6226
- return out;
6227
8442
  }
6228
- throw new Error(`Invalid third segment '${slot3}' on column '${colName}'. Expected 'nullable', fk(...), or — for vector columns — a positive integer dimension.`);
8443
+ return {
8444
+ rows,
8445
+ blocked,
8446
+ failed
8447
+ };
6229
8448
  }
8449
+ //#endregion
8450
+ //#region src/commands/ship.ts
6230
8451
  /**
6231
- * Parse the `fk(...)` body into the API's `references` shape.
6232
- * Supported inner grammars:
6233
- * - `<table>` → `{ table }` (FK to `<table>(id)`, no ON DELETE clause)
6234
- * - `<table>.<column>` → `{ table, column }`
6235
- * - `<table>:<onDelete>` → `{ table, onDelete }`
6236
- * - `<table>.<column>:<onDelete>` → `{ table, column, onDelete }`
6237
- *
6238
- * `onDelete` accepts case-insensitive `cascade | restrict | set_null | no_action`
6239
- * (and the `set null` / `no action` SQL spellings) — translated to the
6240
- * canonical uppercased form the API expects.
8452
+ * `amba ship` — take this Expo app from code-complete to live on the App Store
8453
+ * and Google Play, and `amba ship init` to scaffold its config.
8454
+ *
8455
+ * amba ship run the full pipeline (preflight → monetization →
8456
+ * build → submit → metadata → release)
8457
+ * amba ship --phase build run a single phase
8458
+ * amba ship --dry-run print the plan; touch nothing
8459
+ * amba ship init scaffold amba.ship.json
8460
+ *
8461
+ * Build/submit/release shell out to `eas` (the Expo toolchain owns the build,
8462
+ * the signing credentials, and the store submit). Monetization delegates to the
8463
+ * Amba control plane (`amba monetization`). Steps no API can do — create the
8464
+ * store app record, the Apple Paid-Apps agreement, screenshots, store review —
8465
+ * are detected and surfaced as manual gates with the exact console action.
6241
8466
  */
6242
- function parseFkSpec(inner) {
6243
- const [tablePart, onDelete] = inner.includes(":") ? inner.split(":") : [inner, void 0];
6244
- const trimmedTable = tablePart.trim();
6245
- if (trimmedTable.length === 0) throw new Error(`Invalid fk(...) — table name is required.`);
6246
- const dotIdx = trimmedTable.indexOf(".");
6247
- let table;
6248
- let column;
6249
- if (dotIdx === -1) table = trimmedTable;
6250
- else {
6251
- table = trimmedTable.slice(0, dotIdx);
6252
- column = trimmedTable.slice(dotIdx + 1);
8467
+ /** Read + parse + validate a ship config file. Throws with every error joined. */
8468
+ async function loadShipConfig(file) {
8469
+ const path = resolve(file);
8470
+ let raw;
8471
+ try {
8472
+ raw = await readFile(path, "utf-8");
8473
+ } catch {
8474
+ throw new Error(`Config not found: ${file}. Run ${pc.cyan("amba ship init")} to scaffold one.`);
6253
8475
  }
6254
- if (!/^[a-z_][a-z0-9_]*$/.test(table)) throw new Error(`Invalid fk table name '${table}'.`);
6255
- if (column !== void 0 && !/^[a-z_][a-z0-9_]*$/.test(column)) throw new Error(`Invalid fk column name '${column}'.`);
6256
- const out = { table };
6257
- if (column !== void 0) out.column = column;
6258
- if (onDelete !== void 0) out.onDelete = normalizeOnDelete(onDelete);
6259
- return out;
8476
+ let parsed;
8477
+ try {
8478
+ parsed = JSON.parse(raw);
8479
+ } catch (err) {
8480
+ throw new Error(`Failed to parse ${file} as JSON: ${err instanceof Error ? err.message : String(err)}`);
8481
+ }
8482
+ const result = validateShipConfig(parsed);
8483
+ if (!result.ok) throw new Error(`Invalid ${file}:\n${result.errors.map((e) => ` • ${e}`).join("\n")}`);
8484
+ return result.config;
8485
+ }
8486
+ function parseShipPhase(raw) {
8487
+ if (raw === void 0 || raw === "all") return "all";
8488
+ if (SHIP_PHASES.includes(raw)) return raw;
8489
+ throw new Error(`Unknown --phase "${raw}". Expected one of: ${SHIP_PHASES.join(", ")}, all.`);
8490
+ }
8491
+ /** Resolve the platforms to act on: --platform override, else the config's. */
8492
+ function resolvePlatforms(raw, configPlatforms) {
8493
+ if (raw === void 0 || raw === "all") return configPlatforms;
8494
+ if (!SHIP_PLATFORMS.includes(raw)) throw new Error(`Unknown --platform "${raw}". Expected one of: ${SHIP_PLATFORMS.join(", ")}, all.`);
8495
+ const platform = raw;
8496
+ if (!configPlatforms.includes(platform)) throw new Error(`Platform "${platform}" is not in build.platforms (${configPlatforms.join(", ")}).`);
8497
+ return [platform];
8498
+ }
8499
+ const STATUS_BADGE = {
8500
+ done: pc.green("done"),
8501
+ "dry-run": pc.cyan("dry-run"),
8502
+ skipped: pc.dim("skipped"),
8503
+ gate: pc.yellow("GATE"),
8504
+ failed: pc.red("failed"),
8505
+ pending: pc.dim("pending")
8506
+ };
8507
+ function renderTable(rows) {
8508
+ console.log();
8509
+ for (const row of rows) console.log(` ${row.phase.padEnd(13)} ${STATUS_BADGE[row.status].padEnd(18)} ${pc.dim(row.detail)}`);
8510
+ console.log();
6260
8511
  }
6261
- function normalizeOnDelete(raw) {
6262
- switch (raw.trim().toLowerCase().replace(/_/g, " ")) {
6263
- case "cascade": return "CASCADE";
6264
- case "restrict": return "RESTRICT";
6265
- case "set null": return "SET NULL";
6266
- case "no action": return "NO ACTION";
6267
- default: throw new Error(`Invalid onDelete '${raw}' in fk(...). Supported: cascade | restrict | set_null | no_action.`);
8512
+ function renderGates(rows) {
8513
+ const gateRows = rows.filter((r) => r.gates && r.gates.length > 0);
8514
+ if (gateRows.length === 0) return;
8515
+ console.log(pc.yellow(" Manual gates — steps no API can do (with exact locations):"));
8516
+ for (const row of gateRows) for (const gate of row.gates ?? []) console.log(` ${pc.bold("□")} ${pc.dim(`[${row.phase}]`)} ${gate}`);
8517
+ console.log();
8518
+ }
8519
+ async function shipCommand(options = {}, deps = {}) {
8520
+ const config = await loadShipConfig(options.config ?? "amba.ship.json");
8521
+ const phase = parseShipPhase(options.phase);
8522
+ const platforms = resolvePlatforms(options.platform, config.build.platforms);
8523
+ const dryRun = options.dryRun === true;
8524
+ const phases = phase === "all" ? [...SHIP_PHASES] : [phase];
8525
+ console.log();
8526
+ console.log(pc.bold(` amba ship ${pc.cyan(config.app.name)} ${pc.dim(`v${config.app.version}`)}`));
8527
+ console.log(pc.dim(" ─────────────────────────────────"));
8528
+ console.log(pc.dim(` phase=${phase} · platforms=${platforms.join(",")}${dryRun ? " · DRY-RUN" : ""}${options.force ? " · FORCE" : ""}`));
8529
+ const result = await runShip({
8530
+ config,
8531
+ phases,
8532
+ platforms,
8533
+ exec: deps.exec ?? createNodeExecutor(dryRun),
8534
+ dryRun,
8535
+ force: options.force === true,
8536
+ log: (line) => console.log(pc.dim(` ${line}`)),
8537
+ ...deps.stateBaseDir !== void 0 ? { stateBaseDir: deps.stateBaseDir } : {},
8538
+ ...deps.resolveProjectId ? { resolveProjectId: deps.resolveProjectId } : {},
8539
+ ...deps.adminGet ? { adminGet: deps.adminGet } : {},
8540
+ ...deps.now ? { now: deps.now } : {},
8541
+ ...deps.sleep ? { sleep: deps.sleep } : {},
8542
+ ...deps.releasePollMs !== void 0 ? { releasePollMs: deps.releasePollMs } : {},
8543
+ ...deps.releaseMaxWaitMs !== void 0 ? { releaseMaxWaitMs: deps.releaseMaxWaitMs } : {}
8544
+ });
8545
+ renderTable(result.rows);
8546
+ renderGates(result.rows);
8547
+ if (result.failed) {
8548
+ const bad = result.rows.find((r) => r.status === "failed");
8549
+ throw new Error(`Launch failed${bad ? ` at ${bad.phase}` : ""}: ${bad?.detail ?? "see above"}`);
8550
+ }
8551
+ if (dryRun) {
8552
+ console.log(pc.green(" ✓") + " Dry-run complete — nothing was changed.");
8553
+ console.log();
8554
+ return;
8555
+ }
8556
+ if (result.blocked) {
8557
+ const gates = result.rows.filter((r) => r.status === "gate").length;
8558
+ console.log(pc.yellow(" ⏸ Launch paused") + pc.dim(` — ${gates} gate(s) need a manual store action (above). Clear them, then re-run `) + pc.cyan("amba ship") + pc.dim("."));
8559
+ console.log();
8560
+ return;
6268
8561
  }
8562
+ console.log(pc.green(" ✓") + " Launch run complete.");
8563
+ console.log();
6269
8564
  }
6270
- /**
6271
- * Parse an index spec into the wire shape data's API expects.
6272
- *
6273
- * Input syntax (CLI-friendly): `"col1 [asc|desc], col2 [asc|desc]"`.
6274
- * Wire shape (per data's contract): `{ columns: ['col1 desc', 'col2 asc'] }` —
6275
- * direction is embedded in each column string and parsed by the DDL emit.
6276
- *
6277
- * Bare column names (no direction) are passed through verbatim; the
6278
- * default sort direction is the DDL emit's responsibility.
6279
- */
6280
- function parseIndexSpec(spec) {
6281
- const cols = [];
6282
- for (const part of spec.split(",")) {
6283
- const trimmed = part.trim();
6284
- if (!trimmed) throw new Error(`Invalid index spec '${spec}'.`);
6285
- const m = /^([a-zA-Z_][a-zA-Z0-9_]*)(?:\s+(asc|desc))?$/i.exec(trimmed);
6286
- if (!m) throw new Error(`Invalid index column '${trimmed}'. Expected '<col>' or '<col> asc|desc'.`);
6287
- const colName = m[1];
6288
- const direction = m[2]?.toLowerCase();
6289
- cols.push(direction ? `${colName} ${direction}` : colName);
8565
+ async function shipInitCommand(options = {}) {
8566
+ const file = options.config ?? "amba.ship.json";
8567
+ const path = resolve(file);
8568
+ if (!options.force) {
8569
+ if (await access(path).then(() => true, () => false)) throw new Error(`${file} already exists. Pass ${pc.cyan("--force")} to overwrite it.`);
6290
8570
  }
6291
- return { columns: cols };
8571
+ const template = shipConfigTemplate((options.slug ?? "my-app").toLowerCase());
8572
+ await writeFile(path, JSON.stringify(template, null, 2) + "\n", "utf-8");
8573
+ console.log();
8574
+ console.log(pc.green(" ✓") + ` Wrote ${pc.cyan(file)}.`);
8575
+ console.log(pc.dim(" Edit the app IDs, then ") + pc.cyan("amba ship --phase preflight") + pc.dim(" to check your setup."));
8576
+ console.log();
6292
8577
  }
6293
8578
  //#endregion
6294
8579
  //#region src/_internal/codegen.ts
@@ -6334,8 +8619,22 @@ const PG_TYPE_TO_TS = {
6334
8619
  jsonb: "unknown",
6335
8620
  json: "unknown"
6336
8621
  };
8622
+ const ARRAY_UDT_TO_TS = {
8623
+ _text: "string",
8624
+ _varchar: "string",
8625
+ _uuid: "string",
8626
+ _int4: "number",
8627
+ _int8: "number",
8628
+ _int2: "number",
8629
+ _numeric: "number",
8630
+ _float8: "number",
8631
+ _float4: "number",
8632
+ _bool: "boolean"
8633
+ };
6337
8634
  function tsTypeForColumn(col) {
6338
- const base = PG_TYPE_TO_TS[col.data_type] ?? "unknown";
8635
+ let base;
8636
+ if (col.data_type === "ARRAY") base = `${col.udt_name ? ARRAY_UDT_TO_TS[col.udt_name] ?? "unknown" : "unknown"}[]`;
8637
+ else base = PG_TYPE_TO_TS[col.data_type] ?? "unknown";
6339
8638
  if (col.column_name === "deleted_at") return "string | null";
6340
8639
  if (col.is_nullable === "YES") return `${base} | null`;
6341
8640
  return base;
@@ -6885,9 +9184,351 @@ async function domainsListCommand() {
6885
9184
  console.log();
6886
9185
  }
6887
9186
  //#endregion
9187
+ //#region src/commands/iac.ts
9188
+ /**
9189
+ * `amba apply` / `amba diff` / `amba export` — declarative
9190
+ * infrastructure-as-code for a project's reusable configuration.
9191
+ *
9192
+ * A declarative bundle (default `amba.config.json`) captures the entities a
9193
+ * developer hand-builds once and wants to stand up identically elsewhere:
9194
+ * remote configs, collection schemas, content libraries + items, currencies,
9195
+ * achievements, streaks, challenges, leaderboards, and xp_rules. It carries
9196
+ * CONFIGURATION ONLY — no secrets, no per-user data.
9197
+ *
9198
+ * These commands wrap the platform's promotion endpoints:
9199
+ * GET /v1/admin/projects/:p/promotion/export
9200
+ * POST /v1/admin/projects/:p/promotion/import (mode=skip_existing|merge)
9201
+ *
9202
+ * - `amba export [file]` writes the current project's bundle to disk.
9203
+ * - `amba apply [file]` POSTs the local bundle to the import endpoint and
9204
+ * prints a `{ created, updated, skipped }` summary
9205
+ * per section (the server's authoritative result).
9206
+ * - `amba diff [file]` shows what `apply` WOULD change without writing.
9207
+ * The import route has NO server-side dry-run, so
9208
+ * `diff` exports the current project and computes the
9209
+ * created/updated/skipped per section LOCALLY.
9210
+ */
9211
+ /** Default declarative bundle filename when no `[file]` is passed. */
9212
+ const DEFAULT_BUNDLE_FILE = "amba.config.json";
9213
+ /** The ordered list of bundle sections + their natural-key field. */
9214
+ const BUNDLE_SECTIONS = [
9215
+ {
9216
+ key: "remote_configs",
9217
+ naturalKey: "key"
9218
+ },
9219
+ {
9220
+ key: "collections",
9221
+ naturalKey: "name"
9222
+ },
9223
+ {
9224
+ key: "currencies",
9225
+ naturalKey: "code"
9226
+ },
9227
+ {
9228
+ key: "achievements",
9229
+ naturalKey: "key"
9230
+ },
9231
+ {
9232
+ key: "streaks",
9233
+ naturalKey: "key"
9234
+ },
9235
+ {
9236
+ key: "leaderboards",
9237
+ naturalKey: "name"
9238
+ },
9239
+ {
9240
+ key: "xp_rules",
9241
+ naturalKey: "name"
9242
+ },
9243
+ {
9244
+ key: "challenges",
9245
+ naturalKey: "name"
9246
+ },
9247
+ {
9248
+ key: "content_libraries",
9249
+ naturalKey: "name"
9250
+ },
9251
+ {
9252
+ key: "monetization",
9253
+ naturalKey: "key"
9254
+ }
9255
+ ];
9256
+ /**
9257
+ * Read + parse a declarative bundle from disk. JSON only — we deliberately
9258
+ * don't pull in a YAML dependency the CLI doesn't already ship (the package
9259
+ * has no YAML lib), so `.yaml`/`.yml` files are rejected with a clear hint
9260
+ * rather than silently mis-parsed.
9261
+ */
9262
+ async function readBundleFile(file) {
9263
+ const path = resolve(file);
9264
+ if (/\.ya?ml$/i.test(path)) throw new Error(`YAML bundles are not supported. Convert ${file} to JSON (the bundle format is plain JSON).`);
9265
+ let raw;
9266
+ try {
9267
+ raw = await readFile(path, "utf-8");
9268
+ } catch {
9269
+ throw new Error(`Bundle file not found: ${file}. Run \`amba export\` to scaffold one from your current project.`);
9270
+ }
9271
+ let parsed;
9272
+ try {
9273
+ parsed = JSON.parse(raw);
9274
+ } catch (err) {
9275
+ throw new Error(`Failed to parse ${file} as JSON: ${err instanceof Error ? err.message : String(err)}`);
9276
+ }
9277
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Bundle in ${file} must be a JSON object with a \`version\` field.`);
9278
+ const bundle = parsed;
9279
+ if (typeof bundle["version"] !== "number") throw new Error(`Bundle in ${file} is missing a numeric \`version\` field. Re-export it with \`amba export\`.`);
9280
+ return bundle;
9281
+ }
9282
+ /**
9283
+ * Index a section array by its natural key. Definitions missing the key (or
9284
+ * non-object entries) are skipped — they can't be matched and the server
9285
+ * will reject them on apply.
9286
+ */
9287
+ function indexByNaturalKey(items, naturalKey) {
9288
+ const out = /* @__PURE__ */ new Map();
9289
+ if (!Array.isArray(items)) return out;
9290
+ for (const item of items) {
9291
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
9292
+ const rec = item;
9293
+ const k = rec[naturalKey];
9294
+ if (typeof k !== "string" && typeof k !== "number") continue;
9295
+ out.set(String(k), rec);
9296
+ }
9297
+ return out;
9298
+ }
9299
+ /**
9300
+ * Pure section-by-section diff of a desired bundle against the current
9301
+ * project's exported bundle. The classification MIRRORS the server's import
9302
+ * (`apps/api/src/lib/promotion-bundle.ts`) so `amba diff` agrees with what
9303
+ * `amba apply` will report:
9304
+ * - created — natural key absent in current.
9305
+ * - updated — natural key present AND `mode === 'merge'`. The server runs an
9306
+ * UNCONDITIONAL update for every existing key in merge mode and
9307
+ * increments `updated` — it does NOT compare field values, so we
9308
+ * don't either (a local field comparison made `diff` under-report).
9309
+ * - skipped — natural key present under `skip_existing`; OR an existing
9310
+ * collection (always — collections are create-if-absent, never
9311
+ * merged); OR a `remote_configs` row flagged `is_system: true`
9312
+ * (the server always skips those before any existence check); OR
9313
+ * a desired row with no valid natural key (can't be matched
9314
+ * against the export, and the server import dedups by that key —
9315
+ * so it is never a reliable create).
9316
+ *
9317
+ * Within one bundle section, the server walks rows sequentially and each
9318
+ * insert mutates the DB, so a SECOND row sharing a natural key already created
9319
+ * by an EARLIER row in the same section is seen as existing (→ skip under
9320
+ * skip_existing, update under merge) rather than created a second time. We
9321
+ * track already-seen keys per section to mirror that, so duplicate-keyed local
9322
+ * rows don't inflate the `created` total versus what `apply` does.
9323
+ */
9324
+ function computeLocalDiff(desired, current, mode) {
9325
+ const diff = {};
9326
+ for (const { key, naturalKey } of BUNDLE_SECTIONS) {
9327
+ const desiredItems = desired[key];
9328
+ if (!Array.isArray(desiredItems) || desiredItems.length === 0) continue;
9329
+ const currentIndex = indexByNaturalKey(current[key], naturalKey);
9330
+ const section = {
9331
+ created: 0,
9332
+ updated: 0,
9333
+ skipped: 0
9334
+ };
9335
+ const seen = /* @__PURE__ */ new Set();
9336
+ for (const item of desiredItems) {
9337
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
9338
+ const rec = item;
9339
+ if (key === "remote_configs" && rec["is_system"] === true) {
9340
+ section.skipped++;
9341
+ continue;
9342
+ }
9343
+ const k = rec[naturalKey];
9344
+ const matchKey = typeof k === "string" || typeof k === "number" ? String(k) : null;
9345
+ if (matchKey === null) {
9346
+ section.skipped++;
9347
+ continue;
9348
+ }
9349
+ const existing = currentIndex.has(matchKey) || seen.has(matchKey);
9350
+ seen.add(matchKey);
9351
+ if (!existing) {
9352
+ section.created++;
9353
+ continue;
9354
+ }
9355
+ if (key === "collections" || mode === "skip_existing") {
9356
+ section.skipped++;
9357
+ continue;
9358
+ }
9359
+ section.updated++;
9360
+ }
9361
+ diff[key] = section;
9362
+ }
9363
+ const monDiff = computeMonetizationLocalDiff(desired["monetization"], current["monetization"], mode);
9364
+ if (monDiff) diff["monetization"] = monDiff;
9365
+ return diff;
9366
+ }
9367
+ /**
9368
+ * Natural keys per monetization sub-collection. All key on `key` except the
9369
+ * package join, which is unique within an offering — but for diff purposes the
9370
+ * package counts roll into their owning offering, so only the four top-level
9371
+ * collections are counted here.
9372
+ */
9373
+ const MONETIZATION_SUBSECTIONS = [
9374
+ "entitlements",
9375
+ "products",
9376
+ "offerings",
9377
+ "paywalls"
9378
+ ];
9379
+ function computeMonetizationLocalDiff(desired, current, mode) {
9380
+ if (desired === null || typeof desired !== "object" || Array.isArray(desired)) return null;
9381
+ const desiredObj = desired;
9382
+ const currentObj = current !== null && typeof current === "object" && !Array.isArray(current) ? current : {};
9383
+ const section = {
9384
+ created: 0,
9385
+ updated: 0,
9386
+ skipped: 0
9387
+ };
9388
+ let any = false;
9389
+ for (const sub of MONETIZATION_SUBSECTIONS) {
9390
+ const desiredItems = desiredObj[sub];
9391
+ if (!Array.isArray(desiredItems) || desiredItems.length === 0) continue;
9392
+ any = true;
9393
+ const currentIndex = indexByNaturalKey(currentObj[sub], "key");
9394
+ const seen = /* @__PURE__ */ new Set();
9395
+ for (const item of desiredItems) {
9396
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
9397
+ const k = item["key"];
9398
+ const matchKey = typeof k === "string" || typeof k === "number" ? String(k) : null;
9399
+ if (matchKey === null) {
9400
+ section.skipped++;
9401
+ continue;
9402
+ }
9403
+ const existing = currentIndex.has(matchKey) || seen.has(matchKey);
9404
+ seen.add(matchKey);
9405
+ if (!existing) section.created++;
9406
+ else if (mode === "skip_existing") section.skipped++;
9407
+ else section.updated++;
9408
+ }
9409
+ }
9410
+ return any ? section : null;
9411
+ }
9412
+ /** Roll a per-section diff up into grand totals across every section. */
9413
+ function summarizeDiff(diff) {
9414
+ return Object.values(diff).reduce((acc, s) => ({
9415
+ created: acc.created + s.created,
9416
+ updated: acc.updated + s.updated,
9417
+ skipped: acc.skipped + s.skipped
9418
+ }), {
9419
+ created: 0,
9420
+ updated: 0,
9421
+ skipped: 0
9422
+ });
9423
+ }
9424
+ function renderSummary(summary, verb) {
9425
+ let created = 0;
9426
+ let updated = 0;
9427
+ let skipped = 0;
9428
+ const sections = Object.keys(summary);
9429
+ if (sections.length === 0) console.log(pc.dim(" Nothing to " + verb + " — bundle is empty."));
9430
+ for (const section of sections) {
9431
+ const s = summary[section];
9432
+ created += s.created;
9433
+ updated += s.updated;
9434
+ skipped += s.skipped;
9435
+ console.log(` ${pc.bold(section)} ${pc.green(`${s.created} created`)} · ${pc.cyan(`${s.updated} updated`)} · ` + pc.dim(`${s.skipped} skipped`));
9436
+ }
9437
+ console.log();
9438
+ console.log(pc.dim(` Total: ${created} created · ${updated} updated · ${skipped} skipped`));
9439
+ }
9440
+ /**
9441
+ * `amba export [file]` — write the current project's declarative bundle to
9442
+ * disk so it can be checked in, diffed, and re-applied.
9443
+ */
9444
+ async function exportConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
9445
+ const projectId = await requireProjectId(process.cwd(), options.project);
9446
+ const path = resolve(file);
9447
+ console.log();
9448
+ console.log(pc.bold(` amba export ${pc.cyan(file)}`));
9449
+ console.log(pc.dim(" ─────────────────────────────────"));
9450
+ console.log();
9451
+ console.log(pc.dim(` Project: ${projectId}`));
9452
+ console.log(pc.dim(" Fetching current configuration…"));
9453
+ const bundle = (await exportPromotionBundle(projectId)).data;
9454
+ await writeFile(path, JSON.stringify(bundle, null, 2) + "\n", "utf-8");
9455
+ const sectionCount = BUNDLE_SECTIONS.reduce((sum, { key }) => {
9456
+ const v = bundle[key];
9457
+ if (Array.isArray(v)) return sum + v.length;
9458
+ if (key === "monetization" && v !== null && typeof v === "object") {
9459
+ const m = v;
9460
+ return sum + MONETIZATION_SUBSECTIONS.reduce((n, sub) => n + (Array.isArray(m[sub]) ? m[sub].length : 0), 0);
9461
+ }
9462
+ return sum;
9463
+ }, 0);
9464
+ console.log();
9465
+ console.log(pc.green(" ✓") + ` Wrote ${pc.cyan(file)} (${sectionCount} definitions).`);
9466
+ console.log(pc.dim(" Edit it, then ") + pc.cyan("amba diff") + pc.dim(" to preview and ") + pc.cyan("amba apply") + pc.dim(" to apply."));
9467
+ console.log();
9468
+ }
9469
+ /**
9470
+ * `amba apply [file]` — read a local declarative bundle and POST it to the
9471
+ * import endpoint. Prints the server's authoritative `{ created, updated,
9472
+ * skipped }` per section.
9473
+ */
9474
+ async function applyConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
9475
+ const projectId = await requireProjectId(process.cwd(), options.project);
9476
+ const mode = options.mode ?? "skip_existing";
9477
+ const bundle = await readBundleFile(file);
9478
+ console.log();
9479
+ console.log(pc.bold(` amba apply ${pc.cyan(file)}`));
9480
+ console.log(pc.dim(" ─────────────────────────────────"));
9481
+ console.log();
9482
+ console.log(pc.dim(` Project: ${projectId}`));
9483
+ console.log(pc.dim(` Mode: ${mode}`));
9484
+ console.log(pc.dim(" Applying…"));
9485
+ console.log();
9486
+ let res;
9487
+ try {
9488
+ res = await importPromotionBundle(projectId, bundle, mode);
9489
+ } catch (err) {
9490
+ 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.`);
9491
+ throw err;
9492
+ }
9493
+ renderSummary(res.data.summary, "apply");
9494
+ console.log();
9495
+ }
9496
+ /**
9497
+ * `amba diff [file]` — show what `apply` WOULD change without writing.
9498
+ *
9499
+ * The import route has no server-side dry-run, so this fetches the current
9500
+ * project's exported bundle and computes the diff LOCALLY. Output mirrors the
9501
+ * `apply` summary shape so the two read identically.
9502
+ */
9503
+ async function diffConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
9504
+ const projectId = await requireProjectId(process.cwd(), options.project);
9505
+ const mode = options.mode ?? "skip_existing";
9506
+ const desired = await readBundleFile(file);
9507
+ console.log();
9508
+ console.log(pc.bold(` amba diff ${pc.cyan(file)}`));
9509
+ console.log(pc.dim(" ─────────────────────────────────"));
9510
+ console.log();
9511
+ console.log(pc.dim(` Project: ${projectId}`));
9512
+ console.log(pc.dim(` Mode: ${mode}`));
9513
+ console.log(pc.dim(" (computed locally — the import route has no server dry-run)"));
9514
+ console.log(pc.dim(" Fetching current configuration…"));
9515
+ console.log();
9516
+ const diff = computeLocalDiff(desired, (await exportPromotionBundle(projectId)).data, mode);
9517
+ renderSummary(diff, "change");
9518
+ const totals = summarizeDiff(diff);
9519
+ console.log();
9520
+ if (totals.created > 0 || totals.updated > 0) console.log(pc.dim(" Run ") + pc.cyan(`amba apply ${file}`) + pc.dim(" to apply these changes."));
9521
+ else if (totals.skipped > 0) if (mode === "skip_existing") {
9522
+ console.log(pc.green(" ✓") + " apply would make no changes (skip_existing leaves existing entities untouched).");
9523
+ 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."));
9524
+ } else console.log(pc.green(" ✓") + " apply would make no changes.");
9525
+ else console.log(pc.green(" ✓") + " No changes — nothing in the bundle to apply.");
9526
+ console.log();
9527
+ }
9528
+ //#endregion
6888
9529
  //#region src/index.ts
6889
9530
  const program = new Command();
6890
- program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("0.1.0");
9531
+ program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("4.1.0");
6891
9532
  program.option("--token <pat>", "Use a Personal Access Token for headless / CI / agent use (overrides ~/.amba/credentials.json + AMBA_PAT env)");
6892
9533
  program.hook("preAction", (thisCommand) => {
6893
9534
  setBearerOverride(resolveTokenSource({
@@ -7029,8 +9670,8 @@ program.command("schema").description("Schema export commands").command("export"
7029
9670
  }));
7030
9671
  });
7031
9672
  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));
9673
+ 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) => {
9674
+ await runAction(() => functionsDeployCommand(target, opts));
7034
9675
  });
7035
9676
  functions.command("list").description("List active functions for the current project").action(async () => {
7036
9677
  await runAction(functionsListCommand);
@@ -7047,11 +9688,13 @@ functions.command("dev <file>").description("Run a local dev server for your fun
7047
9688
  noWatch: opts.watch === false
7048
9689
  }));
7049
9690
  });
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) => {
9691
+ 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("--since-request-id <id>", "Start the range at the oldest event mentioning this request id (e.g. r_...) — \"everything from this request onward\". Searches the last 24h (or --since/--until when given). 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
9692
  await runAction(() => functionsLogsCommand(name, {
7052
9693
  since: opts.since,
7053
9694
  until: opts.until,
7054
9695
  limit: opts.limit,
9696
+ requestId: opts.requestId,
9697
+ sinceRequestId: opts.sinceRequestId,
7055
9698
  tail: opts.tail || opts.follow,
7056
9699
  json: opts.json
7057
9700
  }));
@@ -7114,8 +9757,77 @@ billing.command("set-ceiling <amount>").description("Cap the monthly bill at <am
7114
9757
  }
7115
9758
  await runAction(() => billingSetCeilingCommand({ ceiling }));
7116
9759
  });
9760
+ const payments = program.command("payments").description("Accept payments in your app (Amba is the rail; your app is the seller)");
9761
+ 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) => {
9762
+ if (opts.country && !/^[A-Za-z]{2}$/.test(opts.country)) {
9763
+ console.log(" ✗ --country must be a two-letter ISO code (e.g. US)");
9764
+ process.exit(1);
9765
+ }
9766
+ await runAction(() => paymentsSetupCommand({
9767
+ country: opts.country ? opts.country.toUpperCase() : void 0,
9768
+ email: opts.email
9769
+ }));
9770
+ });
9771
+ payments.command("status").description("Show onboarding stage + whether charges/payouts are enabled").action(async () => {
9772
+ await runAction(paymentsStatusCommand);
9773
+ });
9774
+ payments.command("set-fee <bps>").description("Set the default platform fee in basis points (250 = 2.5%), or 'off' to clear").action(async (bps) => {
9775
+ let value;
9776
+ if (bps.toLowerCase() === "off" || bps === "") value = null;
9777
+ else {
9778
+ const parsed = Number(bps);
9779
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 1e4) {
9780
+ console.log(" ✗ bps must be an integer between 0 and 10000, or \"off\"");
9781
+ process.exit(1);
9782
+ }
9783
+ value = parsed;
9784
+ }
9785
+ await runAction(() => paymentsSetFeeCommand({ bps: value }));
9786
+ });
9787
+ payments.command("balance").description("Show the connected account's available and pending balance").action(async () => {
9788
+ await runAction(paymentsBalanceCommand);
9789
+ });
9790
+ 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) => {
9791
+ const limit = opts.limit ? Number(opts.limit) : void 0;
9792
+ if (limit !== void 0 && (!Number.isInteger(limit) || limit < 1 || limit > 100)) {
9793
+ console.log(" ✗ --limit must be an integer between 1 and 100");
9794
+ process.exit(1);
9795
+ }
9796
+ await runAction(() => paymentsPayoutsCommand({ limit }));
9797
+ });
9798
+ const monetization = program.command("monetization").description("Monetization control plane: plan, drift, export, adopt, and apply your subscription config as Infrastructure-as-Code.");
9799
+ monetization.command("plan").description("Preview the three-way diff (declared vs adopted vs live) + store-floor + drift").action(async () => {
9800
+ await runAction(monetizationPlanCommand);
9801
+ });
9802
+ monetization.command("drift").description("Show managed objects changed out-of-band in the provider").action(async () => {
9803
+ await runAction(monetizationDriftCommand);
9804
+ });
9805
+ 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) => {
9806
+ await runAction(() => monetizationExportCommand(opts));
9807
+ });
9808
+ 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) => {
9809
+ await runAction(() => monetizationAdoptCommand(opts));
9810
+ });
9811
+ 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) => {
9812
+ await runAction(() => monetizationApplyCommand(opts));
9813
+ });
9814
+ program.command("ship").description("Take this Expo app from code-complete to live on the App Store + Google Play (preflight → monetization → build → submit → metadata → release).").option("--phase <phase>", `Run a single phase (${SHIP_PHASES.join(" | ")}) instead of the whole pipeline.`).option("--platform <platform>", "Limit to 'ios' | 'android' (default: build.platforms from the config).").option("--dry-run", "Print the plan; make no cloud changes and no state writes.").option("--force", "Re-run phases already recorded as done.").option("--config <path>", `Config file (default: ${DEFAULT_SHIP_CONFIG_FILE}).`).action(async (opts) => {
9815
+ await runAction(() => shipCommand({
9816
+ phase: opts.phase,
9817
+ platform: opts.platform,
9818
+ dryRun: opts.dryRun,
9819
+ force: opts.force,
9820
+ config: opts.config
9821
+ }));
9822
+ }).command("init").description(`Scaffold a ${DEFAULT_SHIP_CONFIG_FILE} for this app`).option("--config <path>", `Output file (default: ${DEFAULT_SHIP_CONFIG_FILE})`).option("--slug <slug>", "App slug used to scope the launch state (default: my-app)").option("--force", "Overwrite an existing config file").action(async (opts) => {
9823
+ await runAction(() => shipInitCommand({
9824
+ config: opts.config,
9825
+ slug: opts.slug,
9826
+ force: opts.force
9827
+ }));
9828
+ });
7117
9829
  const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
7118
- collections.command("create <name>").description("Create a collection with the given fields").option("--field <spec>", "Field spec: name:type[:nullable] (e.g. user_id:uuid, parsed:jsonb:nullable). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--index <spec>", "Index spec: \"col1 [asc|desc], col2 [asc|desc]\". Repeatable.", (val, prev) => [...prev ?? [], val], []).action(async (name, opts) => {
9830
+ 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, tags:text[], parsed:jsonb:nullable, embedding:vector:1536). 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
9831
  await runAction(() => collectionsCreateCommand(name, opts));
7120
9832
  });
7121
9833
  collections.command("alter <name>").description("Add columns / indexes or drop columns on an existing collection").option("--add-field <spec>", "Column to add (name:type[:nullable]). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--add-index <spec>", "Index to add (\"col [asc|desc], …\"). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--drop-field <name>", "Column to drop (DESTRUCTIVE — requires --confirm <name>). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--confirm <name>", "Confirm a destructive --drop-field <name>. Repeatable.", (val, prev) => [...prev ?? [], val], []).action(async (name, opts) => {
@@ -7131,7 +9843,7 @@ program.command("types").description("TypeScript codegen for collections").comma
7131
9843
  await runAction(() => typesGenerateCommand(opts));
7132
9844
  });
7133
9845
  const sites = program.command("sites").description("Static site hosting");
7134
- sites.command("deploy <dir>").description("Upload a built static-site directory to Pages-for-Platforms").option("--name <name>", "Site name (default: basename of dir, slug-cleaned)").option("--dry-run", "Scan + size-check without uploading").action(async (dir, opts) => {
9846
+ sites.command("deploy <dir>").description("Upload a built static-site directory to Amba site hosting").option("--name <name>", "Site name (default: basename of dir, slug-cleaned)").option("--dry-run", "Scan + size-check without uploading").action(async (dir, opts) => {
7135
9847
  await runAction(() => sitesDeployCommand(dir, opts));
7136
9848
  });
7137
9849
  sites.command("list").description("List sites for the current project").action(async () => {
@@ -7199,6 +9911,29 @@ aiProviders.command("list").description("List registered AI providers for the cu
7199
9911
  aiProviders.command("delete <provider>").description("Remove a provider registration (refuses if active prompts reference it)").action(async (provider) => {
7200
9912
  await runAction(() => aiProvidersDeleteCommand(provider));
7201
9913
  });
9914
+ function parseImportMode(raw) {
9915
+ if (raw === void 0) return "skip_existing";
9916
+ if (raw === "merge" || raw === "skip_existing") return raw;
9917
+ console.log(" ✗ --mode must be \"skip_existing\" (default) or \"merge\"");
9918
+ process.exit(1);
9919
+ }
9920
+ 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) => {
9921
+ await runAction(() => exportConfigCommand(file, { project: opts.project }));
9922
+ });
9923
+ 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) => {
9924
+ const mode = parseImportMode(opts.mode);
9925
+ await runAction(() => diffConfigCommand(file, {
9926
+ project: opts.project,
9927
+ mode
9928
+ }));
9929
+ });
9930
+ 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) => {
9931
+ const mode = parseImportMode(opts.mode);
9932
+ await runAction(() => applyConfigCommand(file, {
9933
+ project: opts.project,
9934
+ mode
9935
+ }));
9936
+ });
7202
9937
  program.parse();
7203
9938
  //#endregion
7204
9939
  export {};