@layers/amba 4.0.4 → 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,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
  }
@@ -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,20 @@ 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) 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.
6832
6909
  *
6833
6910
  * Project is resolved via `loadProjectConfig` — same pattern as the other
6834
6911
  * project-scoped CLI commands.
@@ -6885,16 +6962,31 @@ async function monetizationPlanCommand() {
6885
6962
  console.log();
6886
6963
  }
6887
6964
  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})`);
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
+ }
6890
6970
  console.log();
6891
6971
  }
6972
+ printHumanFloor(plan.human_floor);
6892
6973
  if (plan.drift.length > 0) {
6893
6974
  console.log(pc.yellow(" Drift — managed objects changed out-of-band in the provider:"));
6894
6975
  for (const d of plan.drift) console.log(` ${pc.bold(d.object_type)} ${d.identifier}`);
6895
6976
  console.log();
6896
6977
  }
6897
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
+ }
6898
6990
  async function monetizationDriftCommand() {
6899
6991
  const { projectId } = await loadProjectConfig();
6900
6992
  header("drift");
@@ -6939,10 +7031,13 @@ async function monetizationApplyCommand(options = {}) {
6939
7031
  header("apply");
6940
7032
  const plan = (await adminGet(`/projects/${projectId}/monetization/plan`)).data;
6941
7033
  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})`);
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
+ }
6944
7039
  console.log();
6945
- console.log(" ✗ Configure these in the store, then run plan + apply again.");
7040
+ console.log(" ✗ Fix the declaration (or connect the app), then run plan + apply again.");
6946
7041
  process.exit(1);
6947
7042
  }
6948
7043
  if (plan.clean) {
@@ -6958,49 +7053,77 @@ async function monetizationApplyCommand(options = {}) {
6958
7053
  if (options.confirm) body["confirm"] = plan.plan_hash;
6959
7054
  if (options.allowDetachLive) body["allow_detach_live"] = true;
6960
7055
  if (options.reconcile) body["reconcile"] = options.reconcile;
6961
- let res;
7056
+ let start;
6962
7057
  try {
6963
- res = await adminPost(`/projects/${projectId}/monetization/apply`, body);
7058
+ start = (await adminPost(`/projects/${projectId}/monetization/apply`, body)).data;
6964
7059
  } 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}`));
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."));
6975
7063
  console.log();
6976
7064
  process.exit(1);
6977
7065
  }
6978
7066
  throw err;
6979
7067
  }
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));
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}`);
6984
7071
  console.log();
6985
7072
  }
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}`));
7073
+ if (start.status === "succeeded") {
7074
+ console.log(pc.green(" ✓") + " Nothing to apply — declared config matches the provider.");
7075
+ console.log();
7076
+ return;
6991
7077
  }
6992
- if (d.gated.length > 0) {
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
+ }
6993
7085
  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}`);
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);
6996
7094
  }
