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