@layers/amba 4.0.4 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,15 +3,16 @@ 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
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
- import { createWriteStream, watch } from "node:fs";
12
+ import fs, { createWriteStream, watch } from "node:fs";
13
13
  import { spawn } from "node:child_process";
14
14
  import { build } from "esbuild";
15
+ import process$1 from "node:process";
15
16
  new Map([
16
17
  {
17
18
  type: "user.created",
@@ -460,6 +461,37 @@ new Map([
460
461
  reason: "card_declined"
461
462
  },
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"
463
495
  }
464
496
  ].map((e) => [e.type, e]));
465
497
  /**
@@ -996,6 +1028,7 @@ async function getFunctionLogs(projectId, functionName, options = {}) {
996
1028
  if (options.until) params.set("until", options.until);
997
1029
  if (options.limit !== void 0) params.set("limit", String(options.limit));
998
1030
  if (options.requestId) params.set("request_id", options.requestId);
1031
+ if (options.sinceRequestId) params.set("since_request_id", options.sinceRequestId);
999
1032
  const qs = params.toString();
1000
1033
  return request("GET", `/projects/${projectId}/functions/${functionName}/logs${qs ? `?${qs}` : ""}`);
1001
1034
  }
@@ -2820,7 +2853,7 @@ All SDKs expose the same surface: \`Amba.configure({ projectId, apiKey })\`, the
2820
2853
  - **analytics** — funnels + retention. Query via \`amba_analytics_get\`.
2821
2854
 
2822
2855
  ### Infrastructure
2823
- - **collections** — your own typed key-value tables. Define with \`amba_collections_create\`; read/write from SDK \`Amba.client.*\`.
2856
+ - **collections** — your own relational Postgres tables (typed columns, foreign keys, transactions, unique indexes, vector search). Define with \`amba_collections_create\`; read/write from SDK \`Amba.client.*\`.
2824
2857
  - **functions** — serverless TypeScript handlers. Deploy with \`amba_functions_deploy\`; schedule with \`amba_functions_schedule\`.
2825
2858
  - **sites** — static site hosting at \`*.app.amba.host\`. Deploy with \`amba_sites_deploy\`.
2826
2859
  - **media** — file storage + CDN. Upload via \`amba_media_upload\`.
@@ -3457,7 +3490,7 @@ When the user asks you to wire Amba into this codebase, run this journey end-to-
3457
3490
  - **gamification** — XP, achievements, streaks, leaderboards, challenges (\`amba://setup/gamification\`).
3458
3491
  - **economy** — currencies, catalog, stores, inventory (\`amba://setup/economy\`).
3459
3492
  - **social** — friends, groups, feeds, messaging, moderation, reviews (\`amba://setup/social\`).
3460
- - **infrastructure** — collections (typed tables), functions, AI prompts, secrets, configs, integrations, media, sites (\`amba://setup/infrastructure\`).
3493
+ - **infrastructure** — collections (relational Postgres tables), functions, AI prompts, secrets, configs, integrations, media, sites (\`amba://setup/infrastructure\`).
3461
3494
 
3462
3495
  ## SDKs
3463
3496
 
@@ -6240,6 +6273,10 @@ function parseRateLimitFlags(options) {
6240
6273
  * - `--tail`: print the last hour's events, then poll every 3s for
6241
6274
  * new events past the highest seen `EventTimestampMs`. Ctrl+C to stop.
6242
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
+ *
6243
6280
  * Output formatting:
6244
6281
  * - `--json`: NDJSON to stdout (one event per line) so `| jq` works.
6245
6282
  * - default: human-readable lines:
@@ -6260,8 +6297,10 @@ async function functionsLogsCommand(functionName, options = {}) {
6260
6297
  since: options.since,
6261
6298
  until: options.until,
6262
6299
  limit: options.limit,
6263
- requestId: options.requestId
6300
+ requestId: options.requestId,
6301
+ sinceRequestId: options.sinceRequestId
6264
6302
  });
6303
+ if (res.data.anchor && !options.json) console.error(pc.dim(` Anchored at ${res.data.anchor} (--since-request-id).`));
6265
6304
  printEvents(res.data.events, options.json);
6266
6305
  if (res.data.truncated) if (options.json) process.stderr.write(`{"truncated":true}\n`);
6267
6306
  else console.error(pc.yellow(` ! Result truncated at limit; narrow --since / --until or raise --limit.`));
@@ -6269,21 +6308,33 @@ async function functionsLogsCommand(functionName, options = {}) {
6269
6308
  async function runTail(projectId, functionName, options) {
6270
6309
  let sinceMs = options.since ? Date.parse(options.since) : Date.now() - 3600 * 1e3;
6271
6310
  if (Number.isNaN(sinceMs)) throw new Error("--since must be a valid ISO 8601 timestamp");
6272
- 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()}.`));
6273
6312
  let highWaterMs = sinceMs;
6313
+ let firstFetch = true;
6274
6314
  for (;;) {
6275
6315
  let result;
6316
+ const anchored = firstFetch && options.sinceRequestId !== void 0;
6276
6317
  try {
6277
6318
  result = await getFunctionLogs(projectId, functionName, {
6278
- since: new Date(highWaterMs).toISOString(),
6319
+ ...anchored ? { sinceRequestId: options.sinceRequestId } : { since: new Date(highWaterMs).toISOString() },
6320
+ ...anchored && options.since ? { since: options.since } : {},
6279
6321
  limit: options.limit ?? 200,
6280
6322
  requestId: options.requestId
6281
6323
  });
6282
6324
  } catch (err) {
6325
+ if (anchored) throw err;
6283
6326
  console.error(pc.yellow(` ! tail fetch failed: `) + (err instanceof Error ? err.message : String(err)));
6284
6327
  await sleep$1(TAIL_POLL_MS);
6285
6328
  continue;
6286
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
+ }
6287
6338
  if (result.data.events.length > 0) {
6288
6339
  printEvents(result.data.events, options.json);
6289
6340
  highWaterMs = result.data.events.reduce((m, e) => Math.max(m, e.EventTimestampMs ?? 0), highWaterMs) + 1;
@@ -6548,6 +6599,11 @@ async function adminWrite$1(method, path, body) {
6548
6599
  }
6549
6600
  return await res.json();
6550
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
+ }
6551
6607
  function formatAxis(label, axis, isStorage) {
6552
6608
  const used = axis.used === null ? "—" : isStorage ? axis.used >= 1024 ? `${(axis.used / 1024).toFixed(2)} GB` : `${axis.used} MB` : axis.used.toLocaleString();
6553
6609
  const limit = axis.limit === null ? "unlimited" : isStorage ? axis.limit >= 1024 ? `${(axis.limit / 1024).toFixed(0)} GB` : `${axis.limit} MB` : axis.limit.toLocaleString();
@@ -6565,18 +6621,29 @@ async function billingStatusCommand() {
6565
6621
  console.log(` Tier: ${pc.bold(s.tier)}`);
6566
6622
  console.log(` Subscription: ${s.subscription_status ?? pc.dim("—")}`);
6567
6623
  console.log(` Next bill anchor: ${s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : pc.dim("—")}`);
6568
- 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)`)}`}`);
6569
6626
  console.log(` Spend mode: ${s.mode}`);
6570
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()})`)}`);
6571
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
+ }
6572
6637
  console.log();
6573
6638
  console.log(pc.bold(" Usage — rolling 30 days"));
6574
- console.log(formatAxis("Monthly active users", s.headroom.mau, false));
6575
- console.log(formatAxis("Engagement events", s.headroom.engagement_events, false));
6576
- console.log(formatAxis("Telemetry events", s.headroom.telemetry_events, false));
6577
- console.log(formatAxis("Push deliveries", s.headroom.push, false));
6578
- console.log(formatAxis("Database storage", s.headroom.db_storage_mb, true));
6579
- 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));
6580
6647
  console.log();
6581
6648
  if (s.human_action_required !== "none") {
6582
6649
  const action = s.human_action_required.replaceAll("_", " ");
@@ -6825,10 +6892,22 @@ async function paymentsPayoutsCommand(input) {
6825
6892
  * so the next plan is a no-op. Writes ONLY Amba's
6826
6893
  * own state — never the provider. Idempotent.
6827
6894
  * amba monetization apply — push the declared config to the provider.
6828
- * ADDITIVE only: create entitlements/offerings/
6829
- * packages/paywall-drafts + attach products.
6830
- * Refuses if the provider drifted since the plan;
6831
- * never does destructive changes.
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, and Google Play one-time
6906
+ * products with push intent in Google Play)
6907
+ * ALWAYS wait for an explicit approval of the
6908
+ * started apply — an up-front --confirm never
6909
+ * auto-runs them. Refuses if the provider
6910
+ * drifted since the plan.
6832
6911
  *
6833
6912
  * Project is resolved via `loadProjectConfig` — same pattern as the other
6834
6913
  * project-scoped CLI commands.
@@ -6885,16 +6964,31 @@ async function monetizationPlanCommand() {
6885
6964
  console.log();
6886
6965
  }
6887
6966
  if (plan.store_floor.length > 0) {
6888
- console.log(pc.yellow(" Store-floor preflight — referenced products missing in the provider:"));
6889
- for (const sf of plan.store_floor) console.log(` ${pc.bold(sf.product_key)} → ${sf.store_identifier} (${sf.store})`);
6967
+ console.log(pc.yellow(" Store-floor preflight — blocking gaps apply can neither resolve nor create:"));
6968
+ for (const sf of plan.store_floor) {
6969
+ console.log(` ${pc.bold(sf.product_key)} → ${sf.store_identifier} (${sf.store})`);
6970
+ console.log(pc.dim(` ${sf.reason}`));
6971
+ }
6890
6972
  console.log();
6891
6973
  }
6974
+ printHumanFloor(plan.human_floor);
6892
6975
  if (plan.drift.length > 0) {
6893
6976
  console.log(pc.yellow(" Drift — managed objects changed out-of-band in the provider:"));
6894
6977
  for (const d of plan.drift) console.log(` ${pc.bold(d.object_type)} ${d.identifier}`);
6895
6978
  console.log();
6896
6979
  }
6897
6980
  }
6981
+ /** Print the store-side human-floor checklist (exact console locations). */
6982
+ function printHumanFloor(items) {
6983
+ if (!items || items.length === 0) return;
6984
+ console.log(pc.yellow(" Store checklist — steps no API can do (with exact locations):"));
6985
+ for (const h of items) {
6986
+ console.log(` ${pc.bold("□")} ${h.title}${h.product_key ? pc.dim(` (${h.product_key})`) : ""}`);
6987
+ console.log(pc.dim(` ${h.detail}`));
6988
+ console.log(pc.dim(` where: ${h.location}`));
6989
+ }
6990
+ console.log();
6991
+ }
6898
6992
  async function monetizationDriftCommand() {
6899
6993
  const { projectId } = await loadProjectConfig();
6900
6994
  header("drift");
@@ -6939,10 +7033,13 @@ async function monetizationApplyCommand(options = {}) {
6939
7033
  header("apply");
6940
7034
  const plan = (await adminGet(`/projects/${projectId}/monetization/plan`)).data;
6941
7035
  if (plan.store_floor.length > 0) {
6942
- console.log(pc.yellow(" Store floor not satisfied — these products must exist in the provider first:"));
6943
- for (const sf of plan.store_floor) console.log(` ${pc.bold(sf.product_key)} → ${sf.store_identifier} (${sf.store})`);
7036
+ console.log(pc.yellow(" Store floor not satisfied — apply can neither resolve nor create these:"));
7037
+ for (const sf of plan.store_floor) {
7038
+ console.log(` ${pc.bold(sf.product_key)} → ${sf.store_identifier} (${sf.store})`);
7039
+ console.log(pc.dim(` ${sf.reason}`));
7040
+ }
6944
7041
  console.log();
6945
- console.log(" ✗ Configure these in the store, then run plan + apply again.");
7042
+ console.log(" ✗ Fix the declaration (or connect the app), then run plan + apply again.");
6946
7043
  process.exit(1);
6947
7044
  }
6948
7045
  if (plan.clean) {
@@ -6958,49 +7055,77 @@ async function monetizationApplyCommand(options = {}) {
6958
7055
  if (options.confirm) body["confirm"] = plan.plan_hash;
6959
7056
  if (options.allowDetachLive) body["allow_detach_live"] = true;
6960
7057
  if (options.reconcile) body["reconcile"] = options.reconcile;
6961
- let res;
7058
+ let start;
6962
7059
  try {
6963
- res = await adminPost(`/projects/${projectId}/monetization/apply`, body);
7060
+ start = (await adminPost(`/projects/${projectId}/monetization/apply`, body)).data;
6964
7061
  } catch (err) {
6965
- if (err instanceof ApiClientError && err.errorCode === "MONETIZATION_CONFIRM_REQUIRED") {
6966
- console.log(pc.yellow(" ⚠ This plan includes gated destructive operation(s) — nothing was applied."));
6967
- console.log(pc.dim(" Review the plan, then re-run with --confirm to execute the gated ops:"));
6968
- console.log(` ${pc.cyan("amba monetization apply --confirm")}`);
6969
- console.log();
6970
- process.exit(1);
6971
- }
6972
- if (err instanceof ApiClientError && err.errorCode === "MONETIZATION_INVARIANT") {
6973
- console.log(pc.red(" ✗ Blocked by the ordering invariant — nothing was applied:"));
6974
- console.log(pc.dim(` ${err.message}`));
7062
+ if (err instanceof ApiClientError && err.errorCode === "MONETIZATION_APPLY_IN_PROGRESS") {
7063
+ console.log(pc.yellow(" ⚠ An apply is already in progress for this project."));
7064
+ console.log(pc.dim(" Wait for it to finish, or re-run with --confirm to approve a pending confirmation."));
6975
7065
  console.log();
6976
7066
  process.exit(1);
6977
7067
  }
6978
7068
  throw err;
6979
7069
  }
6980
- const d = res.data;
6981
- if (d.sandbox_stage?.ran) {
6982
- const tag = d.sandbox_stage.passed ? pc.green("✓ sandbox passed") : pc.red("✗ sandbox failed");
6983
- console.log(` ${tag} ` + pc.dim(d.sandbox_stage.detail));
7070
+ if (start.refused.length > 0) {
7071
+ console.log(pc.yellow(" Not applicable via API (do these in the provider dashboard):"));
7072
+ for (const r of start.refused) console.log(` ${pc.bold(r.op)} ${r.object_type} ${r.identifier}`);
6984
7073
  console.log();
6985
7074
  }
6986
- if (d.status === "succeeded") console.log(pc.green(" ✓") + ` Applied ${d.applied} change(s) to the provider ` + pc.dim(`(${d.skipped_existing} already present, ${d.gated_applied} destructive)`));
6987
- else {
6988
- console.log(pc.red(" ✗") + ` Apply stopped after a failure (${d.applied} applied, ${d.failed} failed).` + (d.stopped_at ? ` Stopped at: ${d.stopped_at}` : ""));
6989
- const failed = d.results.find((r) => r.status === "failed");
6990
- if (failed?.error) console.log(pc.dim(` ${failed.error}`));
7075
+ if (start.status === "succeeded") {
7076
+ console.log(pc.green(" ✓") + " Nothing to apply — declared config matches the provider.");
7077
+ console.log();
7078
+ return;
6991
7079
  }
6992
- if (d.gated.length > 0) {
7080
+ if (start.confirm_required) {
7081
+ const storeSteps = start.gated.filter((g) => g.store === true);
7082
+ console.log(pc.yellow(" ⚠ This plan includes gated operation(s) — they are NOT applied yet."));
7083
+ for (const g of start.gated) {
7084
+ const tag = g.store === true ? pc.magenta(" [store]") : "";
7085
+ console.log(` ${pc.bold(g.op)} ${g.object_type} ${g.identifier}${tag}`);
7086
+ }
6993
7087
  console.log();
6994
- console.log(pc.yellow(" Gated (destructive — run with --confirm to execute):"));
6995
- for (const g of d.gated) console.log(` ${pc.bold(g.op.op)} ${g.op.object_type} ${g.op.identifier}`);
7088
+ 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."));
7089
+ printHumanFloor(start.human_floor);
7090
+ console.log(pc.dim(" Review the plan, then approve the pending apply:"));
7091
+ console.log(` ${pc.cyan("amba monetization apply --confirm")}`);
7092
+ console.log();
7093
+ console.log(pc.dim(` operation: ${start.operation_id}`));
7094
+ console.log();
7095
+ process.exit(1);
6996
7096
  }
6997
- if (d.refused.length > 0) {
7097
+ console.log(pc.dim(` Applying… (operation ${start.operation_id} — safe to Ctrl-C; the apply continues)`));
7098
+ const op = await pollApplyOperation(projectId, start.operation_id);
7099
+ if (op.result?.sandbox_stage?.ran) {
7100
+ const s = op.result.sandbox_stage;
7101
+ const tag = s.passed ? pc.green("✓ sandbox passed") : pc.red("✗ sandbox failed");
7102
+ console.log(` ${tag} ` + pc.dim(s.detail));
6998
7103
  console.log();
6999
- console.log(pc.yellow(" Not applicable via API (do these in the provider dashboard):"));
7000
- for (const r of d.refused) console.log(` ${pc.bold(r.op.op)} ${r.op.object_type} ${r.op.identifier}`);
7001
7104
  }
7105
+ if (op.status === "succeeded") {
7106
+ const r = op.result ?? {};
7107
+ 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)`));
7108
+ console.log();
7109
+ return;
7110
+ }
7111
+ console.log(pc.red(" ✗") + " Apply did not complete.");
7112
+ if (op.failed_reason) console.log(pc.dim(` ${op.failed_reason}`));
7002
7113
  console.log();
7003
- if (d.status !== "succeeded") process.exit(1);
7114
+ process.exit(1);
7115
+ }
7116
+ /** Poll GET /operations/:id until the apply reaches a terminal state. */
7117
+ async function pollApplyOperation(projectId, operationId) {
7118
+ const POLL_INTERVAL_MS = 3e3;
7119
+ const deadline = Date.now() + 900 * 1e3;
7120
+ for (;;) {
7121
+ const op = (await adminGet(`/projects/${projectId}/operations/${operationId}`)).data;
7122
+ if (op.status === "succeeded" || op.status === "failed") return op;
7123
+ if (Date.now() >= deadline) {
7124
+ console.log(pc.yellow(` ⚠ Still running server-side (operation ${operationId}).`) + pc.dim(" Re-run `amba monetization plan` later — a clean plan means it finished."));
7125
+ process.exit(1);
7126
+ }
7127
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
7128
+ }
7004
7129
  }
7005
7130
  //#endregion
7006
7131
  //#region src/commands/collections.ts
@@ -7044,7 +7169,13 @@ const ALLOWED_TYPES = new Set([
7044
7169
  "timestamptz",
7045
7170
  "date",
7046
7171
  "jsonb",
7047
- "vector"
7172
+ "vector",
7173
+ "text[]",
7174
+ "integer[]",
7175
+ "bigint[]",
7176
+ "numeric[]",
7177
+ "boolean[]",
7178
+ "uuid[]"
7048
7179
  ]);
7049
7180
  const TYPE_SYNONYMS = {
7050
7181
  int: "integer",
@@ -7111,7 +7242,10 @@ async function collectionsDropCommand(name, options = {}) {
7111
7242
  }
7112
7243
  function normalizeColumnType(raw) {
7113
7244
  const baseRaw = (raw.split("(")[0] ?? raw).trim().toLowerCase();
7114
- const base = TYPE_SYNONYMS[baseRaw] ?? baseRaw;
7245
+ const isArray = baseRaw.endsWith("[]");
7246
+ const elementRaw = isArray ? baseRaw.slice(0, -2) : baseRaw;
7247
+ const element = TYPE_SYNONYMS[elementRaw] ?? elementRaw;
7248
+ const base = isArray ? `${element}[]` : element;
7115
7249
  if (!ALLOWED_TYPES.has(base)) throw new Error(`Unknown type '${raw}'. Allowed: ${[...ALLOWED_TYPES].join(", ")}.`);
7116
7250
  return base;
7117
7251
  }
@@ -7121,7 +7255,11 @@ function normalizeColumnType(raw) {
7121
7255
  * Accepted grammars:
7122
7256
  *
7123
7257
  * 1. **Plain column:** `name:type[:nullable]`
7124
- * Examples: `letter_id:uuid`, `parsed:jsonb:nullable`.
7258
+ * Examples: `letter_id:uuid`, `parsed:jsonb:nullable`, `tags:text[]`,
7259
+ * `scores:integer[]:nullable`. Array types (`text[]`, `integer[]`,
7260
+ * `bigint[]`, `numeric[]`, `boolean[]`, `uuid[]`) follow the same
7261
+ * grammar as scalars and unlock the contains/containedBy/overlaps
7262
+ * query operators.
7125
7263
  *
7126
7264
  * 2. **Foreign key:** `name:type:fk(<table>[.<column>][:onDelete])`
7127
7265
  * Examples:
@@ -7148,12 +7286,14 @@ function normalizeColumnType(raw) {
7148
7286
  * left-to-right: the third segment is the FK / vector / nullable
7149
7287
  * marker; if it's an FK or vector, the optional fourth segment is
7150
7288
  * `nullable`. Plain columns put `nullable` in slot 3.
7289
+ *
7290
+ * Exported for tests.
7151
7291
  */
7152
7292
  function parseColumnSpec(spec) {
7153
7293
  const parts = spec.replace(/^([a-z_][a-z0-9_]*):vector\((\d+)\)/i, "$1:vector:$2").replace(/fk\(([^)]+)\)/i, (_match, inner) => {
7154
7294
  return `fk(${inner.replace(/:/g, "|")})`;
7155
7295
  }).split(":");
7156
- 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).`);
7296
+ 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).`);
7157
7297
  const colName = parts[0];
7158
7298
  const type = parts[1];
7159
7299
  const slot3 = parts[2];
@@ -7243,6 +7383,8 @@ function normalizeOnDelete(raw) {
7243
7383
  *
7244
7384
  * Bare column names (no direction) are passed through verbatim; the
7245
7385
  * default sort direction is the DDL emit's responsibility.
7386
+ *
7387
+ * Exported for tests.
7246
7388
  */
7247
7389
  function parseIndexSpec(spec) {
7248
7390
  const cols = [];
@@ -7258,6 +7400,1184 @@ function parseIndexSpec(spec) {
7258
7400
  return { columns: cols };
7259
7401
  }
7260
7402
  //#endregion
7403
+ //#region src/ship/config.ts
7404
+ /**
7405
+ * `amba ship` config — the typed, hand-validated launch config.
7406
+ *
7407
+ * One file per repo (default `amba.ship.json`) describes how to take this Expo
7408
+ * app from code-complete to live on the App Store and Google Play. It carries
7409
+ * NON-SECRET identifiers and the NAME of the env var holding the Expo token —
7410
+ * never secret values (the App Store Connect `.p8` and Play service-account
7411
+ * JSON live in `eas.json`, owned by the app repo, exactly where the build runs).
7412
+ *
7413
+ * Validation is hand-rolled (the CLI deliberately ships no `zod` — see
7414
+ * `_internal/shared.ts` for the same return-all-errors-at-once pattern). The
7415
+ * field names are the canonical contract; `validateShipConfig` reports every
7416
+ * problem in one pass and enforces the cross-field invariants.
7417
+ *
7418
+ * Monetization is NOT described here: the App Store / Play product catalog is
7419
+ * Amba's monetization control plane (`amba monetization`, the durable Track H
7420
+ * apply against the provider). `ship` only flips `monetization.enabled` to
7421
+ * decide whether to verify that catalog is in sync before building.
7422
+ */
7423
+ /** The ordered launch phases. `ship` runs them in this sequence. */
7424
+ const SHIP_PHASES = [
7425
+ "preflight",
7426
+ "monetization",
7427
+ "build",
7428
+ "submit",
7429
+ "metadata",
7430
+ "release"
7431
+ ];
7432
+ const SHIP_PLATFORMS = ["ios", "android"];
7433
+ /** Google Play release tracks `ship` can submit to. */
7434
+ const PLAY_TRACKS = [
7435
+ "internal",
7436
+ "alpha",
7437
+ "beta",
7438
+ "production"
7439
+ ];
7440
+ /** Default config filename when no `--config <path>` is passed. */
7441
+ const DEFAULT_SHIP_CONFIG_FILE = "amba.ship.json";
7442
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
7443
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/;
7444
+ const REVERSE_DNS_RE = /^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/;
7445
+ function asObject(value) {
7446
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
7447
+ return value;
7448
+ }
7449
+ /**
7450
+ * Validate an untrusted parsed JSON value into a typed `ShipConfig`,
7451
+ * collecting EVERY problem (not just the first) so a misconfigured file
7452
+ * surfaces all its issues in one run. Returns the typed config on success.
7453
+ */
7454
+ function validateShipConfig(raw) {
7455
+ const errors = [];
7456
+ const root = asObject(raw);
7457
+ if (!root) return {
7458
+ ok: false,
7459
+ errors: ["config must be a JSON object"]
7460
+ };
7461
+ if (typeof root["version"] !== "number") errors.push("version must be a number");
7462
+ const app = asObject(root["app"]);
7463
+ if (!app) errors.push("app must be an object");
7464
+ else {
7465
+ const slug = app["slug"];
7466
+ if (typeof slug !== "string" || !SLUG_RE.test(slug)) errors.push("app.slug must be lowercase letters, digits and dashes");
7467
+ if (typeof app["name"] !== "string" || app["name"].trim().length === 0) errors.push("app.name must be a non-empty string");
7468
+ if (typeof app["version"] !== "string" || !SEMVER_RE.test(app["version"])) errors.push("app.version must be semver MAJOR.MINOR.PATCH");
7469
+ 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");
7470
+ 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");
7471
+ }
7472
+ const build = asObject(root["build"]);
7473
+ if (!build) errors.push("build must be an object");
7474
+ else {
7475
+ 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)");
7476
+ const platforms = build["platforms"];
7477
+ if (!Array.isArray(platforms) || platforms.length === 0) errors.push("build.platforms must be a non-empty array of \"ios\" | \"android\"");
7478
+ else {
7479
+ const seen = /* @__PURE__ */ new Set();
7480
+ for (const p of platforms) if (p !== "ios" && p !== "android") errors.push(`build.platforms entries must be "ios" or "android" (got ${JSON.stringify(p)})`);
7481
+ else if (seen.has(p)) errors.push(`build.platforms has a duplicate entry "${p}"`);
7482
+ else seen.add(p);
7483
+ }
7484
+ }
7485
+ const ios = asObject(root["ios"]);
7486
+ if (!ios) errors.push("ios must be an object");
7487
+ else if (ios["ascAppId"] !== null && typeof ios["ascAppId"] !== "string") errors.push("ios.ascAppId must be a string or null");
7488
+ 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)");
7489
+ const android = asObject(root["android"]);
7490
+ if (!android) errors.push("android must be an object");
7491
+ else if (!PLAY_TRACKS.includes(android["track"])) errors.push(`android.track must be one of ${PLAY_TRACKS.join(" | ")}`);
7492
+ const monetization = asObject(root["monetization"]);
7493
+ if (!monetization) errors.push("monetization must be an object");
7494
+ else if (typeof monetization["enabled"] !== "boolean") errors.push("monetization.enabled must be a boolean");
7495
+ const secrets = asObject(root["secrets"]);
7496
+ if (!secrets) errors.push("secrets must be an object");
7497
+ 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\")");
7498
+ if (errors.length > 0) return {
7499
+ ok: false,
7500
+ errors
7501
+ };
7502
+ const a = app;
7503
+ const b = build;
7504
+ return {
7505
+ ok: true,
7506
+ config: {
7507
+ version: root["version"],
7508
+ app: {
7509
+ slug: a["slug"],
7510
+ name: a["name"].trim(),
7511
+ version: a["version"],
7512
+ bundleId: a["bundleId"],
7513
+ androidPackage: a["androidPackage"]
7514
+ },
7515
+ build: {
7516
+ profile: b["profile"].trim(),
7517
+ platforms: b["platforms"].slice()
7518
+ },
7519
+ ios: { ascAppId: ios["ascAppId"] },
7520
+ android: { track: android["track"] },
7521
+ monetization: { enabled: monetization["enabled"] },
7522
+ secrets: { expoTokenEnvVar: secrets["expoTokenEnvVar"].trim() }
7523
+ }
7524
+ };
7525
+ }
7526
+ /**
7527
+ * A starter config written by `amba ship init`. Placeholders are intentionally
7528
+ * obvious so the validator's invariants don't pass on an unedited file's IDs
7529
+ * (the reverse-DNS / semver placeholders DO validate so `--phase preflight`
7530
+ * works immediately; the operator swaps in their real values).
7531
+ */
7532
+ function shipConfigTemplate(slug) {
7533
+ return {
7534
+ version: 1,
7535
+ app: {
7536
+ slug,
7537
+ name: "My App",
7538
+ version: "1.0.0",
7539
+ bundleId: `com.example.${slug.replace(/-/g, "")}`,
7540
+ androidPackage: `com.example.${slug.replace(/-/g, "")}`
7541
+ },
7542
+ build: {
7543
+ profile: "production",
7544
+ platforms: ["ios", "android"]
7545
+ },
7546
+ ios: { ascAppId: null },
7547
+ android: { track: "internal" },
7548
+ monetization: { enabled: false },
7549
+ secrets: { expoTokenEnvVar: "EXPO_TOKEN" }
7550
+ };
7551
+ }
7552
+ //#endregion
7553
+ //#region src/ship/exec.ts
7554
+ /**
7555
+ * The external-command boundary for `amba ship`.
7556
+ *
7557
+ * Every shell-out (always `eas`) goes through an `Executor`, so the phase
7558
+ * logic — which args get built, how `--json` output is parsed, which failures
7559
+ * map to which manual gate — is unit-tested against a fake executor with NO
7560
+ * live cloud, no EAS account, and no Apple/Google credentials. The default
7561
+ * `createNodeExecutor()` is a thin, shell-free wrapper around
7562
+ * `node:child_process.spawn` (mirrors the launch toolkit's `lib/exec`).
7563
+ */
7564
+ /**
7565
+ * Redact secret-shaped tokens from a string before it is logged. A backstop,
7566
+ * not a license — `ship` never puts secret values on a command line (EAS reads
7567
+ * its token from the env and its store creds from eas.json). Covers Expo /
7568
+ * common access-token shapes and `Bearer`/`sk_` prefixes.
7569
+ */
7570
+ function redactSecrets(text) {
7571
+ 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=***");
7572
+ }
7573
+ /** A backing executor over `node:child_process.spawn`. Never invokes a shell. */
7574
+ function createNodeExecutor(globalDryRun = false) {
7575
+ return { run(cmd, args, opts = {}) {
7576
+ const dryRun = opts.dryRun ?? globalDryRun;
7577
+ const printable = redactSecrets([cmd, ...args].join(" "));
7578
+ if (dryRun) return Promise.resolve({
7579
+ command: printable,
7580
+ code: 0,
7581
+ stdout: "",
7582
+ stderr: "",
7583
+ dryRun: true
7584
+ });
7585
+ const stream = opts.stream ?? false;
7586
+ return new Promise((resolve, reject) => {
7587
+ const child = spawn(cmd, args, {
7588
+ cwd: opts.cwd ?? process$1.cwd(),
7589
+ env: opts.env ? {
7590
+ ...process$1.env,
7591
+ ...opts.env
7592
+ } : process$1.env,
7593
+ shell: false
7594
+ });
7595
+ let stdout = "";
7596
+ let stderr = "";
7597
+ let timedOut = false;
7598
+ let timer;
7599
+ if (opts.timeoutMs && opts.timeoutMs > 0) timer = setTimeout(() => {
7600
+ timedOut = true;
7601
+ child.kill("SIGKILL");
7602
+ }, opts.timeoutMs);
7603
+ child.stdout?.on("data", (chunk) => {
7604
+ const s = chunk.toString();
7605
+ stdout += s;
7606
+ if (stream) process$1.stdout.write(redactSecrets(s));
7607
+ });
7608
+ child.stderr?.on("data", (chunk) => {
7609
+ const s = chunk.toString();
7610
+ stderr += s;
7611
+ if (stream) process$1.stderr.write(redactSecrets(s));
7612
+ });
7613
+ child.on("error", (err) => {
7614
+ if (timer) clearTimeout(timer);
7615
+ const hint = err.code === "ENOENT" ? `Command "${cmd}" not found. Is it installed and on PATH?` : err.message;
7616
+ reject(/* @__PURE__ */ new Error(`Failed to start "${printable}": ${hint}`));
7617
+ });
7618
+ child.on("close", (code) => {
7619
+ if (timer) clearTimeout(timer);
7620
+ if (timedOut) {
7621
+ reject(/* @__PURE__ */ new Error(`Command timed out after ${opts.timeoutMs}ms: ${printable}\n stderr tail: ${redactSecrets(stderr.slice(-400))}`));
7622
+ return;
7623
+ }
7624
+ if ((code ?? -1) !== 0) {
7625
+ reject(/* @__PURE__ */ new Error(`Command failed (exit ${code ?? -1}): ${printable}\n stderr tail: ${redactSecrets(stderr.slice(-400) || stdout.slice(-400) || "(no output)")}`));
7626
+ return;
7627
+ }
7628
+ resolve({
7629
+ command: printable,
7630
+ code: code ?? 0,
7631
+ stdout,
7632
+ stderr,
7633
+ dryRun: false
7634
+ });
7635
+ });
7636
+ });
7637
+ } };
7638
+ }
7639
+ /**
7640
+ * Run a command and report success/failure WITHOUT throwing — for read-only
7641
+ * "is this CLI installed/authed?" probes where a non-zero exit is data, not a
7642
+ * fatal error.
7643
+ */
7644
+ async function probe(exec, cmd, args, opts = {}) {
7645
+ try {
7646
+ return {
7647
+ ok: true,
7648
+ result: await exec.run(cmd, args, {
7649
+ ...opts,
7650
+ dryRun: false
7651
+ })
7652
+ };
7653
+ } catch {
7654
+ return { ok: false };
7655
+ }
7656
+ }
7657
+ //#endregion
7658
+ //#region src/ship/state.ts
7659
+ /**
7660
+ * `amba ship` per-app idempotency state.
7661
+ *
7662
+ * Each app's launch progress is a JSON file at `.amba/ship-state/<slug>.json`
7663
+ * recording, per phase, whether it is done / failed / blocked-by-a-human-gate /
7664
+ * in-progress, when, and an optional structured `note` (e.g. the build ids a
7665
+ * later phase reads back). The orchestrator uses it to skip already-done phases
7666
+ * and to resume the "clear a gate → re-run" loop.
7667
+ *
7668
+ * PURITY RULE: this module NEVER reads the clock itself — callers pass an
7669
+ * ISO-8601 timestamp into every mutation so it stays deterministically
7670
+ * testable (the orchestrator owns the clock). The file is purely an
7671
+ * optimization/audit log: deleting it is safe because every phase re-detects
7672
+ * real cloud state before mutating.
7673
+ */
7674
+ const STATE_DIR = path.join(".amba", "ship-state");
7675
+ /** Path to the state file for a slug, under `<baseDir>/.amba/ship-state/`. */
7676
+ function shipStatePath(slug, baseDir = process.cwd()) {
7677
+ return path.join(baseDir, STATE_DIR, `${slug}.json`);
7678
+ }
7679
+ /** Load state for a slug, or a fresh empty state if none exists. Pure read. */
7680
+ function loadShipState(slug, baseDir = process.cwd()) {
7681
+ const file = shipStatePath(slug, baseDir);
7682
+ if (!fs.existsSync(file)) return {
7683
+ slug,
7684
+ stateVersion: 1,
7685
+ phases: {}
7686
+ };
7687
+ let parsed;
7688
+ try {
7689
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
7690
+ } catch (err) {
7691
+ 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).`);
7692
+ }
7693
+ 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.`);
7694
+ const state = parsed;
7695
+ if (state.slug !== slug) throw new Error(`State file ${file} is for slug "${state.slug}", expected "${slug}".`);
7696
+ if (!state.phases || typeof state.phases !== "object") state.phases = {};
7697
+ return state;
7698
+ }
7699
+ /** Write state atomically (write temp, then rename) to avoid partial files. */
7700
+ function saveShipState(state, baseDir = process.cwd()) {
7701
+ const file = shipStatePath(state.slug, baseDir);
7702
+ fs.mkdirSync(path.dirname(file), { recursive: true });
7703
+ const tmp = `${file}.tmp`;
7704
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
7705
+ fs.renameSync(tmp, file);
7706
+ }
7707
+ /**
7708
+ * Return a NEW state with one phase updated. Pure: does not touch disk and does
7709
+ * not read the clock — the caller passes `timestamp`. Use `saveShipState` to
7710
+ * persist.
7711
+ */
7712
+ function withPhase(state, phase, status, timestamp, note) {
7713
+ return {
7714
+ ...state,
7715
+ phases: {
7716
+ ...state.phases,
7717
+ [phase]: {
7718
+ status,
7719
+ updatedAt: timestamp,
7720
+ ...note !== void 0 ? { note } : {}
7721
+ }
7722
+ }
7723
+ };
7724
+ }
7725
+ /** True if a phase is recorded as done. Used by the orchestrator for idempotency. */
7726
+ function isPhaseDone(state, phase) {
7727
+ return state.phases[phase]?.status === "done";
7728
+ }
7729
+ //#endregion
7730
+ //#region src/ship/phases.ts
7731
+ const EAS = "eas";
7732
+ const EXEC_TIMEOUT_MS = 10 * 6e4;
7733
+ function easBuildArgs(platform, profile) {
7734
+ return [
7735
+ "build",
7736
+ "--platform",
7737
+ platform,
7738
+ "--profile",
7739
+ profile,
7740
+ "--non-interactive",
7741
+ "--json",
7742
+ "--no-wait"
7743
+ ];
7744
+ }
7745
+ function easSubmitArgs(input) {
7746
+ const selector = input.buildId ? ["--id", input.buildId] : ["--latest"];
7747
+ const track = input.track ? ["--track", input.track] : [];
7748
+ return [
7749
+ "submit",
7750
+ "--platform",
7751
+ input.platform,
7752
+ "--profile",
7753
+ input.profile,
7754
+ ...selector,
7755
+ ...track,
7756
+ "--non-interactive",
7757
+ "--json",
7758
+ "--no-wait"
7759
+ ];
7760
+ }
7761
+ function easBuildViewArgs(buildId) {
7762
+ return [
7763
+ "build:view",
7764
+ buildId,
7765
+ "--json",
7766
+ "--non-interactive"
7767
+ ];
7768
+ }
7769
+ function easMetadataArgs(profile) {
7770
+ return [
7771
+ "metadata:push",
7772
+ "--profile",
7773
+ profile,
7774
+ "--non-interactive"
7775
+ ];
7776
+ }
7777
+ /** Parse the first JSON value out of CLI stdout, tolerating a leaked banner. */
7778
+ function parseLooseJson(raw) {
7779
+ const trimmed = (raw || "").trim();
7780
+ if (!trimmed) return null;
7781
+ try {
7782
+ return JSON.parse(trimmed);
7783
+ } catch {
7784
+ const start = trimmed.search(/[[{]/);
7785
+ if (start === -1) return null;
7786
+ try {
7787
+ return JSON.parse(trimmed.slice(start));
7788
+ } catch {
7789
+ return null;
7790
+ }
7791
+ }
7792
+ }
7793
+ /** Pull the build object for `platform` out of `eas build --json` stdout. */
7794
+ function extractBuild(raw, platform) {
7795
+ const parsed = parseLooseJson(raw);
7796
+ if (parsed === null) throw new Error("eas build --json produced no parseable output. Is the Expo token set and the project linked (eas init)?");
7797
+ const builds = Array.isArray(parsed) ? parsed : [parsed];
7798
+ const want = platform.toUpperCase();
7799
+ const match = builds.find((b) => (b.platform ?? "").toUpperCase() === want);
7800
+ if (match?.id) return match;
7801
+ const sole = builds.length === 1 ? builds[0] : void 0;
7802
+ if (sole?.id && (!sole.platform || sole.platform.toUpperCase() === want)) return sole;
7803
+ throw new Error(`eas build --json did not return a build id for ${platform}.`);
7804
+ }
7805
+ function artifactUrlOf(b) {
7806
+ return b.artifacts?.applicationArchiveUrl ?? b.artifacts?.buildUrl ?? null;
7807
+ }
7808
+ /** Parse the submission object for `platform` out of `eas submit --json` stdout. */
7809
+ function parseSubmission(raw, platform) {
7810
+ const parsed = parseLooseJson(raw);
7811
+ if (parsed === null) return { platform };
7812
+ const subs = Array.isArray(parsed) ? parsed : [parsed];
7813
+ const want = platform.toUpperCase();
7814
+ const match = subs.find((s) => (s.platform ?? "").toUpperCase() === want);
7815
+ if (match) return match;
7816
+ const sole = subs.length === 1 ? subs[0] : void 0;
7817
+ if (sole && (!sole.platform || sole.platform.toUpperCase() === want)) return sole;
7818
+ return { platform };
7819
+ }
7820
+ function diagnoseBuildFailure(message) {
7821
+ const o = (message || "").toLowerCase();
7822
+ if (o.includes("not found") && o.includes("eas")) return "eas CLI not installed. Install it with `npm i -g eas-cli`, then re-run.";
7823
+ 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`.";
7824
+ 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.";
7825
+ 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.";
7826
+ 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.";
7827
+ return "See the eas build prerequisites (eas.json build profile, linked project, signing credentials).";
7828
+ }
7829
+ /** A submit failure that needs a human store action, or null for a plain error. */
7830
+ function diagnoseSubmitGate(platform, message) {
7831
+ const o = (message || "").toLowerCase();
7832
+ if (platform === "ios") {
7833
+ 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).";
7834
+ if (o.includes("export compliance") || o.includes("encryption")) return "The first upload needs an export-compliance answer in App Store Connect (appstoreconnect.apple.com).";
7835
+ } else {
7836
+ 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.";
7837
+ 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.";
7838
+ }
7839
+ return null;
7840
+ }
7841
+ function readNote(ctx, phase) {
7842
+ const raw = ctx.getNote(phase);
7843
+ if (!raw) return null;
7844
+ try {
7845
+ return JSON.parse(raw);
7846
+ } catch {
7847
+ return null;
7848
+ }
7849
+ }
7850
+ const TERMINAL_OK = "finished";
7851
+ const TERMINAL_BAD = new Set(["errored", "canceled"]);
7852
+ /**
7853
+ * The env to pass to every `eas` child. EAS reads the token from `EXPO_TOKEN`
7854
+ * specifically, so a config that names a DIFFERENT env var
7855
+ * (`secrets.expoTokenEnvVar`) must map that value onto `EXPO_TOKEN` — otherwise
7856
+ * a non-default name passes preflight while `eas build`/`submit` run
7857
+ * unauthenticated.
7858
+ */
7859
+ function easEnv(ctx) {
7860
+ const varName = ctx.config.secrets.expoTokenEnvVar;
7861
+ const token = ctx.env[varName];
7862
+ if (varName !== "EXPO_TOKEN" && token) return {
7863
+ ...ctx.env,
7864
+ EXPO_TOKEN: token
7865
+ };
7866
+ return ctx.env;
7867
+ }
7868
+ /** Config-derived store gates (no external call) — surfaced by preflight. */
7869
+ function storeGateWarnings(ctx) {
7870
+ const warnings = [];
7871
+ 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.");
7872
+ 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.");
7873
+ return warnings;
7874
+ }
7875
+ const runPreflight = async (ctx) => {
7876
+ const tokenVar = ctx.config.secrets.expoTokenEnvVar;
7877
+ if (ctx.dryRun) {
7878
+ ctx.log("would verify: eas installed + authenticated");
7879
+ const warnings = storeGateWarnings(ctx);
7880
+ for (const w of warnings) ctx.log(`warning: ${w}`);
7881
+ return {
7882
+ status: "done",
7883
+ detail: "dry-run: eas probes not executed"
7884
+ };
7885
+ }
7886
+ const warnings = [];
7887
+ if (!(await probe(ctx.exec, EAS, ["--version"])).ok) return {
7888
+ status: "failed",
7889
+ detail: "eas CLI not found on PATH. Install it with `npm i -g eas-cli`, then re-run preflight."
7890
+ };
7891
+ ctx.log("eas CLI installed");
7892
+ const tokenSet = (ctx.env[tokenVar] ?? "").trim().length > 0;
7893
+ if ((await probe(ctx.exec, EAS, ["whoami"], { env: easEnv(ctx) })).ok) ctx.log(`eas authenticated ${tokenSet ? `(via ${tokenVar})` : "(via stored CLI session)"}`);
7894
+ else if (tokenSet) ctx.log(`${tokenVar} is set (eas whoami unavailable — token used at build time)`);
7895
+ else warnings.push(`Not authenticated with EAS: set ${tokenVar} (CI) or run \`eas login\`. Build will fail without it.`);
7896
+ warnings.push(...storeGateWarnings(ctx));
7897
+ for (const w of warnings) ctx.log(`warning: ${w}`);
7898
+ return {
7899
+ status: "done",
7900
+ detail: warnings.length === 0 ? "all checks passed" : `${warnings.length} warning(s)`
7901
+ };
7902
+ };
7903
+ const runMonetization = async (ctx) => {
7904
+ if (!ctx.config.monetization.enabled) return {
7905
+ status: "skipped",
7906
+ detail: "monetization disabled in config"
7907
+ };
7908
+ const projectId = await ctx.resolveProjectId();
7909
+ if (!projectId) return {
7910
+ status: "skipped",
7911
+ detail: "no Amba project linked — run `amba init` to manage the store product catalog"
7912
+ };
7913
+ if (ctx.dryRun) return {
7914
+ status: "done",
7915
+ detail: `would verify the monetization catalog for project ${projectId}`
7916
+ };
7917
+ let plan;
7918
+ try {
7919
+ plan = (await ctx.adminGet(`/projects/${projectId}/monetization/plan`)).data;
7920
+ } catch (err) {
7921
+ if (err instanceof ApiClientError && (err.errorCode === "MONETIZATION_NOT_CONFIGURED" || err.errorCode === "MONETIZATION_NOT_MIGRATED")) return {
7922
+ status: "skipped",
7923
+ detail: "no monetization configured for this project (nothing to verify)"
7924
+ };
7925
+ return {
7926
+ status: "failed",
7927
+ detail: `could not verify monetization: ${err instanceof Error ? err.message : String(err)}. Fix API access (or set monetization.enabled=false) and re-run.`
7928
+ };
7929
+ }
7930
+ if (plan.clean) return {
7931
+ status: "done",
7932
+ detail: "store product catalog is in sync"
7933
+ };
7934
+ const gates = [];
7935
+ for (const op of plan.ops) gates.push(`${op.op} ${op.object_type} ${op.identifier}`);
7936
+ for (const sf of plan.store_floor) gates.push(`store floor: ${sf.product_key} → ${sf.store_identifier} (${sf.store}) — ${sf.reason}`);
7937
+ for (const h of plan.human_floor ?? []) gates.push(`${h.title} — ${h.detail} (${h.location})`);
7938
+ gates.push("Apply with `amba monetization apply`.");
7939
+ return {
7940
+ status: "gate",
7941
+ detail: `monetization catalog has ${plan.ops.length} pending change(s)`,
7942
+ gates
7943
+ };
7944
+ };
7945
+ const runBuild = async (ctx) => {
7946
+ const { profile } = ctx.config.build;
7947
+ const tokenVar = ctx.config.secrets.expoTokenEnvVar;
7948
+ if ((ctx.env[tokenVar] ?? "").trim().length === 0) ctx.log(`warning: ${tokenVar} not set — eas build needs it for non-interactive CI auth.`);
7949
+ const builds = { ...readNote(ctx, "build")?.builds };
7950
+ for (const platform of ctx.platforms) {
7951
+ const existing = builds[platform];
7952
+ if (existing && existing.appVersion === ctx.config.app.version && existing.profile === profile && !TERMINAL_BAD.has((existing.status || "").toLowerCase()) && !ctx.force) {
7953
+ ctx.log(`${platform}: reusing build ${existing.buildId} (${existing.status}) — pass --force to rebuild`);
7954
+ continue;
7955
+ }
7956
+ ctx.log(`${platform}: triggering eas ${profile} build…`);
7957
+ let stdout = "";
7958
+ let isDry = false;
7959
+ try {
7960
+ const res = await ctx.exec.run(EAS, easBuildArgs(platform, profile), {
7961
+ env: easEnv(ctx),
7962
+ timeoutMs: EXEC_TIMEOUT_MS,
7963
+ dryRun: ctx.dryRun
7964
+ });
7965
+ stdout = res.stdout;
7966
+ isDry = res.dryRun;
7967
+ } catch (err) {
7968
+ return {
7969
+ status: "failed",
7970
+ detail: `${platform} build failed: ${diagnoseBuildFailure(err instanceof Error ? err.message : String(err))}`,
7971
+ note: JSON.stringify({
7972
+ profile,
7973
+ appVersion: ctx.config.app.version,
7974
+ builds
7975
+ })
7976
+ };
7977
+ }
7978
+ const record = isDry ? {
7979
+ buildId: `dry-run-${platform}`,
7980
+ status: "dry-run",
7981
+ platform,
7982
+ artifactUrl: null,
7983
+ buildPageUrl: null,
7984
+ appVersion: ctx.config.app.version,
7985
+ profile,
7986
+ triggeredAt: ctx.now()
7987
+ } : (() => {
7988
+ const b = extractBuild(stdout, platform);
7989
+ return {
7990
+ buildId: b.id,
7991
+ status: b.status ?? "in-queue",
7992
+ platform,
7993
+ artifactUrl: artifactUrlOf(b),
7994
+ buildPageUrl: b.buildDetailsPageUrl ?? null,
7995
+ appVersion: b.appVersion ?? ctx.config.app.version,
7996
+ profile,
7997
+ triggeredAt: ctx.now()
7998
+ };
7999
+ })();
8000
+ builds[platform] = record;
8001
+ ctx.log(`${platform}: queued build ${record.buildId} (${record.status})`);
8002
+ ctx.checkpoint("build", "in-progress", JSON.stringify({
8003
+ profile,
8004
+ appVersion: ctx.config.app.version,
8005
+ builds
8006
+ }));
8007
+ }
8008
+ const note = {
8009
+ profile,
8010
+ appVersion: ctx.config.app.version,
8011
+ builds
8012
+ };
8013
+ return {
8014
+ status: "done",
8015
+ detail: Object.values(builds).map((r) => `${r.platform}=${r.buildId}(${r.status})`).join(" "),
8016
+ note: JSON.stringify(note)
8017
+ };
8018
+ };
8019
+ const runSubmit = async (ctx) => {
8020
+ const { profile } = ctx.config.build;
8021
+ const prior = readNote(ctx, "submit");
8022
+ const buildNote = readNote(ctx, "build");
8023
+ const submissions = { ...prior?.submissions };
8024
+ const gates = [];
8025
+ let failure = null;
8026
+ for (const platform of ctx.platforms) {
8027
+ if (platform === "ios" && !ctx.config.ios.ascAppId) {
8028
+ gates.push("iOS: create the App Store Connect app record (appstoreconnect.apple.com → Apps → +) and set ios.ascAppId, then re-run submit.");
8029
+ continue;
8030
+ }
8031
+ const buildId = buildNote?.builds?.[platform]?.buildId ?? null;
8032
+ if (!buildId) ctx.log(`${platform}: no recorded build id — submitting --latest (run the build phase first for a deterministic mapping)`);
8033
+ const existing = submissions[platform];
8034
+ if (existing && existing.buildId === buildId && !TERMINAL_BAD.has((existing.status || "").toLowerCase()) && !ctx.force) {
8035
+ ctx.log(`${platform}: already submitted for build ${buildId ?? "(latest)"} (${existing.status}) — pass --force to resubmit`);
8036
+ continue;
8037
+ }
8038
+ ctx.log(`${platform}: submitting to the store…`);
8039
+ const track = platform === "android" ? ctx.config.android.track : void 0;
8040
+ let stdout = "";
8041
+ let isDry = false;
8042
+ try {
8043
+ const res = await ctx.exec.run(EAS, easSubmitArgs({
8044
+ platform,
8045
+ profile,
8046
+ buildId,
8047
+ track
8048
+ }), {
8049
+ env: easEnv(ctx),
8050
+ timeoutMs: EXEC_TIMEOUT_MS,
8051
+ dryRun: ctx.dryRun
8052
+ });
8053
+ stdout = res.stdout;
8054
+ isDry = res.dryRun;
8055
+ } catch (err) {
8056
+ const msg = err instanceof Error ? err.message : String(err);
8057
+ const gate = diagnoseSubmitGate(platform, msg);
8058
+ if (gate) {
8059
+ gates.push(`${platform}: ${gate}`);
8060
+ continue;
8061
+ }
8062
+ failure = `${platform} submit failed: ${msg}`;
8063
+ break;
8064
+ }
8065
+ const record = isDry ? {
8066
+ submissionId: `dry-run-${platform}`,
8067
+ status: "dry-run",
8068
+ platform,
8069
+ buildId,
8070
+ detailsPageUrl: null,
8071
+ submittedAt: ctx.now()
8072
+ } : (() => {
8073
+ const s = parseSubmission(stdout, platform);
8074
+ return {
8075
+ submissionId: s.id ?? null,
8076
+ status: s.status ?? "in-queue",
8077
+ platform,
8078
+ buildId: s.buildId ?? buildId,
8079
+ detailsPageUrl: s.submissionDetailsPageUrl ?? null,
8080
+ submittedAt: ctx.now()
8081
+ };
8082
+ })();
8083
+ submissions[platform] = record;
8084
+ ctx.log(`${platform}: submitted (${record.status})`);
8085
+ ctx.checkpoint("submit", "in-progress", JSON.stringify({
8086
+ profile,
8087
+ submissions
8088
+ }));
8089
+ }
8090
+ const note = JSON.stringify({
8091
+ profile,
8092
+ submissions
8093
+ });
8094
+ if (failure) return {
8095
+ status: "failed",
8096
+ detail: failure,
8097
+ note
8098
+ };
8099
+ if (gates.length > 0) return {
8100
+ status: "gate",
8101
+ detail: `${gates.length} platform(s) need a store action`,
8102
+ note,
8103
+ gates
8104
+ };
8105
+ return {
8106
+ status: "done",
8107
+ detail: Object.values(submissions).map((r) => `${r.platform}=${r.status}`).join(" "),
8108
+ note
8109
+ };
8110
+ };
8111
+ const runMetadata = async (ctx) => {
8112
+ const reminders = [];
8113
+ if (ctx.platforms.includes("ios")) reminders.push("iOS: capture screenshots and complete the App Privacy questionnaire in App Store Connect.");
8114
+ if (ctx.platforms.includes("android")) reminders.push("Android: complete the Play listing (graphics, Data safety, Content rating) in Play Console.");
8115
+ if (ctx.platforms.includes("ios")) {
8116
+ ctx.log("pushing App Store listing metadata…");
8117
+ try {
8118
+ await ctx.exec.run(EAS, easMetadataArgs(ctx.config.build.profile), {
8119
+ env: easEnv(ctx),
8120
+ timeoutMs: EXEC_TIMEOUT_MS,
8121
+ dryRun: ctx.dryRun
8122
+ });
8123
+ ctx.log("App Store listing metadata pushed");
8124
+ } catch (err) {
8125
+ return {
8126
+ status: "gate",
8127
+ detail: `eas metadata:push did not run: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
8128
+ gates: ["Configure the App Store listing (eas.json metadata) or complete it in App Store Connect.", ...reminders]
8129
+ };
8130
+ }
8131
+ }
8132
+ for (const r of reminders) ctx.log(`reminder: ${r}`);
8133
+ return {
8134
+ status: "done",
8135
+ detail: "listing metadata pushed; complete the store-side checklist",
8136
+ gates: reminders
8137
+ };
8138
+ };
8139
+ /** Poll a build to a terminal state via `eas build:view`. Returns the last status. */
8140
+ async function waitForBuild(ctx, buildId) {
8141
+ if (ctx.dryRun) return TERMINAL_OK;
8142
+ const deadline = Date.now() + ctx.releaseMaxWaitMs;
8143
+ let last = "unknown";
8144
+ for (;;) {
8145
+ const res = await probe(ctx.exec, EAS, easBuildViewArgs(buildId), { env: easEnv(ctx) });
8146
+ if (res.ok && res.result) {
8147
+ if (res.result.dryRun) return TERMINAL_OK;
8148
+ const parsed = parseLooseJson(res.result.stdout);
8149
+ last = ((Array.isArray(parsed) ? parsed[0] : parsed)?.status ?? last ?? "unknown").toLowerCase();
8150
+ }
8151
+ if (last === TERMINAL_OK || TERMINAL_BAD.has(last)) return last;
8152
+ if (Date.now() >= deadline) return last;
8153
+ ctx.log(`build ${buildId}: ${last} — re-checking…`);
8154
+ await ctx.sleep(ctx.releasePollMs);
8155
+ }
8156
+ }
8157
+ const runRelease = async (ctx) => {
8158
+ const profile = ctx.config.build.profile;
8159
+ const prior = readNote(ctx, "release");
8160
+ const buildNote = readNote(ctx, "build");
8161
+ const submitNote = readNote(ctx, "submit");
8162
+ const releases = { ...prior?.releases };
8163
+ const gates = [];
8164
+ let failure = null;
8165
+ for (const platform of ctx.platforms) {
8166
+ const buildRec = buildNote?.builds?.[platform];
8167
+ if (!buildRec?.buildId) {
8168
+ gates.push(`${platform}: no recorded build — run \`amba ship --phase build\` first.`);
8169
+ releases[platform] = {
8170
+ platform,
8171
+ buildId: null,
8172
+ buildStatus: "unknown",
8173
+ releaseState: "pending-build",
8174
+ gate: true,
8175
+ detail: "No recorded build."
8176
+ };
8177
+ continue;
8178
+ }
8179
+ ctx.log(`${platform}: waiting for build ${buildRec.buildId} to finish…`);
8180
+ const buildStatus = await waitForBuild(ctx, buildRec.buildId);
8181
+ if (TERMINAL_BAD.has(buildStatus)) {
8182
+ failure = `${platform}: build ${buildRec.buildId} is ${buildStatus}; re-run the build phase.`;
8183
+ releases[platform] = {
8184
+ platform,
8185
+ buildId: buildRec.buildId,
8186
+ buildStatus,
8187
+ releaseState: "blocked",
8188
+ gate: false,
8189
+ detail: failure
8190
+ };
8191
+ break;
8192
+ }
8193
+ if (buildStatus !== TERMINAL_OK) {
8194
+ gates.push(`${platform}: build ${buildRec.buildId} still ${buildStatus} — re-run release once it finishes.`);
8195
+ releases[platform] = {
8196
+ platform,
8197
+ buildId: buildRec.buildId,
8198
+ buildStatus,
8199
+ releaseState: "pending-build",
8200
+ gate: true,
8201
+ detail: `Build still ${buildStatus}.`
8202
+ };
8203
+ continue;
8204
+ }
8205
+ if (platform === "ios") {
8206
+ gates.push("iOS: in App Store Connect, attach the build to a version, then \"Add for Review\" → \"Submit for Review\" (appstoreconnect.apple.com).");
8207
+ releases[platform] = {
8208
+ platform,
8209
+ buildId: buildRec.buildId,
8210
+ buildStatus,
8211
+ releaseState: "in-testflight",
8212
+ gate: true,
8213
+ detail: "Uploaded to App Store Connect / TestFlight; Submit-for-Review is manual."
8214
+ };
8215
+ continue;
8216
+ }
8217
+ const priorAndroid = releases[platform];
8218
+ if (priorAndroid && priorAndroid.buildId === buildRec.buildId && priorAndroid.releaseState === "promoted-to-production" && !ctx.force) {
8219
+ ctx.log("android: build already promoted to production — pass --force to re-promote");
8220
+ continue;
8221
+ }
8222
+ if (ctx.config.android.track === "production") {
8223
+ if (!submitNote?.submissions?.["android"]) {
8224
+ gates.push("android: nothing submitted to Play yet — run `amba ship --phase submit` first (your track is \"production\").");
8225
+ releases[platform] = {
8226
+ platform,
8227
+ buildId: buildRec.buildId,
8228
+ buildStatus,
8229
+ releaseState: "blocked",
8230
+ gate: true,
8231
+ detail: "No submission recorded; run the submit phase before release."
8232
+ };
8233
+ continue;
8234
+ }
8235
+ releases[platform] = {
8236
+ platform,
8237
+ buildId: buildRec.buildId,
8238
+ buildStatus,
8239
+ releaseState: "promoted-to-production",
8240
+ gate: false,
8241
+ detail: "Submitted directly to the production track."
8242
+ };
8243
+ ctx.log("android: already on the production track");
8244
+ continue;
8245
+ }
8246
+ ctx.log(`android: promoting build ${buildRec.buildId} to the production track…`);
8247
+ try {
8248
+ await ctx.exec.run(EAS, easSubmitArgs({
8249
+ platform: "android",
8250
+ profile,
8251
+ buildId: buildRec.buildId,
8252
+ track: "production"
8253
+ }), {
8254
+ env: easEnv(ctx),
8255
+ timeoutMs: EXEC_TIMEOUT_MS,
8256
+ dryRun: ctx.dryRun
8257
+ });
8258
+ releases[platform] = {
8259
+ platform,
8260
+ buildId: buildRec.buildId,
8261
+ buildStatus,
8262
+ releaseState: "promoted-to-production",
8263
+ gate: false,
8264
+ detail: "Promoted internal → production via eas submit."
8265
+ };
8266
+ ctx.log("android: promotion to production queued");
8267
+ } catch (err) {
8268
+ const msg = err instanceof Error ? err.message : String(err);
8269
+ const gate = diagnoseSubmitGate("android", msg);
8270
+ releases[platform] = {
8271
+ platform,
8272
+ buildId: buildRec.buildId,
8273
+ buildStatus,
8274
+ releaseState: "blocked",
8275
+ gate: gate !== null,
8276
+ detail: gate ?? msg
8277
+ };
8278
+ if (gate) gates.push(`android: ${gate}`);
8279
+ else failure = `android promotion failed: ${msg}`;
8280
+ if (failure) break;
8281
+ }
8282
+ }
8283
+ const note = JSON.stringify({
8284
+ profile,
8285
+ releases
8286
+ });
8287
+ if (failure) return {
8288
+ status: "failed",
8289
+ detail: failure,
8290
+ note
8291
+ };
8292
+ if (gates.length > 0) return {
8293
+ status: "gate",
8294
+ detail: `${gates.length} platform(s) need a store action`,
8295
+ note,
8296
+ gates
8297
+ };
8298
+ return {
8299
+ status: "done",
8300
+ detail: Object.values(releases).map((r) => `${r.platform}=${r.releaseState}`).join(" "),
8301
+ note
8302
+ };
8303
+ };
8304
+ const PHASE_RUNNERS = {
8305
+ preflight: runPreflight,
8306
+ monetization: runMonetization,
8307
+ build: runBuild,
8308
+ submit: runSubmit,
8309
+ metadata: runMetadata,
8310
+ release: runRelease
8311
+ };
8312
+ //#endregion
8313
+ //#region src/ship/orchestrator.ts
8314
+ /** Phases that ship a specific binary version (invalidated on a version bump). */
8315
+ const VERSION_SCOPED_PHASES = [
8316
+ "build",
8317
+ "submit",
8318
+ "metadata",
8319
+ "release"
8320
+ ];
8321
+ /** The canonical run order, filtered to the requested subset. */
8322
+ function orderedPhases(requested) {
8323
+ const want = new Set(requested);
8324
+ return SHIP_PHASES.filter((p) => want.has(p));
8325
+ }
8326
+ async function defaultResolveProjectId() {
8327
+ try {
8328
+ const { projectId } = await loadProjectConfig();
8329
+ return projectId;
8330
+ } catch {
8331
+ return null;
8332
+ }
8333
+ }
8334
+ async function runShip(opts) {
8335
+ const slug = opts.config.app.slug;
8336
+ const baseDir = opts.stateBaseDir ?? process.cwd();
8337
+ const now = opts.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
8338
+ const log = opts.log ?? (() => {});
8339
+ const runners = opts.runners ?? PHASE_RUNNERS;
8340
+ let state = loadShipState(slug, baseDir);
8341
+ if (state.appVersion !== void 0 && state.appVersion !== opts.config.app.version) {
8342
+ const phases = { ...state.phases };
8343
+ for (const p of VERSION_SCOPED_PHASES) delete phases[p];
8344
+ state = {
8345
+ ...state,
8346
+ phases
8347
+ };
8348
+ }
8349
+ state = {
8350
+ ...state,
8351
+ appVersion: opts.config.app.version
8352
+ };
8353
+ const persist = (phase, status, note) => {
8354
+ state = withPhase(state, phase, status, now(), note);
8355
+ if (!opts.dryRun) saveShipState(state, baseDir);
8356
+ };
8357
+ const ctx = {
8358
+ config: opts.config,
8359
+ exec: opts.exec,
8360
+ platforms: opts.platforms,
8361
+ dryRun: opts.dryRun,
8362
+ force: opts.force,
8363
+ env: opts.env ?? process.env,
8364
+ now,
8365
+ log,
8366
+ getNote: (phase) => state.phases[phase]?.note ?? null,
8367
+ checkpoint: (phase, status, note) => persist(phase, status, note),
8368
+ resolveProjectId: opts.resolveProjectId ?? defaultResolveProjectId,
8369
+ adminGet: opts.adminGet ?? adminGet,
8370
+ sleep: opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
8371
+ releasePollMs: opts.releasePollMs ?? 3e4,
8372
+ releaseMaxWaitMs: opts.releaseMaxWaitMs ?? 45 * 6e4
8373
+ };
8374
+ const rows = [];
8375
+ let blocked = false;
8376
+ let failed = false;
8377
+ for (const phase of orderedPhases(opts.phases)) {
8378
+ if (!opts.force && isPhaseDone(state, phase)) {
8379
+ const rec = state.phases[phase];
8380
+ rows.push({
8381
+ phase,
8382
+ status: "skipped",
8383
+ detail: `already done ${rec?.updatedAt ?? ""}`.trim()
8384
+ });
8385
+ continue;
8386
+ }
8387
+ if ((blocked || failed) && !opts.dryRun) {
8388
+ rows.push({
8389
+ phase,
8390
+ status: "pending",
8391
+ detail: "not reached (earlier phase needs action)"
8392
+ });
8393
+ continue;
8394
+ }
8395
+ const outcome = await runners[phase](ctx);
8396
+ if (opts.dryRun) {
8397
+ const status = outcome.status === "done" ? "dry-run" : outcome.status;
8398
+ rows.push({
8399
+ phase,
8400
+ status,
8401
+ detail: outcome.detail,
8402
+ ...outcome.gates ? { gates: outcome.gates } : {}
8403
+ });
8404
+ if (outcome.status === "failed") failed = true;
8405
+ continue;
8406
+ }
8407
+ switch (outcome.status) {
8408
+ case "done":
8409
+ persist(phase, "done", outcome.note);
8410
+ rows.push({
8411
+ phase,
8412
+ status: "done",
8413
+ detail: outcome.detail,
8414
+ ...outcome.gates ? { gates: outcome.gates } : {}
8415
+ });
8416
+ break;
8417
+ case "skipped":
8418
+ rows.push({
8419
+ phase,
8420
+ status: "skipped",
8421
+ detail: outcome.detail
8422
+ });
8423
+ break;
8424
+ case "gate":
8425
+ persist(phase, "gate", outcome.note);
8426
+ rows.push({
8427
+ phase,
8428
+ status: "gate",
8429
+ detail: outcome.detail,
8430
+ ...outcome.gates ? { gates: outcome.gates } : {}
8431
+ });
8432
+ blocked = true;
8433
+ break;
8434
+ case "failed":
8435
+ persist(phase, "failed", outcome.note);
8436
+ rows.push({
8437
+ phase,
8438
+ status: "failed",
8439
+ detail: outcome.detail
8440
+ });
8441
+ failed = true;
8442
+ break;
8443
+ }
8444
+ }
8445
+ return {
8446
+ rows,
8447
+ blocked,
8448
+ failed
8449
+ };
8450
+ }
8451
+ //#endregion
8452
+ //#region src/commands/ship.ts
8453
+ /**
8454
+ * `amba ship` — take this Expo app from code-complete to live on the App Store
8455
+ * and Google Play, and `amba ship init` to scaffold its config.
8456
+ *
8457
+ * amba ship run the full pipeline (preflight → monetization →
8458
+ * build → submit → metadata → release)
8459
+ * amba ship --phase build run a single phase
8460
+ * amba ship --dry-run print the plan; touch nothing
8461
+ * amba ship init scaffold amba.ship.json
8462
+ *
8463
+ * Build/submit/release shell out to `eas` (the Expo toolchain owns the build,
8464
+ * the signing credentials, and the store submit). Monetization delegates to the
8465
+ * Amba control plane (`amba monetization`). Steps no API can do — create the
8466
+ * store app record, the Apple Paid-Apps agreement, screenshots, store review —
8467
+ * are detected and surfaced as manual gates with the exact console action.
8468
+ */
8469
+ /** Read + parse + validate a ship config file. Throws with every error joined. */
8470
+ async function loadShipConfig(file) {
8471
+ const path = resolve(file);
8472
+ let raw;
8473
+ try {
8474
+ raw = await readFile(path, "utf-8");
8475
+ } catch {
8476
+ throw new Error(`Config not found: ${file}. Run ${pc.cyan("amba ship init")} to scaffold one.`);
8477
+ }
8478
+ let parsed;
8479
+ try {
8480
+ parsed = JSON.parse(raw);
8481
+ } catch (err) {
8482
+ throw new Error(`Failed to parse ${file} as JSON: ${err instanceof Error ? err.message : String(err)}`);
8483
+ }
8484
+ const result = validateShipConfig(parsed);
8485
+ if (!result.ok) throw new Error(`Invalid ${file}:\n${result.errors.map((e) => ` • ${e}`).join("\n")}`);
8486
+ return result.config;
8487
+ }
8488
+ function parseShipPhase(raw) {
8489
+ if (raw === void 0 || raw === "all") return "all";
8490
+ if (SHIP_PHASES.includes(raw)) return raw;
8491
+ throw new Error(`Unknown --phase "${raw}". Expected one of: ${SHIP_PHASES.join(", ")}, all.`);
8492
+ }
8493
+ /** Resolve the platforms to act on: --platform override, else the config's. */
8494
+ function resolvePlatforms(raw, configPlatforms) {
8495
+ if (raw === void 0 || raw === "all") return configPlatforms;
8496
+ if (!SHIP_PLATFORMS.includes(raw)) throw new Error(`Unknown --platform "${raw}". Expected one of: ${SHIP_PLATFORMS.join(", ")}, all.`);
8497
+ const platform = raw;
8498
+ if (!configPlatforms.includes(platform)) throw new Error(`Platform "${platform}" is not in build.platforms (${configPlatforms.join(", ")}).`);
8499
+ return [platform];
8500
+ }
8501
+ const STATUS_BADGE = {
8502
+ done: pc.green("done"),
8503
+ "dry-run": pc.cyan("dry-run"),
8504
+ skipped: pc.dim("skipped"),
8505
+ gate: pc.yellow("GATE"),
8506
+ failed: pc.red("failed"),
8507
+ pending: pc.dim("pending")
8508
+ };
8509
+ function renderTable(rows) {
8510
+ console.log();
8511
+ for (const row of rows) console.log(` ${row.phase.padEnd(13)} ${STATUS_BADGE[row.status].padEnd(18)} ${pc.dim(row.detail)}`);
8512
+ console.log();
8513
+ }
8514
+ function renderGates(rows) {
8515
+ const gateRows = rows.filter((r) => r.gates && r.gates.length > 0);
8516
+ if (gateRows.length === 0) return;
8517
+ console.log(pc.yellow(" Manual gates — steps no API can do (with exact locations):"));
8518
+ for (const row of gateRows) for (const gate of row.gates ?? []) console.log(` ${pc.bold("□")} ${pc.dim(`[${row.phase}]`)} ${gate}`);
8519
+ console.log();
8520
+ }
8521
+ async function shipCommand(options = {}, deps = {}) {
8522
+ const config = await loadShipConfig(options.config ?? "amba.ship.json");
8523
+ const phase = parseShipPhase(options.phase);
8524
+ const platforms = resolvePlatforms(options.platform, config.build.platforms);
8525
+ const dryRun = options.dryRun === true;
8526
+ const phases = phase === "all" ? [...SHIP_PHASES] : [phase];
8527
+ console.log();
8528
+ console.log(pc.bold(` amba ship ${pc.cyan(config.app.name)} ${pc.dim(`v${config.app.version}`)}`));
8529
+ console.log(pc.dim(" ─────────────────────────────────"));
8530
+ console.log(pc.dim(` phase=${phase} · platforms=${platforms.join(",")}${dryRun ? " · DRY-RUN" : ""}${options.force ? " · FORCE" : ""}`));
8531
+ const result = await runShip({
8532
+ config,
8533
+ phases,
8534
+ platforms,
8535
+ exec: deps.exec ?? createNodeExecutor(dryRun),
8536
+ dryRun,
8537
+ force: options.force === true,
8538
+ log: (line) => console.log(pc.dim(` ${line}`)),
8539
+ ...deps.stateBaseDir !== void 0 ? { stateBaseDir: deps.stateBaseDir } : {},
8540
+ ...deps.resolveProjectId ? { resolveProjectId: deps.resolveProjectId } : {},
8541
+ ...deps.adminGet ? { adminGet: deps.adminGet } : {},
8542
+ ...deps.now ? { now: deps.now } : {},
8543
+ ...deps.sleep ? { sleep: deps.sleep } : {},
8544
+ ...deps.releasePollMs !== void 0 ? { releasePollMs: deps.releasePollMs } : {},
8545
+ ...deps.releaseMaxWaitMs !== void 0 ? { releaseMaxWaitMs: deps.releaseMaxWaitMs } : {}
8546
+ });
8547
+ renderTable(result.rows);
8548
+ renderGates(result.rows);
8549
+ if (result.failed) {
8550
+ const bad = result.rows.find((r) => r.status === "failed");
8551
+ throw new Error(`Launch failed${bad ? ` at ${bad.phase}` : ""}: ${bad?.detail ?? "see above"}`);
8552
+ }
8553
+ if (dryRun) {
8554
+ console.log(pc.green(" ✓") + " Dry-run complete — nothing was changed.");
8555
+ console.log();
8556
+ return;
8557
+ }
8558
+ if (result.blocked) {
8559
+ const gates = result.rows.filter((r) => r.status === "gate").length;
8560
+ 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("."));
8561
+ console.log();
8562
+ return;
8563
+ }
8564
+ console.log(pc.green(" ✓") + " Launch run complete.");
8565
+ console.log();
8566
+ }
8567
+ async function shipInitCommand(options = {}) {
8568
+ const file = options.config ?? "amba.ship.json";
8569
+ const path = resolve(file);
8570
+ if (!options.force) {
8571
+ if (await access(path).then(() => true, () => false)) throw new Error(`${file} already exists. Pass ${pc.cyan("--force")} to overwrite it.`);
8572
+ }
8573
+ const template = shipConfigTemplate((options.slug ?? "my-app").toLowerCase());
8574
+ await writeFile(path, JSON.stringify(template, null, 2) + "\n", "utf-8");
8575
+ console.log();
8576
+ console.log(pc.green(" ✓") + ` Wrote ${pc.cyan(file)}.`);
8577
+ console.log(pc.dim(" Edit the app IDs, then ") + pc.cyan("amba ship --phase preflight") + pc.dim(" to check your setup."));
8578
+ console.log();
8579
+ }
8580
+ //#endregion
7261
8581
  //#region src/_internal/codegen.ts
7262
8582
  /** Read every collection schema and emit a single `.amba/types.d.ts` body. */
7263
8583
  async function generateCollectionTypes(input) {
@@ -8210,7 +9530,7 @@ async function diffConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
8210
9530
  //#endregion
8211
9531
  //#region src/index.ts
8212
9532
  const program = new Command();
8213
- program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("4.0.4");
9533
+ program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("4.1.1");
8214
9534
  program.option("--token <pat>", "Use a Personal Access Token for headless / CI / agent use (overrides ~/.amba/credentials.json + AMBA_PAT env)");
8215
9535
  program.hook("preAction", (thisCommand) => {
8216
9536
  setBearerOverride(resolveTokenSource({
@@ -8370,12 +9690,13 @@ functions.command("dev <file>").description("Run a local dev server for your fun
8370
9690
  noWatch: opts.watch === false
8371
9691
  }));
8372
9692
  });
8373
- functions.command("logs <name>").description("Stream log events for a deployed function").option("--since <iso>", "Start of the time range (default: 1 hour ago)").option("--until <iso>", "End of the time range (default: now). Ignored on --tail.").option("--limit <n>", "Max events per fetch (default 100, max 1000)", (v) => parseInt(v, 10)).option("--request-id <id>", "Only show events whose logs mention this request id (e.g. r_...). Works with --tail.").option("--tail", "Follow new events; polls every 3s. Ctrl+C to stop.").option("--follow", "Alias for --tail (kept for backwards compatibility with v1 log commands).").option("--json", "NDJSON output to stdout (one event per line)").action(async (name, opts) => {
9693
+ 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) => {
8374
9694
  await runAction(() => functionsLogsCommand(name, {
8375
9695
  since: opts.since,
8376
9696
  until: opts.until,
8377
9697
  limit: opts.limit,
8378
9698
  requestId: opts.requestId,
9699
+ sinceRequestId: opts.sinceRequestId,
8379
9700
  tail: opts.tail || opts.follow,
8380
9701
  json: opts.json
8381
9702
  }));
@@ -8492,8 +9813,23 @@ monetization.command("adopt").description("Record the live config as the managed
8492
9813
  monetization.command("apply").description("Apply the declared config to the provider. Additive ops (create entitlements/offerings/packages/paywall-drafts + attach products) apply automatically; destructive ops (detach/archive/remove) need --confirm.").option("--confirm", "Confirm and execute the gated destructive ops (detach/archive/remove). Without it they are reported but not run.").option("--allow-detach-live", "Accept detaching a product a live customer currently resolves (otherwise refused).").option("--reconcile <mode>", "Out-of-band drift mode: 'amba' (default) or 'adopt'.").action(async (opts) => {
8493
9814
  await runAction(() => monetizationApplyCommand(opts));
8494
9815
  });
9816
+ 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) => {
9817
+ await runAction(() => shipCommand({
9818
+ phase: opts.phase,
9819
+ platform: opts.platform,
9820
+ dryRun: opts.dryRun,
9821
+ force: opts.force,
9822
+ config: opts.config
9823
+ }));
9824
+ }).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) => {
9825
+ await runAction(() => shipInitCommand({
9826
+ config: opts.config,
9827
+ slug: opts.slug,
9828
+ force: opts.force
9829
+ }));
9830
+ });
8495
9831
  const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
8496
- collections.command("create <name>").description("Create a collection with the given fields").option("--field <spec>", "Field spec: name:type[:nullable] (e.g. user_id:uuid, parsed:jsonb:nullable). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--index <spec>", "Index spec: \"col1 [asc|desc], col2 [asc|desc]\". Repeatable.", (val, prev) => [...prev ?? [], val], []).action(async (name, opts) => {
9832
+ 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) => {
8497
9833
  await runAction(() => collectionsCreateCommand(name, opts));
8498
9834
  });
8499
9835
  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) => {
@@ -8509,7 +9845,7 @@ program.command("types").description("TypeScript codegen for collections").comma
8509
9845
  await runAction(() => typesGenerateCommand(opts));
8510
9846
  });
8511
9847
  const sites = program.command("sites").description("Static site hosting");
8512
- 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) => {
9848
+ 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) => {
8513
9849
  await runAction(() => sitesDeployCommand(dir, opts));
8514
9850
  });
8515
9851
  sites.command("list").description("List sites for the current project").action(async () => {