6997
- if (d.refused.length > 0) {
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));
6998
7101
  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
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}`));
7002
7111
  console.log();
7003
- if (d.status !== "succeeded") process.exit(1);
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
+ }
7004
7127
  }
7005
7128
  //#endregion
7006
7129
  //#region src/commands/collections.ts
@@ -7044,7 +7167,13 @@ const ALLOWED_TYPES = new Set([
7044
7167
  "timestamptz",
7045
7168
  "date",
7046
7169
  "jsonb",
7047
- "vector"
7170
+ "vector",
7171
+ "text[]",
7172
+ "integer[]",
7173
+ "bigint[]",
7174
+ "numeric[]",
7175
+ "boolean[]",
7176
+ "uuid[]"
7048
7177
  ]);
7049
7178
  const TYPE_SYNONYMS = {
7050
7179
  int: "integer",
@@ -7111,7 +7240,10 @@ async function collectionsDropCommand(name, options = {}) {
7111
7240
  }
7112
7241
  function normalizeColumnType(raw) {
7113
7242
  const baseRaw = (raw.split("(")[0] ?? raw).trim().toLowerCase();
7114
- 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;
7115
7247
  if (!ALLOWED_TYPES.has(base)) throw new Error(`Unknown type '${raw}'. Allowed: ${[...ALLOWED_TYPES].join(", ")}.`);
7116
7248
  return base;
7117
7249
  }
@@ -7121,7 +7253,11 @@ function normalizeColumnType(raw) {
7121
7253
  * Accepted grammars:
7122
7254
  *
7123
7255
  * 1. **Plain column:** `name:type[:nullable]`
7124
- * 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.
7125
7261
  *
7126
7262
  * 2. **Foreign key:** `name:type:fk(<table>[.<column>][:onDelete])`
7127
7263
  * Examples:
@@ -7148,12 +7284,14 @@ function normalizeColumnType(raw) {
7148
7284
  * left-to-right: the third segment is the FK / vector / nullable
7149
7285
  * marker; if it's an FK or vector, the optional fourth segment is
7150
7286
  * `nullable`. Plain columns put `nullable` in slot 3.
7287
+ *
7288
+ * Exported for tests.
7151
7289
  */
7152
7290
  function parseColumnSpec(spec) {
7153
7291
  const parts = spec.replace(/^([a-z_][a-z0-9_]*):vector\((\d+)\)/i, "$1:vector:$2").replace(/fk\(([^)]+)\)/i, (_match, inner) => {
7154
7292
  return `fk(${inner.replace(/:/g, "|")})`;
7155
7293
  }).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).`);
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).`);
7157
7295
  const colName = parts[0];
7158
7296
  const type = parts[1];
7159
7297
  const slot3 = parts[2];
@@ -7243,6 +7381,8 @@ function normalizeOnDelete(raw) {
7243
7381
  *
7244
7382
  * Bare column names (no direction) are passed through verbatim; the
7245
7383
  * default sort direction is the DDL emit's responsibility.
7384
+ *
7385
+ * Exported for tests.
7246
7386
  */
7247
7387
  function parseIndexSpec(spec) {
7248
7388
  const cols = [];
@@ -7258,6 +7398,1184 @@ function parseIndexSpec(spec) {
7258
7398
  return { columns: cols };
7259
7399
  }
7260
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
+ }
8280
+ }
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;
8330
+ }
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;
8441
+ }
8442
+ }
8443
+ return {
8444
+ rows,
8445
+ blocked,
8446
+ failed
8447
+ };
8448
+ }
8449
+ //#endregion
8450
+ //#region src/commands/ship.ts
8451
+ /**
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.
8466
+ */
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.`);
8475
+ }
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();
8511
+ }
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;
8561
+ }
8562
+ console.log(pc.green(" ✓") + " Launch run complete.");
8563
+ console.log();
8564
+ }
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.`);
8570
+ }
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();
8577
+ }
8578
+ //#endregion
7261
8579
  //#region src/_internal/codegen.ts
7262
8580
  /** Read every collection schema and emit a single `.amba/types.d.ts` body. */
7263
8581
  async function generateCollectionTypes(input) {
@@ -8210,7 +9528,7 @@ async function diffConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
8210
9528
  //#endregion
8211
9529
  //#region src/index.ts
8212
9530
  const program = new Command();
8213
- program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("4.0.4");
9531
+ program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("4.1.0");
8214
9532
  program.option("--token <pat>", "Use a Personal Access Token for headless / CI / agent use (overrides ~/.amba/credentials.json + AMBA_PAT env)");
8215
9533
  program.hook("preAction", (thisCommand) => {
8216
9534
  setBearerOverride(resolveTokenSource({
@@ -8370,12 +9688,13 @@ functions.command("dev <file>").description("Run a local dev server for your fun
8370
9688
  noWatch: opts.watch === false
8371
9689
  }));
8372
9690
  });
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) => {
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) => {
8374
9692
  await runAction(() => functionsLogsCommand(name, {
8375
9693
  since: opts.since,
8376
9694
  until: opts.until,
8377
9695
  limit: opts.limit,
8378
9696
  requestId: opts.requestId,
9697
+ sinceRequestId: opts.sinceRequestId,
8379
9698
  tail: opts.tail || opts.follow,
8380
9699
  json: opts.json
8381
9700
  }));
@@ -8492,8 +9811,23 @@ monetization.command("adopt").description("Record the live config as the managed
8492
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) => {
8493
9812
  await runAction(() => monetizationApplyCommand(opts));
8494
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
+ });
8495
9829
  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) => {
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) => {
8497
9831
  await runAction(() => collectionsCreateCommand(name, opts));
8498
9832
  });
8499
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) => {
@@ -8509,7 +9843,7 @@ program.command("types").description("TypeScript codegen for collections").comma
8509
9843
  await runAction(() => typesGenerateCommand(opts));
8510
9844
  });
8511
9845
  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) => {
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) => {
8513
9847
  await runAction(() => sitesDeployCommand(dir, opts));
8514
9848
  });
8515
9849
  sites.command("list").description("List sites for the current project").action(async () => {