@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/README.md +3 -3
- package/dist/api-client.d.ts +4 -1
- package/dist/commands/collections.d.ts +55 -0
- package/dist/commands/functions-logs.d.ts +11 -0
- package/dist/commands/monetization.d.ts +16 -4
- package/dist/commands/ship.d.ts +47 -0
- package/dist/index.js +1395 -59
- package/dist/ship/config.d.ts +98 -0
- package/dist/ship/exec.d.ts +53 -0
- package/dist/ship/orchestrator.d.ts +52 -0
- package/dist/ship/phases.d.ts +87 -0
- package/dist/ship/state.d.ts +52 -0
- package/package.json +2 -2
- package/skill-bundle/SKILL.md +1 -1
- package/skill-bundle/references/infrastructure.md +3 -3
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
6575
|
-
console.log(formatAxis("
|
|
6576
|
-
console.log(formatAxis("
|
|
6577
|
-
console.log(formatAxis("
|
|
6578
|
-
console.log(formatAxis("
|
|
6579
|
-
console.log(formatAxis("
|
|
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
|
-
*
|
|
6829
|
-
*
|
|
6830
|
-
*
|
|
6831
|
-
*
|
|
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 —
|
|
6889
|
-
for (const sf of plan.store_floor)
|
|
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 —
|
|
6943
|
-
for (const sf of plan.store_floor)
|
|
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(" ✗
|
|
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
|
|
7058
|
+
let start;
|
|
6962
7059
|
try {
|
|
6963
|
-
|
|
7060
|
+
start = (await adminPost(`/projects/${projectId}/monetization/apply`, body)).data;
|
|
6964
7061
|
} catch (err) {
|
|
6965
|
-
if (err instanceof ApiClientError && err.errorCode === "
|
|
6966
|
-
console.log(pc.yellow(" ⚠
|
|
6967
|
-
console.log(pc.dim("
|
|
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
|
-
|
|
6981
|
-
|
|
6982
|
-
const
|
|
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 (
|
|
6987
|
-
|
|
6988
|
-
console.log(
|
|
6989
|
-
|
|
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 (
|
|
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.
|
|
6995
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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 () => {
|