@layers/amba 4.0.2 → 4.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-client.d.ts +121 -12
- package/dist/bundle.d.ts +86 -0
- package/dist/commands/domains.d.ts +27 -0
- package/dist/commands/functions-logs.d.ts +4 -2
- package/dist/commands/functions.d.ts +26 -6
- package/dist/commands/iac.d.ts +116 -0
- package/dist/commands/monetization.d.ts +36 -0
- package/dist/commands/payments.d.ts +33 -0
- package/dist/commands/secrets.d.ts +29 -9
- package/dist/index.js +1815 -155
- package/package.json +3 -3
- package/skill-bundle/SKILL.md +2 -0
- package/skill-bundle/references/infrastructure.md +20 -17
package/dist/index.js
CHANGED
|
@@ -8,11 +8,460 @@ 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";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
import { createWriteStream, watch } from "node:fs";
|
|
13
13
|
import { spawn } from "node:child_process";
|
|
14
14
|
import { build } from "esbuild";
|
|
15
|
-
|
|
15
|
+
new Map([
|
|
16
|
+
{
|
|
17
|
+
type: "user.created",
|
|
18
|
+
plane: "app",
|
|
19
|
+
description: "An app user (end user) was created in the project.",
|
|
20
|
+
payloadExample: {
|
|
21
|
+
app_user_id: "usr_123",
|
|
22
|
+
external_id: "auth0|abc",
|
|
23
|
+
created_at: "2026-06-03T00:00:00Z"
|
|
24
|
+
},
|
|
25
|
+
status: "live"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
type: "user.updated",
|
|
29
|
+
plane: "app",
|
|
30
|
+
description: "An app user's profile or attributes changed.",
|
|
31
|
+
payloadExample: {
|
|
32
|
+
app_user_id: "usr_123",
|
|
33
|
+
changed: ["display_name"]
|
|
34
|
+
},
|
|
35
|
+
status: "live"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: "user.deleted",
|
|
39
|
+
plane: "app",
|
|
40
|
+
description: "An app user was deleted.",
|
|
41
|
+
payloadExample: { app_user_id: "usr_123" },
|
|
42
|
+
status: "live"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: "user.reset",
|
|
46
|
+
plane: "app",
|
|
47
|
+
description: "An app user's gamification, economy, engagement and participation state was reset to zero (the user row itself was kept).",
|
|
48
|
+
payloadExample: {
|
|
49
|
+
app_user_id: "usr_123",
|
|
50
|
+
cleared: {
|
|
51
|
+
user_xp: 1,
|
|
52
|
+
xp_ledger: 12
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
status: "live"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
type: "engagement.push.delivered",
|
|
59
|
+
plane: "app",
|
|
60
|
+
description: "A push notification was delivered to a device.",
|
|
61
|
+
payloadExample: {
|
|
62
|
+
app_user_id: "usr_123",
|
|
63
|
+
campaign_id: "cmp_1",
|
|
64
|
+
delivered_at: "2026-06-03T00:00:00Z"
|
|
65
|
+
},
|
|
66
|
+
status: "live"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
type: "engagement.push.failed",
|
|
70
|
+
plane: "app",
|
|
71
|
+
description: "A push notification failed to deliver.",
|
|
72
|
+
payloadExample: {
|
|
73
|
+
app_user_id: "usr_123",
|
|
74
|
+
campaign_id: "cmp_1",
|
|
75
|
+
reason: "unregistered_token"
|
|
76
|
+
},
|
|
77
|
+
status: "live"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
type: "email.sent",
|
|
81
|
+
plane: "app",
|
|
82
|
+
description: "A transactional email was accepted by the provider for delivery.",
|
|
83
|
+
payloadExample: {
|
|
84
|
+
app_user_id: "usr_123",
|
|
85
|
+
template: "welcome",
|
|
86
|
+
to: "a@b.com"
|
|
87
|
+
},
|
|
88
|
+
status: "live"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
type: "email.failed",
|
|
92
|
+
plane: "app",
|
|
93
|
+
description: "A transactional email failed to send.",
|
|
94
|
+
payloadExample: {
|
|
95
|
+
app_user_id: "usr_123",
|
|
96
|
+
template: "welcome",
|
|
97
|
+
reason: "bounce"
|
|
98
|
+
},
|
|
99
|
+
status: "live"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
type: "email.suppressed",
|
|
103
|
+
plane: "app",
|
|
104
|
+
description: "An email was suppressed (recipient on the suppression list).",
|
|
105
|
+
payloadExample: {
|
|
106
|
+
app_user_id: "usr_123",
|
|
107
|
+
to: "a@b.com",
|
|
108
|
+
reason: "unsubscribed"
|
|
109
|
+
},
|
|
110
|
+
status: "live"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: "gamification.xp.granted",
|
|
114
|
+
plane: "app",
|
|
115
|
+
description: "XP was granted to a user.",
|
|
116
|
+
payloadExample: {
|
|
117
|
+
app_user_id: "usr_123",
|
|
118
|
+
amount: 50,
|
|
119
|
+
total_xp: 1250,
|
|
120
|
+
rule: "mission_completed",
|
|
121
|
+
transaction_id: "xpl_abc"
|
|
122
|
+
},
|
|
123
|
+
status: "live"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
type: "gamification.level.up",
|
|
127
|
+
plane: "app",
|
|
128
|
+
description: "A user crossed an XP threshold into a new level.",
|
|
129
|
+
payloadExample: {
|
|
130
|
+
app_user_id: "usr_123",
|
|
131
|
+
level: 7,
|
|
132
|
+
previous_level: 6
|
|
133
|
+
},
|
|
134
|
+
status: "live"
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
type: "gamification.achievement.unlocked",
|
|
138
|
+
plane: "app",
|
|
139
|
+
description: "A user unlocked an achievement.",
|
|
140
|
+
payloadExample: {
|
|
141
|
+
app_user_id: "usr_123",
|
|
142
|
+
achievement_id: "first_win",
|
|
143
|
+
unlocked_at: "2026-06-03T00:00:00Z"
|
|
144
|
+
},
|
|
145
|
+
status: "live"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
type: "gamification.streak.advanced",
|
|
149
|
+
plane: "app",
|
|
150
|
+
description: "A user's streak advanced (a qualifying action extended it).",
|
|
151
|
+
payloadExample: {
|
|
152
|
+
app_user_id: "usr_123",
|
|
153
|
+
streak_id: "daily",
|
|
154
|
+
count: 12
|
|
155
|
+
},
|
|
156
|
+
status: "live"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
type: "gamification.streak.broken",
|
|
160
|
+
plane: "app",
|
|
161
|
+
description: "A user's streak reset to zero after a missed window.",
|
|
162
|
+
payloadExample: {
|
|
163
|
+
app_user_id: "usr_123",
|
|
164
|
+
streak_id: "daily",
|
|
165
|
+
previous_count: 12
|
|
166
|
+
},
|
|
167
|
+
status: "live"
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
type: "gamification.challenge.completed",
|
|
171
|
+
plane: "app",
|
|
172
|
+
description: "A user completed a challenge.",
|
|
173
|
+
payloadExample: {
|
|
174
|
+
app_user_id: "usr_123",
|
|
175
|
+
challenge_id: "ch_1"
|
|
176
|
+
},
|
|
177
|
+
status: "live"
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: "economy.currency.granted",
|
|
181
|
+
plane: "app",
|
|
182
|
+
description: "Currency was granted to a user.",
|
|
183
|
+
payloadExample: {
|
|
184
|
+
app_user_id: "usr_123",
|
|
185
|
+
currency: "coins",
|
|
186
|
+
amount: 100,
|
|
187
|
+
balance: 350,
|
|
188
|
+
transaction_id: "ctx_abc"
|
|
189
|
+
},
|
|
190
|
+
status: "live"
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
type: "economy.currency.spent",
|
|
194
|
+
plane: "app",
|
|
195
|
+
description: "A user spent currency.",
|
|
196
|
+
payloadExample: {
|
|
197
|
+
app_user_id: "usr_123",
|
|
198
|
+
currency: "coins",
|
|
199
|
+
amount: 100,
|
|
200
|
+
balance: 250,
|
|
201
|
+
transaction_id: "ctx_def"
|
|
202
|
+
},
|
|
203
|
+
status: "live"
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
type: "economy.catalog.purchased",
|
|
207
|
+
plane: "app",
|
|
208
|
+
description: "A user purchased a catalog item.",
|
|
209
|
+
payloadExample: {
|
|
210
|
+
app_user_id: "usr_123",
|
|
211
|
+
item_id: "sword",
|
|
212
|
+
price: 100,
|
|
213
|
+
currency: "coins"
|
|
214
|
+
},
|
|
215
|
+
status: "live"
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
type: "economy.inventory.granted",
|
|
219
|
+
plane: "app",
|
|
220
|
+
description: "An item was granted into a user inventory.",
|
|
221
|
+
payloadExample: {
|
|
222
|
+
app_user_id: "usr_123",
|
|
223
|
+
item_id: "sword",
|
|
224
|
+
quantity: 1
|
|
225
|
+
},
|
|
226
|
+
status: "live"
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
type: "subscription.started",
|
|
230
|
+
plane: "app",
|
|
231
|
+
description: "A user gained an active subscription / entitlement.",
|
|
232
|
+
payloadExample: {
|
|
233
|
+
app_user_id: "usr_123",
|
|
234
|
+
entitlement_id: "pro",
|
|
235
|
+
product_id: "pro_yearly",
|
|
236
|
+
store: "app_store",
|
|
237
|
+
period_type: "normal"
|
|
238
|
+
},
|
|
239
|
+
status: "live"
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
type: "subscription.renewed",
|
|
243
|
+
plane: "app",
|
|
244
|
+
description: "An active subscription renewed for another billing period.",
|
|
245
|
+
payloadExample: {
|
|
246
|
+
app_user_id: "usr_123",
|
|
247
|
+
entitlement_id: "pro",
|
|
248
|
+
product_id: "pro_yearly",
|
|
249
|
+
store: "app_store"
|
|
250
|
+
},
|
|
251
|
+
status: "live"
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
type: "subscription.expired",
|
|
255
|
+
plane: "app",
|
|
256
|
+
description: "A subscription lapsed / was revoked and access ended.",
|
|
257
|
+
payloadExample: {
|
|
258
|
+
app_user_id: "usr_123",
|
|
259
|
+
entitlement_id: "pro",
|
|
260
|
+
product_id: "pro_yearly"
|
|
261
|
+
},
|
|
262
|
+
status: "live"
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
type: "entitlement.granted",
|
|
266
|
+
plane: "app",
|
|
267
|
+
description: "An entitlement became active for a user (any source).",
|
|
268
|
+
payloadExample: {
|
|
269
|
+
app_user_id: "usr_123",
|
|
270
|
+
entitlement_id: "pro",
|
|
271
|
+
product_id: "pro_yearly",
|
|
272
|
+
source: "webhook"
|
|
273
|
+
},
|
|
274
|
+
status: "live"
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
type: "entitlement.revoked",
|
|
278
|
+
plane: "app",
|
|
279
|
+
description: "An entitlement became inactive for a user (any source).",
|
|
280
|
+
payloadExample: {
|
|
281
|
+
app_user_id: "usr_123",
|
|
282
|
+
entitlement_id: "pro",
|
|
283
|
+
source: "manual"
|
|
284
|
+
},
|
|
285
|
+
status: "live"
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
type: "social.friendship.created",
|
|
289
|
+
plane: "app",
|
|
290
|
+
description: "A friendship was established between two users.",
|
|
291
|
+
payloadExample: {
|
|
292
|
+
app_user_id: "usr_123",
|
|
293
|
+
friend_id: "usr_456"
|
|
294
|
+
},
|
|
295
|
+
status: "live"
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
type: "social.message.sent",
|
|
299
|
+
plane: "app",
|
|
300
|
+
description: "A message was sent in a conversation.",
|
|
301
|
+
payloadExample: {
|
|
302
|
+
conversation_id: "cnv_1",
|
|
303
|
+
sender_id: "usr_123"
|
|
304
|
+
},
|
|
305
|
+
status: "live"
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
type: "social.review.created",
|
|
309
|
+
plane: "app",
|
|
310
|
+
description: "A review was submitted.",
|
|
311
|
+
payloadExample: {
|
|
312
|
+
app_user_id: "usr_123",
|
|
313
|
+
rating: 5,
|
|
314
|
+
target_id: "item_1"
|
|
315
|
+
},
|
|
316
|
+
status: "live"
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
type: "content.published",
|
|
320
|
+
plane: "app",
|
|
321
|
+
description: "A content item was published / went live.",
|
|
322
|
+
payloadExample: {
|
|
323
|
+
item_id: "post_1",
|
|
324
|
+
library: "news"
|
|
325
|
+
},
|
|
326
|
+
status: "live"
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
type: "moderation.flagged",
|
|
330
|
+
plane: "app",
|
|
331
|
+
description: "User content entered the moderation queue.",
|
|
332
|
+
payloadExample: {
|
|
333
|
+
queue_id: "mq_1",
|
|
334
|
+
content_type: "message",
|
|
335
|
+
reason: "auto_flag"
|
|
336
|
+
},
|
|
337
|
+
status: "live"
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
type: "moderation.rejected",
|
|
341
|
+
plane: "app",
|
|
342
|
+
description: "Moderated content was rejected.",
|
|
343
|
+
payloadExample: {
|
|
344
|
+
queue_id: "mq_1",
|
|
345
|
+
moderator: "auto"
|
|
346
|
+
},
|
|
347
|
+
status: "live"
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
type: "project.provisioning.started",
|
|
351
|
+
plane: "control",
|
|
352
|
+
description: "Project provisioning began (tenant DB + keys being created).",
|
|
353
|
+
payloadExample: { project_id: "prj_1" },
|
|
354
|
+
status: "live"
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
type: "project.provisioned",
|
|
358
|
+
plane: "control",
|
|
359
|
+
description: "The project finished provisioning and is ready to use.",
|
|
360
|
+
payloadExample: {
|
|
361
|
+
project_id: "prj_1",
|
|
362
|
+
provisioned_at: "2026-06-03T00:00:00Z"
|
|
363
|
+
},
|
|
364
|
+
status: "live"
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
type: "project.provisioning.failed",
|
|
368
|
+
plane: "control",
|
|
369
|
+
description: "Project provisioning failed and was compensated.",
|
|
370
|
+
payloadExample: {
|
|
371
|
+
project_id: "prj_1",
|
|
372
|
+
reason: "neon_quota"
|
|
373
|
+
},
|
|
374
|
+
status: "live"
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
type: "site.deploy.completed",
|
|
378
|
+
plane: "control",
|
|
379
|
+
description: "A static site deploy completed and is serving.",
|
|
380
|
+
payloadExample: {
|
|
381
|
+
site: "marketing",
|
|
382
|
+
deployment_id: "dep_1",
|
|
383
|
+
url: "https://x.app.amba.host",
|
|
384
|
+
file_count: 4,
|
|
385
|
+
serving_configured: true
|
|
386
|
+
},
|
|
387
|
+
status: "live"
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
type: "site.deploy.failed",
|
|
391
|
+
plane: "control",
|
|
392
|
+
description: "A static site deploy failed.",
|
|
393
|
+
payloadExample: {
|
|
394
|
+
project_id: "prj_1",
|
|
395
|
+
site: "marketing",
|
|
396
|
+
reason: "storage_write_failed"
|
|
397
|
+
},
|
|
398
|
+
status: "live"
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
type: "domain.registered",
|
|
402
|
+
plane: "control",
|
|
403
|
+
description: "A domain was registered through Amba.",
|
|
404
|
+
payloadExample: {
|
|
405
|
+
domain: "myapp.com",
|
|
406
|
+
site: "marketing"
|
|
407
|
+
},
|
|
408
|
+
status: "live"
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
type: "domain.cert.issued",
|
|
412
|
+
plane: "control",
|
|
413
|
+
description: "The TLS certificate for an attached domain became active.",
|
|
414
|
+
payloadExample: {
|
|
415
|
+
project_id: "prj_1",
|
|
416
|
+
domain: "app.myapp.com"
|
|
417
|
+
},
|
|
418
|
+
status: "live"
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
type: "domain.connected",
|
|
422
|
+
plane: "control",
|
|
423
|
+
description: "A domain is fully connected to a site and serving.",
|
|
424
|
+
payloadExample: {
|
|
425
|
+
project_id: "prj_1",
|
|
426
|
+
domain: "myapp.com",
|
|
427
|
+
site: "marketing"
|
|
428
|
+
},
|
|
429
|
+
status: "live"
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
type: "billing.subscription.updated",
|
|
433
|
+
plane: "control",
|
|
434
|
+
description: "The project's subscription state changed.",
|
|
435
|
+
payloadExample: {
|
|
436
|
+
project_id: "prj_1",
|
|
437
|
+
tier: "pro",
|
|
438
|
+
status: "active"
|
|
439
|
+
},
|
|
440
|
+
status: "live"
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
type: "billing.invoice.paid",
|
|
444
|
+
plane: "control",
|
|
445
|
+
description: "An invoice was paid.",
|
|
446
|
+
payloadExample: {
|
|
447
|
+
project_id: "prj_1",
|
|
448
|
+
invoice_id: "in_1",
|
|
449
|
+
amount_usd: 49
|
|
450
|
+
},
|
|
451
|
+
status: "live"
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
type: "billing.payment_failed",
|
|
455
|
+
plane: "control",
|
|
456
|
+
description: "A payment attempt failed.",
|
|
457
|
+
payloadExample: {
|
|
458
|
+
project_id: "prj_1",
|
|
459
|
+
invoice_id: "in_1",
|
|
460
|
+
reason: "card_declined"
|
|
461
|
+
},
|
|
462
|
+
status: "live"
|
|
463
|
+
}
|
|
464
|
+
].map((e) => [e.type, e]));
|
|
16
465
|
/**
|
|
17
466
|
* Shared email-shape validation.
|
|
18
467
|
*
|
|
@@ -377,7 +826,7 @@ async function request(method, path, body) {
|
|
|
377
826
|
try {
|
|
378
827
|
const errorBody = await res.json();
|
|
379
828
|
if (errorBody.error?.message) {
|
|
380
|
-
errorMessage = errorBody.error.message
|
|
829
|
+
errorMessage = `${res.status} — ${errorBody.error.message}`;
|
|
381
830
|
errorCode = errorBody.error.code;
|
|
382
831
|
}
|
|
383
832
|
} catch {}
|
|
@@ -498,7 +947,7 @@ async function streamUsersExport(projectId, query = {}) {
|
|
|
498
947
|
try {
|
|
499
948
|
const errorBody = await res.json();
|
|
500
949
|
if (errorBody.error?.message) {
|
|
501
|
-
errorMessage = errorBody.error.message
|
|
950
|
+
errorMessage = `${res.status} — ${errorBody.error.message}`;
|
|
502
951
|
errorCode = errorBody.error.code;
|
|
503
952
|
}
|
|
504
953
|
} catch {}
|
|
@@ -529,7 +978,8 @@ async function deployFunctionViaApi(projectId, input) {
|
|
|
529
978
|
fd.set("script", new Blob([input.bundleCode], { type: "application/javascript+module" }), `${input.name}.js`);
|
|
530
979
|
const metadata = {
|
|
531
980
|
name: input.name,
|
|
532
|
-
...input.rate_limit !== void 0 ? { rate_limit: input.rate_limit } : {}
|
|
981
|
+
...input.rate_limit !== void 0 ? { rate_limit: input.rate_limit } : {},
|
|
982
|
+
...input.public === true ? { public: true } : {}
|
|
533
983
|
};
|
|
534
984
|
fd.set("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }), "metadata.json");
|
|
535
985
|
return request("POST", `/projects/${projectId}/functions/deploy`, fd);
|
|
@@ -545,6 +995,7 @@ async function getFunctionLogs(projectId, functionName, options = {}) {
|
|
|
545
995
|
if (options.since) params.set("since", options.since);
|
|
546
996
|
if (options.until) params.set("until", options.until);
|
|
547
997
|
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
998
|
+
if (options.requestId) params.set("request_id", options.requestId);
|
|
548
999
|
const qs = params.toString();
|
|
549
1000
|
return request("GET", `/projects/${projectId}/functions/${functionName}/logs${qs ? `?${qs}` : ""}`);
|
|
550
1001
|
}
|
|
@@ -560,12 +1011,12 @@ async function deleteQueueBinding(projectId, queueName) {
|
|
|
560
1011
|
async function setSecretViaApi(projectId, input) {
|
|
561
1012
|
return request("POST", `/projects/${projectId}/secrets`, input);
|
|
562
1013
|
}
|
|
563
|
-
async function listSecretsViaApi(projectId) {
|
|
564
|
-
return request("GET", `/projects/${projectId}/secrets`);
|
|
1014
|
+
async function listSecretsViaApi(projectId, options = {}) {
|
|
1015
|
+
return request("GET", `/projects/${projectId}/secrets${options.function ? `?${new URLSearchParams({ function: options.function }).toString()}` : ""}`);
|
|
565
1016
|
}
|
|
566
|
-
async function deleteSecretViaApi(projectId, name, options) {
|
|
567
|
-
const qs = new URLSearchParams({ function: options.function });
|
|
568
|
-
return request("DELETE", `/projects/${projectId}/secrets/${encodeURIComponent(name)}
|
|
1017
|
+
async function deleteSecretViaApi(projectId, name, options = {}) {
|
|
1018
|
+
const qs = options.function ? `?${new URLSearchParams({ function: options.function }).toString()}` : "";
|
|
1019
|
+
return request("DELETE", `/projects/${projectId}/secrets/${encodeURIComponent(name)}${qs}`);
|
|
569
1020
|
}
|
|
570
1021
|
async function createCollection(projectId, input) {
|
|
571
1022
|
return request("POST", `/projects/${projectId}/collections`, input);
|
|
@@ -645,6 +1096,20 @@ async function rollbackSiteViaApi(projectId, siteName, deploymentId) {
|
|
|
645
1096
|
async function deleteSiteViaApi(projectId, siteName, options) {
|
|
646
1097
|
return request("DELETE", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}?confirm=${encodeURIComponent(options.confirm)}`);
|
|
647
1098
|
}
|
|
1099
|
+
async function searchDomainsViaApi(projectId, query, limit) {
|
|
1100
|
+
const params = new URLSearchParams({ q: query });
|
|
1101
|
+
if (limit !== void 0) params.set("limit", String(limit));
|
|
1102
|
+
return request("GET", `/projects/${projectId}/domains/search?${params.toString()}`);
|
|
1103
|
+
}
|
|
1104
|
+
async function checkDomainsViaApi(projectId, domains) {
|
|
1105
|
+
return request("POST", `/projects/${projectId}/domains/check`, { domains });
|
|
1106
|
+
}
|
|
1107
|
+
async function purchaseDomainViaApi(projectId, body) {
|
|
1108
|
+
return request("POST", `/projects/${projectId}/domains/purchase`, body);
|
|
1109
|
+
}
|
|
1110
|
+
async function listPurchasedDomainsViaApi(projectId) {
|
|
1111
|
+
return request("GET", `/projects/${projectId}/domains`);
|
|
1112
|
+
}
|
|
648
1113
|
/**
|
|
649
1114
|
* Delete a function entirely. Cascade: removes the deployed script
|
|
650
1115
|
* (backend 404 treated as success), then marks every historical
|
|
@@ -658,6 +1123,17 @@ async function deleteFunctionViaApi(projectId, functionName, options) {
|
|
|
658
1123
|
async function listSiteDomains(projectId, siteName) {
|
|
659
1124
|
return request("GET", `/projects/${projectId}/sites/${encodeURIComponent(siteName)}/domains`);
|
|
660
1125
|
}
|
|
1126
|
+
/** GET the current project's declarative configuration bundle. */
|
|
1127
|
+
async function exportPromotionBundle(projectId) {
|
|
1128
|
+
return request("GET", `/projects/${projectId}/promotion/export`);
|
|
1129
|
+
}
|
|
1130
|
+
/** POST a declarative bundle to import it into the project. */
|
|
1131
|
+
async function importPromotionBundle(projectId, bundle, mode) {
|
|
1132
|
+
return request("POST", `/projects/${projectId}/promotion/import`, {
|
|
1133
|
+
bundle,
|
|
1134
|
+
mode
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
661
1137
|
async function validateApiKey(apiKey) {
|
|
662
1138
|
const res = await fetch(`${getApiRoot()}/v1/auth/validate`, {
|
|
663
1139
|
method: "POST",
|
|
@@ -2135,6 +2611,14 @@ Tell the user where their credentials live:
|
|
|
2135
2611
|
|
|
2136
2612
|
**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"\`.
|
|
2137
2613
|
|
|
2614
|
+
**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):
|
|
2615
|
+
|
|
2616
|
+
\`\`\`bash
|
|
2617
|
+
npx @layers/amba init
|
|
2618
|
+
\`\`\`
|
|
2619
|
+
|
|
2620
|
+
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.
|
|
2621
|
+
|
|
2138
2622
|
## Step 1 — Classify the app
|
|
2139
2623
|
|
|
2140
2624
|
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:
|
|
@@ -2256,7 +2740,7 @@ SKIPPED (low signal — re-run with /amba <feature> if you want them)
|
|
|
2256
2740
|
NEEDS YOUR INPUT
|
|
2257
2741
|
- Apple Sign In: add the "Sign in with Apple" capability in Xcode > Signing & Capabilities.
|
|
2258
2742
|
- Google Sign In: paste your Google OAuth client ID into amba_projects_update({ google_oauth_client_id: "..." }).
|
|
2259
|
-
- APNs / FCM:
|
|
2743
|
+
- APNs / FCM: configure credentials with amba_integrations_configure (paste the .p8 / service-account JSON inline) before push delivers.
|
|
2260
2744
|
|
|
2261
2745
|
NEXT STEPS
|
|
2262
2746
|
- Paste AMBA_CLIENT_KEY into your build env (already shown above)
|
|
@@ -2318,7 +2802,7 @@ All SDKs expose the same surface: \`Amba.configure({ projectId, apiKey })\`, the
|
|
|
2318
2802
|
- **challenges** — time-bounded goals. Define with \`amba_challenges_create\`; progress via SDK.
|
|
2319
2803
|
|
|
2320
2804
|
### Economy
|
|
2321
|
-
- **currencies** — virtual currencies (coins, gems). Define with \`amba_currencies_create\`; grant via \`amba_currencies_grant\` or event rules via \`amba_currency_grant_rules_create
|
|
2805
|
+
- **currencies** — virtual currencies (coins, gems). Define with \`amba_currencies_create\`; grant via \`amba_currencies_grant\` or event rules via \`amba_currency_grant_rules_create\`; debit via \`amba_currencies_spend\` (atomic, rejects on insufficient funds).
|
|
2322
2806
|
- **catalog + stores** — purchasable items + storefronts. Chain: \`amba_catalog_items_create\` → \`amba_catalog_items_set_price\` → \`amba_stores_create\` → \`amba_stores_add_listing\`. (Define currency first.)
|
|
2323
2807
|
- **inventory** — items users own. Read via SDK \`Amba.inventory.*\`; grant with \`amba_inventory_grant_item\`.
|
|
2324
2808
|
|
|
@@ -2341,7 +2825,7 @@ All SDKs expose the same surface: \`Amba.configure({ projectId, apiKey })\`, the
|
|
|
2341
2825
|
- **sites** — static site hosting at \`*.app.amba.host\`. Deploy with \`amba_sites_deploy\`.
|
|
2342
2826
|
- **media** — file storage + CDN. Upload via \`amba_media_upload\`.
|
|
2343
2827
|
- **secrets** — env vars for functions. Set via \`amba_secrets_set\`.
|
|
2344
|
-
- **configs** — remote config flags. Define with \`
|
|
2828
|
+
- **configs** — remote config flags. Define with \`amba_remote_configs_create\`.
|
|
2345
2829
|
- **integrations** — third-party webhooks (RevenueCat, Superwall, AppsFlyer, etc.). Configure with \`amba_integrations_configure\`.
|
|
2346
2830
|
- **ai_prompts** — versioned LLM prompts callable from SDK. Define with \`amba_ai_prompts_create\`; call via \`amba_ai_prompts_invoke\`.
|
|
2347
2831
|
`;
|
|
@@ -4055,7 +4539,7 @@ async function configSetCommand(key, rawValue) {
|
|
|
4055
4539
|
}
|
|
4056
4540
|
//#endregion
|
|
4057
4541
|
//#region src/commands/projects.ts
|
|
4058
|
-
function confirm(question) {
|
|
4542
|
+
function confirm$1(question) {
|
|
4059
4543
|
const rl = createInterface({
|
|
4060
4544
|
input: process.stdin,
|
|
4061
4545
|
output: process.stdout
|
|
@@ -4067,7 +4551,7 @@ function confirm(question) {
|
|
|
4067
4551
|
});
|
|
4068
4552
|
});
|
|
4069
4553
|
}
|
|
4070
|
-
function handleError$
|
|
4554
|
+
function handleError$2(err) {
|
|
4071
4555
|
if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
|
|
4072
4556
|
else console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
4073
4557
|
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
@@ -4117,7 +4601,7 @@ async function projectsListCommand() {
|
|
|
4117
4601
|
console.log(pc.dim(` ${projects.length} project${projects.length === 1 ? "" : "s"}`));
|
|
4118
4602
|
console.log();
|
|
4119
4603
|
} catch (err) {
|
|
4120
|
-
handleError$
|
|
4604
|
+
handleError$2(err);
|
|
4121
4605
|
}
|
|
4122
4606
|
}
|
|
4123
4607
|
async function projectsCreateCommand(input) {
|
|
@@ -4163,7 +4647,7 @@ async function projectsCreateCommand(input) {
|
|
|
4163
4647
|
console.log(pc.dim(" Next: ") + pc.cyan(`amba projects show ${id}`));
|
|
4164
4648
|
console.log();
|
|
4165
4649
|
} catch (err) {
|
|
4166
|
-
handleError$
|
|
4650
|
+
handleError$2(err);
|
|
4167
4651
|
}
|
|
4168
4652
|
}
|
|
4169
4653
|
async function projectsUpdateCommand(projectId, input) {
|
|
@@ -4189,7 +4673,7 @@ async function projectsUpdateCommand(projectId, input) {
|
|
|
4189
4673
|
console.log();
|
|
4190
4674
|
process.exit(1);
|
|
4191
4675
|
}
|
|
4192
|
-
handleError$
|
|
4676
|
+
handleError$2(err);
|
|
4193
4677
|
}
|
|
4194
4678
|
}
|
|
4195
4679
|
async function projectsShowCommand(projectId) {
|
|
@@ -4207,7 +4691,7 @@ async function projectsShowCommand(projectId) {
|
|
|
4207
4691
|
console.log();
|
|
4208
4692
|
process.exit(1);
|
|
4209
4693
|
}
|
|
4210
|
-
handleError$
|
|
4694
|
+
handleError$2(err);
|
|
4211
4695
|
}
|
|
4212
4696
|
}
|
|
4213
4697
|
async function projectsDeleteCommand(projectId, opts = {}) {
|
|
@@ -4216,7 +4700,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
|
|
|
4216
4700
|
console.log(pc.dim(" ─────────────────────────────────"));
|
|
4217
4701
|
console.log();
|
|
4218
4702
|
if (!opts.yes) {
|
|
4219
|
-
if (!await confirm(pc.yellow(` Delete project ${projectId}? This is irreversible. (y/N) `))) {
|
|
4703
|
+
if (!await confirm$1(pc.yellow(` Delete project ${projectId}? This is irreversible. (y/N) `))) {
|
|
4220
4704
|
console.log(pc.dim(" Aborted."));
|
|
4221
4705
|
console.log();
|
|
4222
4706
|
return;
|
|
@@ -4232,7 +4716,7 @@ async function projectsDeleteCommand(projectId, opts = {}) {
|
|
|
4232
4716
|
console.log();
|
|
4233
4717
|
process.exit(1);
|
|
4234
4718
|
}
|
|
4235
|
-
handleError$
|
|
4719
|
+
handleError$2(err);
|
|
4236
4720
|
}
|
|
4237
4721
|
}
|
|
4238
4722
|
//#endregion
|
|
@@ -4975,6 +5459,47 @@ function parseEnv(content) {
|
|
|
4975
5459
|
* empty; customer code is expected to be self-contained.
|
|
4976
5460
|
*/
|
|
4977
5461
|
/**
|
|
5462
|
+
* Conventional entry-point filenames probed (in order) when
|
|
5463
|
+
* `amba functions deploy <dir>` is pointed at a directory rather than a
|
|
5464
|
+
* single file. Mirrors the Node / bundler convention (`index.*`) so a
|
|
5465
|
+
* developer can `amba functions deploy ./my-fn` with an `index.ts` inside.
|
|
5466
|
+
*/
|
|
5467
|
+
const FUNCTION_ENTRY_CANDIDATES = [
|
|
5468
|
+
"index.ts",
|
|
5469
|
+
"index.js",
|
|
5470
|
+
"index.mjs"
|
|
5471
|
+
];
|
|
5472
|
+
/**
|
|
5473
|
+
* Resolve the bundler entry point from a deploy target that may be either a
|
|
5474
|
+
* single file OR a directory.
|
|
5475
|
+
*
|
|
5476
|
+
* - File → returned as-is (single-file back-compat path).
|
|
5477
|
+
* - Dir → resolved by convention to `index.{ts,js,mjs}`, or to an
|
|
5478
|
+
* explicit `entry` (relative to the directory) when provided.
|
|
5479
|
+
*
|
|
5480
|
+
* Throws an actionable error when the target doesn't exist, or when a
|
|
5481
|
+
* directory has no resolvable entry point (and no `--entry` was passed).
|
|
5482
|
+
*/
|
|
5483
|
+
async function resolveFunctionEntry(target, explicitEntry) {
|
|
5484
|
+
const st = await stat(target).catch(() => null);
|
|
5485
|
+
if (!st) throw new Error(`Deploy target not found: ${target}`);
|
|
5486
|
+
if (st.isFile()) {
|
|
5487
|
+
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}.`);
|
|
5488
|
+
return target;
|
|
5489
|
+
}
|
|
5490
|
+
if (!st.isDirectory()) throw new Error(`Deploy target is neither a file nor a directory: ${target}`);
|
|
5491
|
+
if (explicitEntry) {
|
|
5492
|
+
const candidate = join(target, explicitEntry);
|
|
5493
|
+
if (!(await stat(candidate).catch(() => null))?.isFile()) throw new Error(`--entry file not found: ${candidate}`);
|
|
5494
|
+
return candidate;
|
|
5495
|
+
}
|
|
5496
|
+
for (const name of FUNCTION_ENTRY_CANDIDATES) {
|
|
5497
|
+
const candidate = join(target, name);
|
|
5498
|
+
if ((await stat(candidate).catch(() => null))?.isFile()) return candidate;
|
|
5499
|
+
}
|
|
5500
|
+
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).`);
|
|
5501
|
+
}
|
|
5502
|
+
/**
|
|
4978
5503
|
* Modules the bundler treats as `external` by default. The Amba
|
|
4979
5504
|
* function runtime exposes zero npm packages — there is no "platform
|
|
4980
5505
|
* stdlib" for customer functions to import. Keep this list empty.
|
|
@@ -4997,6 +5522,91 @@ var BundleSizeError = class extends Error {
|
|
|
4997
5522
|
}
|
|
4998
5523
|
};
|
|
4999
5524
|
/**
|
|
5525
|
+
* The single source-of-truth message for the wrong-handler-shape failure.
|
|
5526
|
+
* Shared between the CLI bundler (pre-upload), the local `dev` runner, and
|
|
5527
|
+
* the server-side deploy hint so dev / deploy / prod all describe the fix
|
|
5528
|
+
* identically. Customer functions run as Module Workers; the legacy
|
|
5529
|
+
* service-worker shape (`export default async function (req) { … }`) is
|
|
5530
|
+
* rejected by the runtime at upload, so we reject it before the round-trip.
|
|
5531
|
+
*/
|
|
5532
|
+
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.";
|
|
5533
|
+
/** Message used when the entry file has no `export default` at all. */
|
|
5534
|
+
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.";
|
|
5535
|
+
/** Message used when the default export is present but has no `fetch` method. */
|
|
5536
|
+
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.";
|
|
5537
|
+
/**
|
|
5538
|
+
* Thrown when the bundled entry's `export default` is not the Module Worker
|
|
5539
|
+
* shape (`{ fetch(req, env, ctx) }`). Raised BEFORE any upload so a wrong
|
|
5540
|
+
* handler shape fails fast on the developer's machine with an actionable
|
|
5541
|
+
* message instead of an opaque upstream rejection at deploy time.
|
|
5542
|
+
*/
|
|
5543
|
+
var HandlerShapeError = class extends Error {
|
|
5544
|
+
kind;
|
|
5545
|
+
constructor(message, kind) {
|
|
5546
|
+
super(message);
|
|
5547
|
+
this.kind = kind;
|
|
5548
|
+
this.name = "HandlerShapeError";
|
|
5549
|
+
}
|
|
5550
|
+
};
|
|
5551
|
+
/**
|
|
5552
|
+
* Inspect an already-imported module namespace and assert it has the
|
|
5553
|
+
* Module Worker handler shape: a default export that is an object (or
|
|
5554
|
+
* function-with-properties) carrying a callable `fetch`.
|
|
5555
|
+
*
|
|
5556
|
+
* Pure + side-effect free — it only reads the shape of `mod.default`, never
|
|
5557
|
+
* invokes `fetch`. This is the unit-testable core; `assertModuleWorkerShape`
|
|
5558
|
+
* wraps the import-from-code plumbing around it.
|
|
5559
|
+
*
|
|
5560
|
+
* Throws `HandlerShapeError` on:
|
|
5561
|
+
* - no default export (`missing-default`)
|
|
5562
|
+
* - a default-exported plain function — the legacy service-worker shape
|
|
5563
|
+
* (`plain-function`)
|
|
5564
|
+
* - a default export with no callable `fetch` (`no-fetch`)
|
|
5565
|
+
*/
|
|
5566
|
+
function assertHandlerShape(mod) {
|
|
5567
|
+
const def = mod.default;
|
|
5568
|
+
if (def === void 0 || def === null) throw new HandlerShapeError(HANDLER_SHAPE_MISSING_DEFAULT_MESSAGE, "missing-default");
|
|
5569
|
+
if (typeof def === "function") {
|
|
5570
|
+
if (typeof def.fetch !== "function") throw new HandlerShapeError(HANDLER_SHAPE_FIX_MESSAGE, "plain-function");
|
|
5571
|
+
return;
|
|
5572
|
+
}
|
|
5573
|
+
if (typeof def !== "object") throw new HandlerShapeError(HANDLER_SHAPE_NO_FETCH_MESSAGE, "no-fetch");
|
|
5574
|
+
if (typeof def.fetch !== "function") throw new HandlerShapeError(HANDLER_SHAPE_NO_FETCH_MESSAGE, "no-fetch");
|
|
5575
|
+
}
|
|
5576
|
+
/**
|
|
5577
|
+
* Validate that the bundled customer code exports the Module Worker handler
|
|
5578
|
+
* shape, by importing it in isolation and inspecting `mod.default`.
|
|
5579
|
+
*
|
|
5580
|
+
* Mechanism: write the bundle to a throwaway temp `.mjs` and dynamic-import
|
|
5581
|
+
* it, then hand the namespace to `assertHandlerShape`. This is the same
|
|
5582
|
+
* import-and-inspect approach the local `dev` runner uses
|
|
5583
|
+
* (`functions.ts` DEV_RUNNER_SOURCE) — reused here so the pre-upload check
|
|
5584
|
+
* and the dev runner agree on what "valid shape" means. Importing runs the
|
|
5585
|
+
* module's top-level code (unavoidable to read the export), but the handler
|
|
5586
|
+
* itself is NEVER invoked — we only read the shape of the exported object.
|
|
5587
|
+
*
|
|
5588
|
+
* The temp file is always cleaned up (best-effort) before returning.
|
|
5589
|
+
*/
|
|
5590
|
+
async function assertModuleWorkerShape(code) {
|
|
5591
|
+
const dir = await mkdtemp(join(tmpdir(), "amba-fn-shape-"));
|
|
5592
|
+
const filePath = join(dir, "fn.mjs");
|
|
5593
|
+
try {
|
|
5594
|
+
await writeFile(filePath, code, "utf8");
|
|
5595
|
+
let mod;
|
|
5596
|
+
try {
|
|
5597
|
+
mod = await import(pathToFileURL(filePath).href);
|
|
5598
|
+
} catch (err) {
|
|
5599
|
+
throw new Error(`Failed to load the function entrypoint while validating its handler shape: ${err instanceof Error ? err.message : String(err)}`);
|
|
5600
|
+
}
|
|
5601
|
+
assertHandlerShape(mod);
|
|
5602
|
+
} finally {
|
|
5603
|
+
await rm(dir, {
|
|
5604
|
+
recursive: true,
|
|
5605
|
+
force: true
|
|
5606
|
+
}).catch(() => {});
|
|
5607
|
+
}
|
|
5608
|
+
}
|
|
5609
|
+
/**
|
|
5000
5610
|
* Bundle a customer function file for upload to a Workers dispatch
|
|
5001
5611
|
* namespace. Returns the bundled code + metadata; throws
|
|
5002
5612
|
* `BundleSizeError` when the compressed size exceeds the cap so the CLI
|
|
@@ -5029,6 +5639,7 @@ async function bundleFunction(options) {
|
|
|
5029
5639
|
const compressedSize = await gzipSizeOf(output.contents);
|
|
5030
5640
|
const max = options.maxSizeBytes ?? 8388608;
|
|
5031
5641
|
if (compressedSize > max) throw new BundleSizeError(compressedSize, max);
|
|
5642
|
+
if (options.validateHandlerShape !== false) await assertModuleWorkerShape(code);
|
|
5032
5643
|
return {
|
|
5033
5644
|
code,
|
|
5034
5645
|
sha256: await hashSha256Hex(output.contents),
|
|
@@ -5077,16 +5688,19 @@ function formatBytes$1(n) {
|
|
|
5077
5688
|
* records the deployment in a single round-trip. The other subcommands
|
|
5078
5689
|
* are thin shells over the admin API helpers in `api-client.ts`.
|
|
5079
5690
|
*/
|
|
5080
|
-
async function functionsDeployCommand(
|
|
5691
|
+
async function functionsDeployCommand(target, options = {}) {
|
|
5081
5692
|
const projectId = (await loadProjectConfig()).projectId;
|
|
5082
|
-
const
|
|
5693
|
+
const targetIsDir = (await stat(target).catch(() => null))?.isDirectory() ?? false;
|
|
5694
|
+
const entryPoint = await resolveFunctionEntry(target, options.entry);
|
|
5695
|
+
const defaultName = targetIsDir ? basename(target.replace(/[/\\]+$/, "")) : basename(entryPoint).replace(/\.[^.]+$/, "");
|
|
5696
|
+
const functionName = options.name ?? defaultName;
|
|
5083
5697
|
validateFunctionName(functionName);
|
|
5084
5698
|
const rateLimit = parseRateLimitFlags(options);
|
|
5085
5699
|
console.log();
|
|
5086
5700
|
console.log(pc.bold(` amba functions deploy ${pc.cyan(functionName)}`));
|
|
5087
5701
|
console.log(pc.dim(" ─────────────────────────────────"));
|
|
5088
5702
|
console.log();
|
|
5089
|
-
console.log(pc.dim(
|
|
5703
|
+
console.log(pc.dim(` Bundling ${entryPoint}…`));
|
|
5090
5704
|
const bundle = await bundleFunction({ entryPoint });
|
|
5091
5705
|
printBundleReport(bundle);
|
|
5092
5706
|
if (options.dryRun) {
|
|
@@ -5094,14 +5708,26 @@ async function functionsDeployCommand(entryPoint, options = {}) {
|
|
|
5094
5708
|
return;
|
|
5095
5709
|
}
|
|
5096
5710
|
console.log(pc.dim(" Uploading…"));
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5711
|
+
let result;
|
|
5712
|
+
try {
|
|
5713
|
+
result = await deployFunctionViaApi(projectId, {
|
|
5714
|
+
name: functionName,
|
|
5715
|
+
bundleCode: bundle.code,
|
|
5716
|
+
rate_limit: rateLimit,
|
|
5717
|
+
public: options.public === true ? true : void 0
|
|
5718
|
+
});
|
|
5719
|
+
} catch (err) {
|
|
5720
|
+
if (err instanceof ApiClientError && err.statusCode === 503) {
|
|
5721
|
+
const base = err.message.trimEnd();
|
|
5722
|
+
const sep = /[.!?]$/.test(base) ? " " : ". ";
|
|
5723
|
+
throw new Error(`${base}${sep}The function platform is temporarily unavailable; retry in ~30s.`);
|
|
5724
|
+
}
|
|
5725
|
+
throw err;
|
|
5726
|
+
}
|
|
5102
5727
|
console.log(pc.green(" ✓") + ` Deployed ${pc.cyan(functionName)} ${pc.dim(`v${result.data.version}`)}`);
|
|
5103
5728
|
console.log(pc.green(" ✓") + ` URL: ${pc.underline(result.fn_url)}`);
|
|
5104
5729
|
if (rateLimit) console.log(pc.dim(` Rate limit: ${rateLimit.max} per ${rateLimit.window} (key=${rateLimit.key}) — enforced pre-dispatch`));
|
|
5730
|
+
if (options.public === true) console.log(pc.yellow(" ! Public") + pc.dim(" — no X-Api-Key required. Verify the webhook signature inside your handler."));
|
|
5105
5731
|
console.log();
|
|
5106
5732
|
}
|
|
5107
5733
|
async function functionsListCommand() {
|
|
@@ -5153,11 +5779,12 @@ async function functionsScheduleCommand(name, cron, options = {}) {
|
|
|
5153
5779
|
* — externalization rules, size cap, and source-map handling are
|
|
5154
5780
|
* identical so dev → prod parity is automatic.
|
|
5155
5781
|
*
|
|
5156
|
-
* Handler shape: `export default async
|
|
5782
|
+
* Handler shape: `export default { async fetch(req, env, ctx) { ... } }`.
|
|
5157
5783
|
* `.env.local` values in the customer's working directory are passed
|
|
5158
|
-
* through to the child process
|
|
5159
|
-
*
|
|
5160
|
-
*
|
|
5784
|
+
* through to the child process and bridged into the `env` parameter so
|
|
5785
|
+
* the handler reads them the same way as in production (`env.MY_KEY`).
|
|
5786
|
+
* Existing values in the parent's env take priority (so
|
|
5787
|
+
* `MY_KEY=x amba functions dev …` wins).
|
|
5161
5788
|
*/
|
|
5162
5789
|
async function functionsDevCommand(entryPoint, options = {}) {
|
|
5163
5790
|
const port = validatePort(options.port);
|
|
@@ -5432,11 +6059,41 @@ try {
|
|
|
5432
6059
|
// \`pathToFileURL\` is a no-op equivalent on POSIX (returns file:///...).
|
|
5433
6060
|
const bundleUrl = pathToFileURL(bundlePath).href;
|
|
5434
6061
|
const mod = await import(bundleUrl);
|
|
5435
|
-
|
|
5436
|
-
|
|
6062
|
+
// Support the canonical modules export shape: export default { async fetch(req, env, ctx) { ... } }
|
|
6063
|
+
// .env.local values are already in process.env (parent passes childEnv to the child process).
|
|
6064
|
+
// Bridge them into the env parameter so dev/prod parity holds — production injects the same
|
|
6065
|
+
// names as env bindings; local dev reads them from .env.local via process.env.
|
|
6066
|
+
if (mod.default && typeof mod.default.fetch === 'function') {
|
|
6067
|
+
const env = Object.assign(Object.create(null), process.env);
|
|
6068
|
+
const fetchFn = mod.default.fetch.bind(mod.default);
|
|
6069
|
+
handler = (req) => {
|
|
6070
|
+
// Fresh ctx per request — production gives each request its own
|
|
6071
|
+
// ExecutionContext; a shared stub would leak per-request state
|
|
6072
|
+
// across concurrent requests in dev but not in prod.
|
|
6073
|
+
const ctx = {
|
|
6074
|
+
waitUntil: (p) => {
|
|
6075
|
+
// Swallow rejections from background promises — prod handles
|
|
6076
|
+
// waitUntil rejections without crashing. Leaving the rejection
|
|
6077
|
+
// unhandled would cause Node 15+ to crash the child process
|
|
6078
|
+
// even though the HTTP response was already sent.
|
|
6079
|
+
Promise.resolve(p).catch(() => {});
|
|
6080
|
+
},
|
|
6081
|
+
passThroughOnException: () => {},
|
|
6082
|
+
};
|
|
6083
|
+
return fetchFn(req, env, ctx);
|
|
6084
|
+
};
|
|
6085
|
+
} else if (typeof mod.default === 'function') {
|
|
6086
|
+
// Legacy service-worker shape (\`export default async function (req) { ... }\`).
|
|
6087
|
+
// REJECTED — production runs functions as Module Workers and the runtime
|
|
6088
|
+
// rejects this shape at upload. Accepting it in dev was a dev/prod parity
|
|
6089
|
+
// trap: a function that ran here would fail \`amba functions deploy\`. Reject
|
|
6090
|
+
// with the same fix the bundler + server hint use so dev and prod agree.
|
|
6091
|
+
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');
|
|
6092
|
+
process.exit(2);
|
|
6093
|
+
} else {
|
|
6094
|
+
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');
|
|
5437
6095
|
process.exit(2);
|
|
5438
6096
|
}
|
|
5439
|
-
handler = mod.default;
|
|
5440
6097
|
} catch (err) {
|
|
5441
6098
|
process.stderr.write(' Bundle import failed: ' + (err && err.stack ? err.stack : err) + '\\n');
|
|
5442
6099
|
process.exit(2);
|
|
@@ -5586,8 +6243,8 @@ function parseRateLimitFlags(options) {
|
|
|
5586
6243
|
* Output formatting:
|
|
5587
6244
|
* - `--json`: NDJSON to stdout (one event per line) so `| jq` works.
|
|
5588
6245
|
* - default: human-readable lines:
|
|
5589
|
-
* 2026-05-08T12:34:56.789Z scan-letter
|
|
5590
|
-
* 2026-05-08T12:34:57.012Z scan-letter
|
|
6246
|
+
* 2026-05-08T12:34:56.789Z scan-letter@v3 [info] "scanning"
|
|
6247
|
+
* 2026-05-08T12:34:57.012Z scan-letter@v3 [exception] TypeError: …
|
|
5591
6248
|
*
|
|
5592
6249
|
* Server-side scoping is enforced — the API filters by ScriptName
|
|
5593
6250
|
* prefix so a developer can never read another tenant's logs.
|
|
@@ -5602,7 +6259,8 @@ async function functionsLogsCommand(functionName, options = {}) {
|
|
|
5602
6259
|
const res = await getFunctionLogs(projectConfig.projectId, functionName, {
|
|
5603
6260
|
since: options.since,
|
|
5604
6261
|
until: options.until,
|
|
5605
|
-
limit: options.limit
|
|
6262
|
+
limit: options.limit,
|
|
6263
|
+
requestId: options.requestId
|
|
5606
6264
|
});
|
|
5607
6265
|
printEvents(res.data.events, options.json);
|
|
5608
6266
|
if (res.data.truncated) if (options.json) process.stderr.write(`{"truncated":true}\n`);
|
|
@@ -5618,7 +6276,8 @@ async function runTail(projectId, functionName, options) {
|
|
|
5618
6276
|
try {
|
|
5619
6277
|
result = await getFunctionLogs(projectId, functionName, {
|
|
5620
6278
|
since: new Date(highWaterMs).toISOString(),
|
|
5621
|
-
limit: options.limit ?? 200
|
|
6279
|
+
limit: options.limit ?? 200,
|
|
6280
|
+
requestId: options.requestId
|
|
5622
6281
|
});
|
|
5623
6282
|
} catch (err) {
|
|
5624
6283
|
console.error(pc.yellow(` ! tail fetch failed: `) + (err instanceof Error ? err.message : String(err)));
|
|
@@ -5640,7 +6299,7 @@ function printEvents(events, asJson) {
|
|
|
5640
6299
|
}
|
|
5641
6300
|
for (const e of ordered) {
|
|
5642
6301
|
const ts = e.EventTimestampMs ? new Date(e.EventTimestampMs).toISOString() : "????-??-??T??:??:??Z";
|
|
5643
|
-
const script = e.
|
|
6302
|
+
const script = e.function_name ? `${e.function_name}@v${e.version ?? "?"}` : "<unknown-function>";
|
|
5644
6303
|
if (e.Logs && e.Logs.length > 0) for (const logLine of e.Logs) {
|
|
5645
6304
|
const level = renderLevel(logLine.Level);
|
|
5646
6305
|
const msg = renderMessage(logLine.Message);
|
|
@@ -5746,11 +6405,20 @@ async function resolveSecretValue(opts) {
|
|
|
5746
6405
|
//#endregion
|
|
5747
6406
|
//#region src/commands/secrets.ts
|
|
5748
6407
|
/**
|
|
5749
|
-
* `amba secrets ...` — write
|
|
5750
|
-
*
|
|
5751
|
-
*
|
|
5752
|
-
*
|
|
5753
|
-
*
|
|
6408
|
+
* `amba secrets ...` — write secrets through the platform API. The CLI
|
|
6409
|
+
* sends `{name, value, function?}` to the admin endpoint; the server
|
|
6410
|
+
* stores the value, validates reserved binding names, and propagates it to
|
|
6411
|
+
* the deployed Worker binding(s). Sync is eventual — `amba secrets list`
|
|
6412
|
+
* shows progress.
|
|
6413
|
+
*
|
|
6414
|
+
* Two scopes:
|
|
6415
|
+
* - `--function <name>` → the secret binds to that one function.
|
|
6416
|
+
* - no `--function` → a PROJECT-WIDE secret, visible to every
|
|
6417
|
+
* function in the project (and to any function deployed later). Set it
|
|
6418
|
+
* once instead of repeating the same value per function.
|
|
6419
|
+
*
|
|
6420
|
+
* Secrets may be set BEFORE the function is deployed. The sync drains once
|
|
6421
|
+
* a deployment lands (and `amba functions deploy` re-syncs at the end).
|
|
5754
6422
|
*/
|
|
5755
6423
|
async function secretsSetCommand(name, value, options) {
|
|
5756
6424
|
validateSecretName(name);
|
|
@@ -5760,14 +6428,15 @@ async function secretsSetCommand(name, value, options) {
|
|
|
5760
6428
|
});
|
|
5761
6429
|
const projectConfig = await loadProjectConfig();
|
|
5762
6430
|
if (options.env === "prod") console.log(pc.dim(" Note: --env is informational; prod and dev share one secret namespace per project."));
|
|
6431
|
+
const scopeLabel = options.function ? options.function : "project-wide";
|
|
5763
6432
|
console.log();
|
|
5764
6433
|
console.log(pc.bold(` amba secrets set ${pc.cyan(name)}`));
|
|
5765
|
-
console.log(pc.dim(`
|
|
6434
|
+
console.log(pc.dim(` scope=${scopeLabel} env=${options.env ?? "dev"}`));
|
|
5766
6435
|
console.log();
|
|
5767
6436
|
const res = await setSecretViaApi(projectConfig.projectId, {
|
|
5768
6437
|
name,
|
|
5769
6438
|
value: resolvedValue,
|
|
5770
|
-
function: options.function
|
|
6439
|
+
...options.function ? { function: options.function } : {}
|
|
5771
6440
|
});
|
|
5772
6441
|
console.log(pc.green(" ✓") + ` Secret ${pc.cyan(name)} stored ${pc.dim(`v${res.data.version}`)}`);
|
|
5773
6442
|
console.log(pc.green(" ✓") + ` Workers Secret sync ${pc.dim(`(status=${res.data.sync_status})`)} queued`);
|
|
@@ -5775,17 +6444,22 @@ async function secretsSetCommand(name, value, options) {
|
|
|
5775
6444
|
console.log(pc.dim(" Workers Secret will be live within ~30s. Run `amba secrets list` to check sync status."));
|
|
5776
6445
|
console.log();
|
|
5777
6446
|
}
|
|
5778
|
-
async function secretsListCommand() {
|
|
5779
|
-
const res = await listSecretsViaApi((await loadProjectConfig()).projectId);
|
|
6447
|
+
async function secretsListCommand(options = {}) {
|
|
6448
|
+
const res = await listSecretsViaApi((await loadProjectConfig()).projectId, { function: options.function });
|
|
5780
6449
|
console.log();
|
|
6450
|
+
if (options.function) {
|
|
6451
|
+
console.log(pc.dim(` Secrets for function ${pc.bold(options.function)}:`));
|
|
6452
|
+
console.log();
|
|
6453
|
+
}
|
|
5781
6454
|
if (res.data.length === 0) {
|
|
5782
|
-
console.log(pc.dim(" No secrets configured."));
|
|
6455
|
+
console.log(pc.dim(options.function ? ` No secrets configured for ${options.function}.` : " No secrets configured."));
|
|
5783
6456
|
console.log();
|
|
5784
6457
|
return;
|
|
5785
6458
|
}
|
|
5786
6459
|
for (const row of res.data) {
|
|
5787
6460
|
const status = renderStatus(row.sync_status);
|
|
5788
|
-
|
|
6461
|
+
const scope = row.function ?? "project-wide";
|
|
6462
|
+
console.log(` ${pc.bold(row.name)} ` + pc.dim(`(${scope})`) + ` v${row.version} ${status}` + (row.last_error ? pc.dim(` — ${row.last_error}`) : ""));
|
|
5789
6463
|
}
|
|
5790
6464
|
console.log();
|
|
5791
6465
|
}
|
|
@@ -5834,7 +6508,7 @@ function renderStatus(status) {
|
|
|
5834
6508
|
* Project is resolved via `loadProjectConfig` (AMBA_PROJECT_ID env, then
|
|
5835
6509
|
* .env / .env.local in the cwd) — same pattern as `amba secrets *`.
|
|
5836
6510
|
*/
|
|
5837
|
-
function handleError(err) {
|
|
6511
|
+
function handleError$1(err) {
|
|
5838
6512
|
if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
|
|
5839
6513
|
else console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
5840
6514
|
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
@@ -5848,7 +6522,7 @@ function handleError(err) {
|
|
|
5848
6522
|
* its own request wrapper — same auth flow as `request()` in api-client,
|
|
5849
6523
|
* intentionally not exported there so we don't grow the public surface.
|
|
5850
6524
|
*/
|
|
5851
|
-
async function adminWrite(method, path, body) {
|
|
6525
|
+
async function adminWrite$1(method, path, body) {
|
|
5852
6526
|
const token = await resolveBearerToken();
|
|
5853
6527
|
const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
|
|
5854
6528
|
const res = await fetch(url, {
|
|
@@ -5910,7 +6584,7 @@ async function billingStatusCommand() {
|
|
|
5910
6584
|
console.log();
|
|
5911
6585
|
}
|
|
5912
6586
|
} catch (err) {
|
|
5913
|
-
handleError(err);
|
|
6587
|
+
handleError$1(err);
|
|
5914
6588
|
}
|
|
5915
6589
|
}
|
|
5916
6590
|
async function billingUpgradeCommand(input) {
|
|
@@ -5921,7 +6595,7 @@ async function billingUpgradeCommand(input) {
|
|
|
5921
6595
|
try {
|
|
5922
6596
|
const { projectId } = await loadProjectConfig();
|
|
5923
6597
|
const interval = input.interval ?? "month";
|
|
5924
|
-
const res = await adminWrite("POST", `/projects/${projectId}/billing/checkout`, {
|
|
6598
|
+
const res = await adminWrite$1("POST", `/projects/${projectId}/billing/checkout`, {
|
|
5925
6599
|
tier: input.tier,
|
|
5926
6600
|
interval
|
|
5927
6601
|
});
|
|
@@ -5935,7 +6609,7 @@ async function billingUpgradeCommand(input) {
|
|
|
5935
6609
|
console.log(pc.dim(" Subscription status updates automatically once Stripe confirms (a few seconds)."));
|
|
5936
6610
|
console.log();
|
|
5937
6611
|
} catch (err) {
|
|
5938
|
-
handleError(err);
|
|
6612
|
+
handleError$1(err);
|
|
5939
6613
|
}
|
|
5940
6614
|
}
|
|
5941
6615
|
async function billingPortalCommand() {
|
|
@@ -5945,13 +6619,13 @@ async function billingPortalCommand() {
|
|
|
5945
6619
|
console.log();
|
|
5946
6620
|
try {
|
|
5947
6621
|
const { projectId } = await loadProjectConfig();
|
|
5948
|
-
const res = await adminWrite("POST", `/projects/${projectId}/billing/portal`);
|
|
6622
|
+
const res = await adminWrite$1("POST", `/projects/${projectId}/billing/portal`);
|
|
5949
6623
|
console.log(pc.bold(" Open this URL to manage your subscription:"));
|
|
5950
6624
|
console.log();
|
|
5951
6625
|
console.log(` ${pc.cyan(res.data.url)}`);
|
|
5952
6626
|
console.log();
|
|
5953
6627
|
} catch (err) {
|
|
5954
|
-
handleError(err);
|
|
6628
|
+
handleError$1(err);
|
|
5955
6629
|
}
|
|
5956
6630
|
}
|
|
5957
6631
|
async function billingSetCeilingCommand(input) {
|
|
@@ -5961,88 +6635,447 @@ async function billingSetCeilingCommand(input) {
|
|
|
5961
6635
|
console.log();
|
|
5962
6636
|
try {
|
|
5963
6637
|
const { projectId } = await loadProjectConfig();
|
|
5964
|
-
await adminWrite(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
|
|
6638
|
+
await adminWrite$1(`PUT`, `/projects/${projectId}/billing/ceiling`, { ceiling_usd: input.ceiling });
|
|
5965
6639
|
if (input.ceiling === null) console.log(pc.green(" ✓") + " Spend ceiling removed (linear overage continues).");
|
|
5966
6640
|
else console.log(pc.green(" ✓") + ` Spend ceiling set to $${input.ceiling}/mo.`);
|
|
5967
6641
|
console.log();
|
|
5968
6642
|
} catch (err) {
|
|
5969
|
-
handleError(err);
|
|
6643
|
+
handleError$1(err);
|
|
5970
6644
|
}
|
|
5971
6645
|
}
|
|
5972
6646
|
//#endregion
|
|
5973
|
-
//#region src/commands/
|
|
6647
|
+
//#region src/commands/payments.ts
|
|
5974
6648
|
/**
|
|
5975
|
-
* `amba
|
|
5976
|
-
*
|
|
5977
|
-
*
|
|
5978
|
-
*
|
|
5979
|
-
*
|
|
5980
|
-
*
|
|
5981
|
-
*
|
|
5982
|
-
*
|
|
5983
|
-
*
|
|
5984
|
-
*
|
|
5985
|
-
*
|
|
5986
|
-
*
|
|
5987
|
-
*
|
|
5988
|
-
*
|
|
5989
|
-
*
|
|
5990
|
-
*
|
|
5991
|
-
*
|
|
5992
|
-
*
|
|
5993
|
-
*
|
|
5994
|
-
* stays one-to-one with developer actions.
|
|
6649
|
+
* `amba payments *` subcommands — CLI access to the Amba Payments (Stripe
|
|
6650
|
+
* Connect platform) surface at `/v1/admin/projects/:id/payments/*`
|
|
6651
|
+
* (tracker #10).
|
|
6652
|
+
*
|
|
6653
|
+
* Amba is the payments rail for the developer's app: the app is the seller,
|
|
6654
|
+
* Amba takes a platform fee, Stripe carries money-transmission. This is the
|
|
6655
|
+
* developer-facing onboarding + config + reporting surface, so naming Stripe
|
|
6656
|
+
* here is by intent (same as `amba billing`).
|
|
6657
|
+
*
|
|
6658
|
+
* amba payments setup — create the connected account +
|
|
6659
|
+
* print the hosted onboarding URL.
|
|
6660
|
+
* amba payments status — onboarding stage + capability flags.
|
|
6661
|
+
* amba payments set-fee <bps|off> — default platform fee (basis points).
|
|
6662
|
+
* amba payments balance — connected-account balance.
|
|
6663
|
+
* amba payments payouts — recent payouts.
|
|
6664
|
+
*
|
|
6665
|
+
* Every live call is gated by the platform's PAYMENTS_CONNECT_LIVE
|
|
6666
|
+
* kill-switch — when off the API returns PAYMENTS_NOT_ENABLED, surfaced
|
|
6667
|
+
* verbatim here. Project resolved via loadProjectConfig (same as billing).
|
|
5995
6668
|
*/
|
|
6669
|
+
function handleError(err) {
|
|
6670
|
+
if (err instanceof ApiClientError) if (err.statusCode === 401 || err.statusCode === 403) console.log(pc.red(" ✗") + " Not authenticated — run `amba login` first.");
|
|
6671
|
+
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.");
|
|
6672
|
+
else console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
6673
|
+
else if (err instanceof Error) console.log(pc.red(" ✗") + ` ${err.message}`);
|
|
6674
|
+
else console.log(pc.red(" ✗") + " Unknown error");
|
|
6675
|
+
console.log();
|
|
6676
|
+
process.exit(1);
|
|
6677
|
+
}
|
|
5996
6678
|
/**
|
|
5997
|
-
*
|
|
5998
|
-
*
|
|
5999
|
-
*
|
|
6000
|
-
* The CLI accepts customer-friendly synonyms (e.g. `int` → `integer`)
|
|
6001
|
-
* via `normalizeColumnType` so the `--field` flag stays ergonomic
|
|
6002
|
-
* without the API needing to relax its closed catalog.
|
|
6679
|
+
* POST/PUT to the admin API. Mirrors the helper in billing.ts (api-client
|
|
6680
|
+
* doesn't export a generic write for arbitrary paths).
|
|
6003
6681
|
*/
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
"
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
]);
|
|
6016
|
-
const TYPE_SYNONYMS = {
|
|
6017
|
-
int: "integer",
|
|
6018
|
-
int4: "integer",
|
|
6019
|
-
int8: "bigint",
|
|
6020
|
-
bool: "boolean",
|
|
6021
|
-
json: "jsonb",
|
|
6022
|
-
string: "text"
|
|
6023
|
-
};
|
|
6024
|
-
async function collectionsCreateCommand(name, options) {
|
|
6025
|
-
const reason = getReservationReason(name);
|
|
6026
|
-
if (reason) throw new Error(`Cannot create collection '${name}': ${reason}`);
|
|
6027
|
-
const columns = options.field.map(parseColumnSpec);
|
|
6028
|
-
const indexes = options.index.map(parseIndexSpec);
|
|
6029
|
-
const projectConfig = await loadProjectConfig();
|
|
6030
|
-
console.log();
|
|
6031
|
-
console.log(pc.bold(` amba collections create ${pc.cyan(name)}`));
|
|
6032
|
-
for (const c of columns) console.log(pc.dim(" ") + c.name + pc.dim(": ") + c.type + (c.nullable ? pc.dim(" NULL") : pc.dim(" NOT NULL")));
|
|
6033
|
-
console.log();
|
|
6034
|
-
const res = await createCollection(projectConfig.projectId, {
|
|
6035
|
-
name,
|
|
6036
|
-
columns,
|
|
6037
|
-
indexes
|
|
6682
|
+
async function adminWrite(method, path, body) {
|
|
6683
|
+
const token = await resolveBearerToken();
|
|
6684
|
+
const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
|
|
6685
|
+
const res = await fetch(url, {
|
|
6686
|
+
method,
|
|
6687
|
+
headers: {
|
|
6688
|
+
Authorization: `Bearer ${token}`,
|
|
6689
|
+
"Content-Type": "application/json",
|
|
6690
|
+
"User-Agent": "amba-cli/0.1.1"
|
|
6691
|
+
},
|
|
6692
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
6038
6693
|
});
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6694
|
+
if (!res.ok) {
|
|
6695
|
+
let message = `API request failed: ${res.status} ${res.statusText}`;
|
|
6696
|
+
let code;
|
|
6697
|
+
try {
|
|
6698
|
+
const errorBody = await res.json();
|
|
6699
|
+
if (errorBody.error?.message) {
|
|
6700
|
+
message = errorBody.error.message;
|
|
6701
|
+
code = errorBody.error.code;
|
|
6702
|
+
}
|
|
6703
|
+
} catch {}
|
|
6704
|
+
throw new ApiClientError(message, res.status, code);
|
|
6705
|
+
}
|
|
6706
|
+
return await res.json();
|
|
6042
6707
|
}
|
|
6043
|
-
|
|
6044
|
-
|
|
6045
|
-
|
|
6708
|
+
function printAccount(a) {
|
|
6709
|
+
console.log(` Account: ${pc.dim(a.stripe_account_id)}`);
|
|
6710
|
+
console.log(` Onboarding: ${pc.bold(a.onboarding_status)}`);
|
|
6711
|
+
console.log(` Charges enabled: ${a.charges_enabled ? pc.green("yes") : pc.yellow("no")}`);
|
|
6712
|
+
console.log(` Payouts enabled: ${a.payouts_enabled ? pc.green("yes") : pc.yellow("no")}`);
|
|
6713
|
+
console.log(` Country: ${a.country ?? pc.dim("—")}`);
|
|
6714
|
+
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)}%)`}`);
|
|
6715
|
+
}
|
|
6716
|
+
async function paymentsSetupCommand(input) {
|
|
6717
|
+
console.log();
|
|
6718
|
+
console.log(pc.bold(" amba payments setup"));
|
|
6719
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
6720
|
+
console.log();
|
|
6721
|
+
try {
|
|
6722
|
+
const { projectId } = await loadProjectConfig();
|
|
6723
|
+
printAccount((await adminWrite("POST", `/projects/${projectId}/payments/accounts`, {
|
|
6724
|
+
...input.country ? { country: input.country } : {},
|
|
6725
|
+
...input.email ? { email: input.email } : {}
|
|
6726
|
+
})).data);
|
|
6727
|
+
console.log();
|
|
6728
|
+
const link = await adminWrite("POST", `/projects/${projectId}/payments/accounts/link`);
|
|
6729
|
+
console.log(pc.bold(" Open this URL to finish setting up payments:"));
|
|
6730
|
+
console.log();
|
|
6731
|
+
console.log(` ${pc.cyan(link.data.url)}`);
|
|
6732
|
+
console.log();
|
|
6733
|
+
console.log(pc.dim(" Status updates automatically once setup completes."));
|
|
6734
|
+
console.log();
|
|
6735
|
+
} catch (err) {
|
|
6736
|
+
handleError(err);
|
|
6737
|
+
}
|
|
6738
|
+
}
|
|
6739
|
+
async function paymentsStatusCommand() {
|
|
6740
|
+
console.log();
|
|
6741
|
+
console.log(pc.bold(" amba payments status"));
|
|
6742
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
6743
|
+
console.log();
|
|
6744
|
+
try {
|
|
6745
|
+
const { projectId } = await loadProjectConfig();
|
|
6746
|
+
const res = await adminGet(`/projects/${projectId}/payments/accounts`);
|
|
6747
|
+
if (!res.data.connected_account) {
|
|
6748
|
+
console.log(pc.dim(" No payments account yet. Run `amba payments setup`."));
|
|
6749
|
+
console.log();
|
|
6750
|
+
return;
|
|
6751
|
+
}
|
|
6752
|
+
printAccount(res.data.connected_account);
|
|
6753
|
+
console.log();
|
|
6754
|
+
} catch (err) {
|
|
6755
|
+
handleError(err);
|
|
6756
|
+
}
|
|
6757
|
+
}
|
|
6758
|
+
async function paymentsSetFeeCommand(input) {
|
|
6759
|
+
console.log();
|
|
6760
|
+
console.log(pc.bold(" amba payments set-fee"));
|
|
6761
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
6762
|
+
console.log();
|
|
6763
|
+
try {
|
|
6764
|
+
const { projectId } = await loadProjectConfig();
|
|
6765
|
+
await adminWrite("PUT", `/projects/${projectId}/payments/config`, { default_platform_fee_bps: input.bps });
|
|
6766
|
+
if (input.bps === null) console.log(pc.green(" ✓") + " Default platform fee cleared.");
|
|
6767
|
+
else console.log(pc.green(" ✓") + ` Default platform fee set to ${input.bps} bps (${(input.bps / 100).toFixed(2)}%).`);
|
|
6768
|
+
console.log();
|
|
6769
|
+
} catch (err) {
|
|
6770
|
+
handleError(err);
|
|
6771
|
+
}
|
|
6772
|
+
}
|
|
6773
|
+
async function paymentsBalanceCommand() {
|
|
6774
|
+
console.log();
|
|
6775
|
+
console.log(pc.bold(" amba payments balance"));
|
|
6776
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
6777
|
+
console.log();
|
|
6778
|
+
try {
|
|
6779
|
+
const { projectId } = await loadProjectConfig();
|
|
6780
|
+
const res = await adminGet(`/projects/${projectId}/payments/balance`);
|
|
6781
|
+
const fmt = (b) => `${(b.amount / 100).toFixed(2)} ${b.currency.toUpperCase()}`;
|
|
6782
|
+
console.log(` Available: ${res.data.available.length ? res.data.available.map(fmt).join(", ") : pc.dim("—")}`);
|
|
6783
|
+
console.log(` Pending: ${res.data.pending.length ? res.data.pending.map(fmt).join(", ") : pc.dim("—")}`);
|
|
6784
|
+
console.log();
|
|
6785
|
+
} catch (err) {
|
|
6786
|
+
handleError(err);
|
|
6787
|
+
}
|
|
6788
|
+
}
|
|
6789
|
+
async function paymentsPayoutsCommand(input) {
|
|
6790
|
+
console.log();
|
|
6791
|
+
console.log(pc.bold(" amba payments payouts"));
|
|
6792
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
6793
|
+
console.log();
|
|
6794
|
+
try {
|
|
6795
|
+
const { projectId } = await loadProjectConfig();
|
|
6796
|
+
const res = await adminGet(`/projects/${projectId}/payments/payouts${input.limit ? `?limit=${input.limit}` : ""}`);
|
|
6797
|
+
if (res.data.length === 0) {
|
|
6798
|
+
console.log(pc.dim(" No payouts yet."));
|
|
6799
|
+
console.log();
|
|
6800
|
+
return;
|
|
6801
|
+
}
|
|
6802
|
+
for (const p of res.data) {
|
|
6803
|
+
const arrival = (/* @__PURE__ */ new Date(p.arrival_date * 1e3)).toLocaleDateString();
|
|
6804
|
+
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}`);
|
|
6805
|
+
}
|
|
6806
|
+
console.log();
|
|
6807
|
+
} catch (err) {
|
|
6808
|
+
handleError(err);
|
|
6809
|
+
}
|
|
6810
|
+
}
|
|
6811
|
+
//#endregion
|
|
6812
|
+
//#region src/commands/monetization.ts
|
|
6813
|
+
/**
|
|
6814
|
+
* `amba monetization *` subcommands — CLI access to the monetization control
|
|
6815
|
+
* plane behind `/v1/admin/projects/:id/monetization/*`.
|
|
6816
|
+
*
|
|
6817
|
+
* amba monetization plan — three-way diff (declared vs adopted baseline
|
|
6818
|
+
* vs live provider) + store-floor preflight +
|
|
6819
|
+
* drift. READ-ONLY — previews; applies nothing.
|
|
6820
|
+
* amba monetization drift — out-of-band changes to Amba-managed objects.
|
|
6821
|
+
* amba monetization export — snapshot the live config to a declarative
|
|
6822
|
+
* bundle (the `monetization` section), to stdout
|
|
6823
|
+
* or a file.
|
|
6824
|
+
* amba monetization adopt — record the live config as the managed baseline
|
|
6825
|
+
* so the next plan is a no-op. Writes ONLY Amba's
|
|
6826
|
+
* own state — never the provider. Idempotent.
|
|
6827
|
+
* amba monetization apply — push the declared config to the provider.
|
|
6828
|
+
* ADDITIVE only: create entitlements/offerings/
|
|
6829
|
+
* packages/paywall-drafts + attach products.
|
|
6830
|
+
* Refuses if the provider drifted since the plan;
|
|
6831
|
+
* never does destructive changes.
|
|
6832
|
+
*
|
|
6833
|
+
* Project is resolved via `loadProjectConfig` — same pattern as the other
|
|
6834
|
+
* project-scoped CLI commands.
|
|
6835
|
+
*/
|
|
6836
|
+
/**
|
|
6837
|
+
* Authenticated write helper (POST). Mirrors the `adminWrite` in
|
|
6838
|
+
* `commands/billing.ts` — kept local so the api-client public surface doesn't
|
|
6839
|
+
* grow a generic POST.
|
|
6840
|
+
*/
|
|
6841
|
+
async function adminPost(path, body) {
|
|
6842
|
+
const token = await resolveBearerToken();
|
|
6843
|
+
const url = `${process.env["AMBA_API_URL"] ?? "https://api.amba.dev"}/v1/admin${path}`;
|
|
6844
|
+
const res = await fetch(url, {
|
|
6845
|
+
method: "POST",
|
|
6846
|
+
headers: {
|
|
6847
|
+
Authorization: `Bearer ${token}`,
|
|
6848
|
+
"Content-Type": "application/json",
|
|
6849
|
+
"User-Agent": "amba-cli/0.1.1"
|
|
6850
|
+
},
|
|
6851
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
6852
|
+
});
|
|
6853
|
+
if (!res.ok) {
|
|
6854
|
+
let message = `API request failed: ${res.status} ${res.statusText}`;
|
|
6855
|
+
let code;
|
|
6856
|
+
try {
|
|
6857
|
+
const errorBody = await res.json();
|
|
6858
|
+
if (errorBody.error?.message) {
|
|
6859
|
+
message = errorBody.error.message;
|
|
6860
|
+
code = errorBody.error.code;
|
|
6861
|
+
}
|
|
6862
|
+
} catch {}
|
|
6863
|
+
throw new ApiClientError(message, res.status, code);
|
|
6864
|
+
}
|
|
6865
|
+
return await res.json();
|
|
6866
|
+
}
|
|
6867
|
+
function header(title) {
|
|
6868
|
+
console.log();
|
|
6869
|
+
console.log(pc.bold(` amba monetization ${title}`));
|
|
6870
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
6871
|
+
console.log();
|
|
6872
|
+
}
|
|
6873
|
+
async function monetizationPlanCommand() {
|
|
6874
|
+
const { projectId } = await loadProjectConfig();
|
|
6875
|
+
header("plan");
|
|
6876
|
+
const plan = (await adminGet(`/projects/${projectId}/monetization/plan`)).data;
|
|
6877
|
+
if (plan.clean) {
|
|
6878
|
+
console.log(pc.green(" ✓") + " No changes — declared config matches the live provider.");
|
|
6879
|
+
console.log();
|
|
6880
|
+
return;
|
|
6881
|
+
}
|
|
6882
|
+
if (plan.ops.length > 0) {
|
|
6883
|
+
console.log(pc.bold(" Pending operations (preview — nothing is applied):"));
|
|
6884
|
+
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}]`)}`);
|
|
6885
|
+
console.log();
|
|
6886
|
+
}
|
|
6887
|
+
if (plan.store_floor.length > 0) {
|
|
6888
|
+
console.log(pc.yellow(" Store-floor preflight — referenced products missing in the provider:"));
|
|
6889
|
+
for (const sf of plan.store_floor) console.log(` ${pc.bold(sf.product_key)} → ${sf.store_identifier} (${sf.store})`);
|
|
6890
|
+
console.log();
|
|
6891
|
+
}
|
|
6892
|
+
if (plan.drift.length > 0) {
|
|
6893
|
+
console.log(pc.yellow(" Drift — managed objects changed out-of-band in the provider:"));
|
|
6894
|
+
for (const d of plan.drift) console.log(` ${pc.bold(d.object_type)} ${d.identifier}`);
|
|
6895
|
+
console.log();
|
|
6896
|
+
}
|
|
6897
|
+
}
|
|
6898
|
+
async function monetizationDriftCommand() {
|
|
6899
|
+
const { projectId } = await loadProjectConfig();
|
|
6900
|
+
header("drift");
|
|
6901
|
+
const data = (await adminGet(`/projects/${projectId}/monetization/drift`)).data;
|
|
6902
|
+
if (!data.has_drift) {
|
|
6903
|
+
console.log(pc.green(" ✓") + " No drift — managed config matches the live provider.");
|
|
6904
|
+
console.log();
|
|
6905
|
+
return;
|
|
6906
|
+
}
|
|
6907
|
+
console.log(pc.yellow(" Drift detected:"));
|
|
6908
|
+
for (const d of data.drift) console.log(` ${pc.bold(d.object_type)} ${d.identifier}`);
|
|
6909
|
+
console.log();
|
|
6910
|
+
}
|
|
6911
|
+
async function monetizationExportCommand(options = {}) {
|
|
6912
|
+
const { projectId } = await loadProjectConfig();
|
|
6913
|
+
const bundle = {
|
|
6914
|
+
version: 1,
|
|
6915
|
+
monetization: (await adminGet(`/projects/${projectId}/monetization/export`)).data.monetization
|
|
6916
|
+
};
|
|
6917
|
+
const json = JSON.stringify(bundle, null, 2);
|
|
6918
|
+
if (options.file) {
|
|
6919
|
+
await writeFile(resolve(options.file), json + "\n", "utf-8");
|
|
6920
|
+
console.log();
|
|
6921
|
+
console.log(pc.green(" ✓") + ` Wrote live monetization config to ${pc.cyan(options.file)}.`);
|
|
6922
|
+
console.log();
|
|
6923
|
+
} else console.log(json);
|
|
6924
|
+
}
|
|
6925
|
+
async function monetizationAdoptCommand(options = {}) {
|
|
6926
|
+
const { projectId } = await loadProjectConfig();
|
|
6927
|
+
header("adopt");
|
|
6928
|
+
if (!options.all) {
|
|
6929
|
+
console.log(" ✗ Pass --all to adopt the entire live config as the managed baseline.");
|
|
6930
|
+
process.exit(1);
|
|
6931
|
+
}
|
|
6932
|
+
const d = (await adminPost(`/projects/${projectId}/monetization/adopt`, { adopt_all: true })).data;
|
|
6933
|
+
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)`));
|
|
6934
|
+
console.log(pc.dim(" The next `amba monetization plan` is now a clean no-op."));
|
|
6935
|
+
console.log();
|
|
6936
|
+
}
|
|
6937
|
+
async function monetizationApplyCommand(options = {}) {
|
|
6938
|
+
const { projectId } = await loadProjectConfig();
|
|
6939
|
+
header("apply");
|
|
6940
|
+
const plan = (await adminGet(`/projects/${projectId}/monetization/plan`)).data;
|
|
6941
|
+
if (plan.store_floor.length > 0) {
|
|
6942
|
+
console.log(pc.yellow(" Store floor not satisfied — these products must exist in the provider first:"));
|
|
6943
|
+
for (const sf of plan.store_floor) console.log(` ${pc.bold(sf.product_key)} → ${sf.store_identifier} (${sf.store})`);
|
|
6944
|
+
console.log();
|
|
6945
|
+
console.log(" ✗ Configure these in the store, then run plan + apply again.");
|
|
6946
|
+
process.exit(1);
|
|
6947
|
+
}
|
|
6948
|
+
if (plan.clean) {
|
|
6949
|
+
console.log(pc.green(" ✓") + " Nothing to apply — declared config matches the provider.");
|
|
6950
|
+
console.log();
|
|
6951
|
+
return;
|
|
6952
|
+
}
|
|
6953
|
+
if (options.reconcile && options.reconcile !== "amba" && options.reconcile !== "adopt") {
|
|
6954
|
+
console.log(pc.red(" ✗") + " --reconcile must be 'amba' or 'adopt'.");
|
|
6955
|
+
process.exit(1);
|
|
6956
|
+
}
|
|
6957
|
+
const body = { plan_hash: plan.plan_hash };
|
|
6958
|
+
if (options.confirm) body["confirm"] = plan.plan_hash;
|
|
6959
|
+
if (options.allowDetachLive) body["allow_detach_live"] = true;
|
|
6960
|
+
if (options.reconcile) body["reconcile"] = options.reconcile;
|
|
6961
|
+
let res;
|
|
6962
|
+
try {
|
|
6963
|
+
res = await adminPost(`/projects/${projectId}/monetization/apply`, body);
|
|
6964
|
+
} catch (err) {
|
|
6965
|
+
if (err instanceof ApiClientError && err.errorCode === "MONETIZATION_CONFIRM_REQUIRED") {
|
|
6966
|
+
console.log(pc.yellow(" ⚠ This plan includes gated destructive operation(s) — nothing was applied."));
|
|
6967
|
+
console.log(pc.dim(" Review the plan, then re-run with --confirm to execute the gated ops:"));
|
|
6968
|
+
console.log(` ${pc.cyan("amba monetization apply --confirm")}`);
|
|
6969
|
+
console.log();
|
|
6970
|
+
process.exit(1);
|
|
6971
|
+
}
|
|
6972
|
+
if (err instanceof ApiClientError && err.errorCode === "MONETIZATION_INVARIANT") {
|
|
6973
|
+
console.log(pc.red(" ✗ Blocked by the ordering invariant — nothing was applied:"));
|
|
6974
|
+
console.log(pc.dim(` ${err.message}`));
|
|
6975
|
+
console.log();
|
|
6976
|
+
process.exit(1);
|
|
6977
|
+
}
|
|
6978
|
+
throw err;
|
|
6979
|
+
}
|
|
6980
|
+
const d = res.data;
|
|
6981
|
+
if (d.sandbox_stage?.ran) {
|
|
6982
|
+
const tag = d.sandbox_stage.passed ? pc.green("✓ sandbox passed") : pc.red("✗ sandbox failed");
|
|
6983
|
+
console.log(` ${tag} ` + pc.dim(d.sandbox_stage.detail));
|
|
6984
|
+
console.log();
|
|
6985
|
+
}
|
|
6986
|
+
if (d.status === "succeeded") console.log(pc.green(" ✓") + ` Applied ${d.applied} change(s) to the provider ` + pc.dim(`(${d.skipped_existing} already present, ${d.gated_applied} destructive)`));
|
|
6987
|
+
else {
|
|
6988
|
+
console.log(pc.red(" ✗") + ` Apply stopped after a failure (${d.applied} applied, ${d.failed} failed).` + (d.stopped_at ? ` Stopped at: ${d.stopped_at}` : ""));
|
|
6989
|
+
const failed = d.results.find((r) => r.status === "failed");
|
|
6990
|
+
if (failed?.error) console.log(pc.dim(` ${failed.error}`));
|
|
6991
|
+
}
|
|
6992
|
+
if (d.gated.length > 0) {
|
|
6993
|
+
console.log();
|
|
6994
|
+
console.log(pc.yellow(" Gated (destructive — run with --confirm to execute):"));
|
|
6995
|
+
for (const g of d.gated) console.log(` ${pc.bold(g.op.op)} ${g.op.object_type} ${g.op.identifier}`);
|
|
6996
|
+
}
|
|
6997
|
+
if (d.refused.length > 0) {
|
|
6998
|
+
console.log();
|
|
6999
|
+
console.log(pc.yellow(" Not applicable via API (do these in the provider dashboard):"));
|
|
7000
|
+
for (const r of d.refused) console.log(` ${pc.bold(r.op.op)} ${r.op.object_type} ${r.op.identifier}`);
|
|
7001
|
+
}
|
|
7002
|
+
console.log();
|
|
7003
|
+
if (d.status !== "succeeded") process.exit(1);
|
|
7004
|
+
}
|
|
7005
|
+
//#endregion
|
|
7006
|
+
//#region src/commands/collections.ts
|
|
7007
|
+
/**
|
|
7008
|
+
* `amba collections ...` — thin shells over the admin collection routes.
|
|
7009
|
+
*
|
|
7010
|
+
* Wire shapes:
|
|
7011
|
+
*
|
|
7012
|
+
* POST /admin/projects/:p/collections — create (full schema)
|
|
7013
|
+
* GET /admin/projects/:p/collections — list
|
|
7014
|
+
* PATCH /admin/projects/:p/collections/:name — single-op alter
|
|
7015
|
+
* DELETE /admin/projects/:p/collections/:name — drop (requires ?confirm)
|
|
7016
|
+
*
|
|
7017
|
+
* The CLI's job is to:
|
|
7018
|
+
* 1. Validate names / types client-side so an obviously-bad invocation
|
|
7019
|
+
* doesn't round-trip.
|
|
7020
|
+
* 2. Translate the user-friendly CLI flag shape (`--field name:type`,
|
|
7021
|
+
* `--add-field`, `--drop-field`) into the wire shape (single op per
|
|
7022
|
+
* PATCH).
|
|
7023
|
+
* 3. Block until the server reports success.
|
|
7024
|
+
* 4. Surface server errors with a sensible CLI message.
|
|
7025
|
+
*
|
|
7026
|
+
* Multi-op alters are issued sequentially so the operator audit trail
|
|
7027
|
+
* stays one-to-one with developer actions.
|
|
7028
|
+
*/
|
|
7029
|
+
/**
|
|
7030
|
+
* Closed-set type catalog. Matches data's `ColumnType` exactly — keeps
|
|
7031
|
+
* the CLI fail-fast path aligned with the DDL emit's accepted shapes.
|
|
7032
|
+
*
|
|
7033
|
+
* The CLI accepts customer-friendly synonyms (e.g. `int` → `integer`)
|
|
7034
|
+
* via `normalizeColumnType` so the `--field` flag stays ergonomic
|
|
7035
|
+
* without the API needing to relax its closed catalog.
|
|
7036
|
+
*/
|
|
7037
|
+
const ALLOWED_TYPES = new Set([
|
|
7038
|
+
"uuid",
|
|
7039
|
+
"text",
|
|
7040
|
+
"integer",
|
|
7041
|
+
"bigint",
|
|
7042
|
+
"numeric",
|
|
7043
|
+
"boolean",
|
|
7044
|
+
"timestamptz",
|
|
7045
|
+
"date",
|
|
7046
|
+
"jsonb",
|
|
7047
|
+
"vector"
|
|
7048
|
+
]);
|
|
7049
|
+
const TYPE_SYNONYMS = {
|
|
7050
|
+
int: "integer",
|
|
7051
|
+
int4: "integer",
|
|
7052
|
+
int8: "bigint",
|
|
7053
|
+
bool: "boolean",
|
|
7054
|
+
json: "jsonb",
|
|
7055
|
+
string: "text"
|
|
7056
|
+
};
|
|
7057
|
+
async function collectionsCreateCommand(name, options) {
|
|
7058
|
+
const reason = getReservationReason(name);
|
|
7059
|
+
if (reason) throw new Error(`Cannot create collection '${name}': ${reason}`);
|
|
7060
|
+
const columns = options.field.map(parseColumnSpec);
|
|
7061
|
+
const indexes = options.index.map(parseIndexSpec);
|
|
7062
|
+
const projectConfig = await loadProjectConfig();
|
|
7063
|
+
console.log();
|
|
7064
|
+
console.log(pc.bold(` amba collections create ${pc.cyan(name)}`));
|
|
7065
|
+
for (const c of columns) console.log(pc.dim(" ") + c.name + pc.dim(": ") + c.type + (c.nullable ? pc.dim(" NULL") : pc.dim(" NOT NULL")));
|
|
7066
|
+
console.log();
|
|
7067
|
+
const res = await createCollection(projectConfig.projectId, {
|
|
7068
|
+
name,
|
|
7069
|
+
columns,
|
|
7070
|
+
indexes
|
|
7071
|
+
});
|
|
7072
|
+
console.log(pc.green(" ✓") + ` Created — version ${res.data.version}`);
|
|
7073
|
+
console.log(pc.dim(` workflow_id: ${res.data.workflow_id}`));
|
|
7074
|
+
console.log();
|
|
7075
|
+
}
|
|
7076
|
+
async function collectionsAlterCommand(name, options) {
|
|
7077
|
+
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.");
|
|
7078
|
+
const confirmed = new Set(options.confirm ?? []);
|
|
6046
7079
|
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).`);
|
|
6047
7080
|
const projectConfig = await loadProjectConfig();
|
|
6048
7081
|
for (const fieldSpec of options.addField) {
|
|
@@ -6268,8 +7301,22 @@ const PG_TYPE_TO_TS = {
|
|
|
6268
7301
|
jsonb: "unknown",
|
|
6269
7302
|
json: "unknown"
|
|
6270
7303
|
};
|
|
7304
|
+
const ARRAY_UDT_TO_TS = {
|
|
7305
|
+
_text: "string",
|
|
7306
|
+
_varchar: "string",
|
|
7307
|
+
_uuid: "string",
|
|
7308
|
+
_int4: "number",
|
|
7309
|
+
_int8: "number",
|
|
7310
|
+
_int2: "number",
|
|
7311
|
+
_numeric: "number",
|
|
7312
|
+
_float8: "number",
|
|
7313
|
+
_float4: "number",
|
|
7314
|
+
_bool: "boolean"
|
|
7315
|
+
};
|
|
6271
7316
|
function tsTypeForColumn(col) {
|
|
6272
|
-
|
|
7317
|
+
let base;
|
|
7318
|
+
if (col.data_type === "ARRAY") base = `${col.udt_name ? ARRAY_UDT_TO_TS[col.udt_name] ?? "unknown" : "unknown"}[]`;
|
|
7319
|
+
else base = PG_TYPE_TO_TS[col.data_type] ?? "unknown";
|
|
6273
7320
|
if (col.column_name === "deleted_at") return "string | null";
|
|
6274
7321
|
if (col.is_nullable === "YES") return `${base} | null`;
|
|
6275
7322
|
return base;
|
|
@@ -6391,13 +7438,21 @@ function makeCodegenHttpClient() {
|
|
|
6391
7438
|
* directory (mirrors `wrangler pages deploy ./out` semantics). Dynamic
|
|
6392
7439
|
* logic belongs in `amba functions deploy`, not in a site directory.
|
|
6393
7440
|
*/
|
|
6394
|
-
const SITE_NAME_RE = /^[a-z][a-z0-9_-]{0,49}$/;
|
|
6395
|
-
const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
7441
|
+
const SITE_NAME_RE$1 = /^[a-z][a-z0-9_-]{0,49}$/;
|
|
7442
|
+
const HOSTNAME_RE$1 = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
7443
|
+
const MAX_CERT_CN_LENGTH = 64;
|
|
7444
|
+
const PLATFORM_DOMAINS = ["amba.host", "amba.dev"];
|
|
7445
|
+
function isPlatformDomain(host) {
|
|
7446
|
+
return PLATFORM_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`));
|
|
7447
|
+
}
|
|
6396
7448
|
function validateSiteName(name) {
|
|
6397
|
-
if (!SITE_NAME_RE.test(name)) throw new Error(`Invalid site name '${name}'. Must match /^[a-z][a-z0-9_-]{0,49}$/ (lowercase ASCII, digits, underscore or hyphen; ≤50 chars).`);
|
|
7449
|
+
if (!SITE_NAME_RE$1.test(name)) throw new Error(`Invalid site name '${name}'. Must match /^[a-z][a-z0-9_-]{0,49}$/ (lowercase ASCII, digits, underscore or hyphen; ≤50 chars).`);
|
|
6398
7450
|
}
|
|
6399
7451
|
function validateHostname(host) {
|
|
6400
|
-
|
|
7452
|
+
const h = host.trim().toLowerCase();
|
|
7453
|
+
if (!HOSTNAME_RE$1.test(h)) throw new Error(`Invalid hostname '${host}'. Must be a DNS-shaped name (e.g. site.example.com).`);
|
|
7454
|
+
if (h.length > MAX_CERT_CN_LENGTH) throw new Error(`Hostname '${host}' is too long for certificate issuance (max ${MAX_CERT_CN_LENGTH} characters). Use a shorter subdomain.`);
|
|
7455
|
+
if (isPlatformDomain(h)) throw new Error(`'${host}' is reserved by the platform. Attach a domain you own, e.g. app.yourdomain.com.`);
|
|
6401
7456
|
}
|
|
6402
7457
|
/**
|
|
6403
7458
|
* Per-deploy size cap. The hosting provider enforces 25 MiB per file +
|
|
@@ -6446,7 +7501,7 @@ async function sitesDeployCommand(inputDir, options = {}) {
|
|
|
6446
7501
|
if (domains.data.length > 0) {
|
|
6447
7502
|
console.log();
|
|
6448
7503
|
console.log(pc.dim(" Domains:"));
|
|
6449
|
-
for (const d of domains.data) console.log(` ${pc.bold(d.hostname)} ${
|
|
7504
|
+
for (const d of domains.data) console.log(` ${pc.bold(d.hostname)} ${formatDomainStatus(d)}`);
|
|
6450
7505
|
}
|
|
6451
7506
|
console.log();
|
|
6452
7507
|
}
|
|
@@ -6496,32 +7551,51 @@ async function sitesDomainAddCommand(hostname, options) {
|
|
|
6496
7551
|
console.log();
|
|
6497
7552
|
const res = await addSiteDomainViaApi(projectId, options.site, hostname);
|
|
6498
7553
|
console.log(pc.green(" ✓") + ` Custom hostname registered`);
|
|
7554
|
+
const validation = res.data.ssl_validation ?? [];
|
|
7555
|
+
const ownership = res.data.ownership_verification;
|
|
7556
|
+
const isApex = res.data.is_apex ?? false;
|
|
7557
|
+
if (validation.length > 0 || ownership) {
|
|
7558
|
+
console.log();
|
|
7559
|
+
console.log(pc.dim(" 1. Publish these DNS records to verify ownership:"));
|
|
7560
|
+
for (const rec of validation) if (rec.txt_name && rec.txt_value) console.log(` ${pc.bold("TXT")} ${rec.txt_name} → ${pc.cyan(rec.txt_value)}`);
|
|
7561
|
+
if (ownership?.name && ownership.value) console.log(` ${pc.bold("TXT")} ${ownership.name} → ${pc.cyan(ownership.value)}`);
|
|
7562
|
+
console.log();
|
|
7563
|
+
console.log(pc.dim(" 2. Point your hostname at the Amba target:"));
|
|
7564
|
+
} else {
|
|
7565
|
+
console.log();
|
|
7566
|
+
console.log(pc.dim(" Point your hostname at the Amba target:"));
|
|
7567
|
+
}
|
|
7568
|
+
console.log(` ${pc.bold("CNAME")} ${hostname} → ${pc.cyan(res.data.dns_target)}`);
|
|
6499
7569
|
console.log();
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
7570
|
+
if (isApex) {
|
|
7571
|
+
console.log(pc.dim(` ${hostname} is a root domain — the CNAME above works only if your DNS provider`));
|
|
7572
|
+
console.log(pc.dim(` flattens CNAME-at-root. If it doesn't, A-record apex support is coming soon;`));
|
|
7573
|
+
console.log(pc.dim(` use a subdomain like app.${hostname} in the meantime.`));
|
|
7574
|
+
console.log();
|
|
7575
|
+
}
|
|
6503
7576
|
if (options.noWait) {
|
|
6504
|
-
console.log(pc.yellow(" ! --no-wait — skipping
|
|
7577
|
+
console.log(pc.yellow(" ! --no-wait — skipping status poll. Run `amba sites describe` later."));
|
|
6505
7578
|
return;
|
|
6506
7579
|
}
|
|
6507
7580
|
const timeout = (options.timeout ?? 600) * 1e3;
|
|
6508
7581
|
const start = Date.now();
|
|
6509
|
-
let
|
|
7582
|
+
let lastLine = "";
|
|
6510
7583
|
while (Date.now() - start < timeout) {
|
|
6511
7584
|
const row = (await listSiteDomains(projectId, options.site)).data.find((d) => d.hostname === hostname);
|
|
6512
7585
|
if (!row) throw new Error(`Domain ${hostname} disappeared from listing — check API state.`);
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
7586
|
+
const line = formatDomainStatus(row);
|
|
7587
|
+
if (line !== lastLine) {
|
|
7588
|
+
console.log(pc.dim(` ${line}`));
|
|
7589
|
+
lastLine = line;
|
|
6516
7590
|
}
|
|
6517
|
-
if (row.cert_status === "active") {
|
|
6518
|
-
console.log(pc.green(" ✓") + ` ${hostname} live with valid
|
|
7591
|
+
if (row.cert_status === "active" && row.ownership_status === "active") {
|
|
7592
|
+
console.log(pc.green(" ✓") + ` ${hostname} is live with a valid certificate.`);
|
|
6519
7593
|
return;
|
|
6520
7594
|
}
|
|
6521
|
-
if (row.cert_status === "error") throw new Error(`
|
|
7595
|
+
if (row.cert_status === "error" || row.ownership_status === "error") throw new Error(`Domain setup failed for ${hostname}. Confirm the TXT record(s) above are published and the CNAME points at ${res.data.dns_target}.`);
|
|
6522
7596
|
await sleep(5e3);
|
|
6523
7597
|
}
|
|
6524
|
-
console.log(pc.yellow(` ! Timed out after ${options.timeout ?? 600}s
|
|
7598
|
+
console.log(pc.yellow(` ! Timed out after ${options.timeout ?? 600}s. Re-run \`amba sites describe ${options.site}\` to check status.`));
|
|
6525
7599
|
}
|
|
6526
7600
|
async function sitesDomainListCommand(siteName) {
|
|
6527
7601
|
validateSiteName(siteName);
|
|
@@ -6532,7 +7606,7 @@ async function sitesDomainListCommand(siteName) {
|
|
|
6532
7606
|
console.log();
|
|
6533
7607
|
return;
|
|
6534
7608
|
}
|
|
6535
|
-
for (const d of res.data) console.log(` ${pc.bold(d.hostname)} ${
|
|
7609
|
+
for (const d of res.data) console.log(` ${pc.bold(d.hostname)} ${formatDomainStatus(d)}`);
|
|
6536
7610
|
console.log();
|
|
6537
7611
|
}
|
|
6538
7612
|
async function sitesDomainRemoveCommand(hostname, options) {
|
|
@@ -6641,13 +7715,502 @@ function formatCertStatus(s) {
|
|
|
6641
7715
|
if (s === "error") return pc.red(s);
|
|
6642
7716
|
return pc.yellow(s);
|
|
6643
7717
|
}
|
|
7718
|
+
/**
|
|
7719
|
+
* Render BOTH lifecycle states for a domain row. A domain is only live when
|
|
7720
|
+
* ownership AND cert are both `active`, so display paths must show both —
|
|
7721
|
+
* showing cert alone reads "live" while ownership is still pending.
|
|
7722
|
+
*/
|
|
7723
|
+
function formatDomainStatus(d) {
|
|
7724
|
+
return `ownership: ${formatCertStatus(d.ownership_status)} cert: ${formatCertStatus(d.cert_status)}`;
|
|
7725
|
+
}
|
|
6644
7726
|
function sleep(ms) {
|
|
6645
7727
|
return new Promise((r) => setTimeout(r, ms));
|
|
6646
7728
|
}
|
|
6647
7729
|
//#endregion
|
|
7730
|
+
//#region src/commands/domains.ts
|
|
7731
|
+
/**
|
|
7732
|
+
* `amba domains ...` commands — buy a domain through Amba.
|
|
7733
|
+
*
|
|
7734
|
+
* amba domains search <query> find available domains + prices
|
|
7735
|
+
* amba domains check <domain...> authoritative availability + price
|
|
7736
|
+
* amba domains buy <domain> --site s buy + connect to a site
|
|
7737
|
+
* amba domains list domains this project has purchased
|
|
7738
|
+
*
|
|
7739
|
+
* Search + check are free. `buy` surfaces the cost and requires explicit
|
|
7740
|
+
* confirmation (interactive y/N, or `--yes`) before it submits a purchase —
|
|
7741
|
+
* and the API itself only executes a real, paid registration when the
|
|
7742
|
+
* platform has live purchasing enabled. A registered domain is auto-
|
|
7743
|
+
* connected to the chosen site with no DNS setup by the customer.
|
|
7744
|
+
*/
|
|
7745
|
+
const HOSTNAME_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
7746
|
+
const SITE_NAME_RE = /^[a-z][a-z0-9_-]{0,49}$/;
|
|
7747
|
+
function confirm(question) {
|
|
7748
|
+
const rl = createInterface({
|
|
7749
|
+
input: process.stdin,
|
|
7750
|
+
output: process.stdout
|
|
7751
|
+
});
|
|
7752
|
+
return new Promise((resolve) => {
|
|
7753
|
+
rl.question(question, (answer) => {
|
|
7754
|
+
rl.close();
|
|
7755
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
7756
|
+
});
|
|
7757
|
+
});
|
|
7758
|
+
}
|
|
7759
|
+
function formatPrice(price, currency) {
|
|
7760
|
+
if (price === null) return pc.dim("price unavailable");
|
|
7761
|
+
return pc.bold(`$${price.toFixed(2)} ${currency}/yr`);
|
|
7762
|
+
}
|
|
7763
|
+
async function domainsSearchCommand(query, options = {}) {
|
|
7764
|
+
const { projectId } = await loadProjectConfig();
|
|
7765
|
+
const res = await searchDomainsViaApi(projectId, query, options.limit);
|
|
7766
|
+
console.log();
|
|
7767
|
+
if (res.data.length === 0) {
|
|
7768
|
+
console.log(pc.dim(` No suggestions for "${query}".`));
|
|
7769
|
+
console.log();
|
|
7770
|
+
return;
|
|
7771
|
+
}
|
|
7772
|
+
for (const s of res.data) {
|
|
7773
|
+
const tag = s.available ? pc.green("available") : pc.red("taken");
|
|
7774
|
+
const premium = s.premium ? pc.yellow(" premium") : "";
|
|
7775
|
+
console.log(` ${pc.bold(s.domain)} ${tag}${premium} ${formatPrice(s.price_usd, s.currency)}`);
|
|
7776
|
+
}
|
|
7777
|
+
console.log();
|
|
7778
|
+
console.log(pc.dim(" Buy one with: amba domains buy <domain> --site <site>"));
|
|
7779
|
+
console.log();
|
|
7780
|
+
}
|
|
7781
|
+
async function domainsCheckCommand(domains) {
|
|
7782
|
+
if (domains.length === 0) throw new Error("Pass at least one domain to check.");
|
|
7783
|
+
if (domains.length > 20) throw new Error("At most 20 domains per check.");
|
|
7784
|
+
const normalised = domains.map((d) => d.toLowerCase());
|
|
7785
|
+
for (const d of normalised) if (!HOSTNAME_RE.test(d)) throw new Error(`Invalid domain '${d}'.`);
|
|
7786
|
+
const { projectId } = await loadProjectConfig();
|
|
7787
|
+
const res = await checkDomainsViaApi(projectId, normalised);
|
|
7788
|
+
console.log();
|
|
7789
|
+
for (const a of res.data) {
|
|
7790
|
+
const tag = a.available ? pc.green("available") : pc.red("taken");
|
|
7791
|
+
const unsupported = a.supported ? "" : pc.yellow(" (extension not supported)");
|
|
7792
|
+
console.log(` ${pc.bold(a.domain)} ${tag}${unsupported} ${formatPrice(a.price_usd, a.currency)}`);
|
|
7793
|
+
}
|
|
7794
|
+
console.log();
|
|
7795
|
+
}
|
|
7796
|
+
async function domainsBuyCommand(domain, options) {
|
|
7797
|
+
const hostname = domain.toLowerCase();
|
|
7798
|
+
if (!HOSTNAME_RE.test(hostname)) throw new Error(`Invalid domain '${domain}'.`);
|
|
7799
|
+
if (!options.site || !SITE_NAME_RE.test(options.site)) throw new Error("--site <name> is required (the site to connect the domain to).");
|
|
7800
|
+
const { projectId } = await loadProjectConfig();
|
|
7801
|
+
console.log();
|
|
7802
|
+
console.log(pc.bold(` amba domains buy ${pc.cyan(hostname)}`));
|
|
7803
|
+
console.log(pc.dim(` → connect to site ${pc.cyan(options.site)}`));
|
|
7804
|
+
console.log();
|
|
7805
|
+
const check = await checkDomainsViaApi(projectId, [hostname]);
|
|
7806
|
+
const availability = check.data.find((d) => d.domain === hostname) ?? check.data[0];
|
|
7807
|
+
if (!availability) throw new Error("Could not determine availability for that domain.");
|
|
7808
|
+
if (!availability.available) throw new Error(`${hostname} is not available to register. Try \`amba domains search\`.`);
|
|
7809
|
+
if (!availability.supported) throw new Error(`${hostname} uses an extension we can't register yet.`);
|
|
7810
|
+
if (availability.price_usd === null) throw new Error(`${hostname} can't be priced right now — try again shortly.`);
|
|
7811
|
+
const price = availability.price_usd;
|
|
7812
|
+
console.log(` Price: ${formatPrice(price, availability.currency)}` + (availability.premium ? pc.yellow(" (premium)") : ""));
|
|
7813
|
+
console.log(pc.dim(` Includes: registration${options.noPrivacy ? "" : " + WHOIS privacy"}${options.noAutoRenew ? "" : " + auto-renew"}, auto-connected to ${options.site} (no DNS setup).`));
|
|
7814
|
+
console.log();
|
|
7815
|
+
if (!options.yes) {
|
|
7816
|
+
if (!await confirm(pc.yellow(` Buy ${hostname} for $${price.toFixed(2)} ${availability.currency}? `) + "(y/N) ")) {
|
|
7817
|
+
console.log(pc.dim(" Cancelled — nothing was purchased."));
|
|
7818
|
+
console.log();
|
|
7819
|
+
return;
|
|
7820
|
+
}
|
|
7821
|
+
}
|
|
7822
|
+
const r = (await purchaseDomainViaApi(projectId, {
|
|
7823
|
+
domain: hostname,
|
|
7824
|
+
site: options.site,
|
|
7825
|
+
accept_price_usd: price,
|
|
7826
|
+
confirm: true,
|
|
7827
|
+
privacy: !options.noPrivacy,
|
|
7828
|
+
auto_renew: !options.noAutoRenew,
|
|
7829
|
+
...options.years ? { years: options.years } : {}
|
|
7830
|
+
})).data;
|
|
7831
|
+
if (!r.executed) {
|
|
7832
|
+
if (r.gated) {
|
|
7833
|
+
console.log(pc.yellow(" ! Domain purchasing is not enabled on this deployment."));
|
|
7834
|
+
console.log(pc.dim(" The domain is available at the price shown; no charge was made."));
|
|
7835
|
+
} else {
|
|
7836
|
+
console.log(pc.yellow(" ! Purchase not completed."));
|
|
7837
|
+
if (r.message) console.log(pc.dim(` ${r.message}`));
|
|
7838
|
+
}
|
|
7839
|
+
console.log();
|
|
7840
|
+
return;
|
|
7841
|
+
}
|
|
7842
|
+
console.log(pc.green(" ✓") + ` Registered ${pc.bold(hostname)}`);
|
|
7843
|
+
if (r.provisioned) {
|
|
7844
|
+
console.log(pc.green(" ✓") + ` Connected to ${options.site}${r.dns_autoconfigured ? " — no DNS setup needed" : ""}.`);
|
|
7845
|
+
if (r.public_url) console.log(pc.green(" ✓") + ` Live at ${pc.underline(r.public_url)}`);
|
|
7846
|
+
console.log(pc.dim(" The TLS certificate is issuing now (usually a minute or two)."));
|
|
7847
|
+
} else if (r.message) console.log(pc.dim(` ${r.message}`));
|
|
7848
|
+
console.log();
|
|
7849
|
+
}
|
|
7850
|
+
async function domainsListCommand() {
|
|
7851
|
+
const { projectId } = await loadProjectConfig();
|
|
7852
|
+
const res = await listPurchasedDomainsViaApi(projectId);
|
|
7853
|
+
console.log();
|
|
7854
|
+
if (res.data.length === 0) {
|
|
7855
|
+
console.log(pc.dim(" No domains purchased through Amba yet."));
|
|
7856
|
+
console.log(pc.dim(" Buy one with: amba domains buy <domain> --site <site>"));
|
|
7857
|
+
console.log();
|
|
7858
|
+
return;
|
|
7859
|
+
}
|
|
7860
|
+
for (const d of res.data) {
|
|
7861
|
+
const domain = String(d.domain ?? "");
|
|
7862
|
+
const status = String(d.status ?? "");
|
|
7863
|
+
const coloured = status === "active" ? pc.green(status) : status === "failed" ? pc.red(status) : pc.yellow(status);
|
|
7864
|
+
console.log(` ${pc.bold(domain)} ${coloured}`);
|
|
7865
|
+
}
|
|
7866
|
+
console.log();
|
|
7867
|
+
}
|
|
7868
|
+
//#endregion
|
|
7869
|
+
//#region src/commands/iac.ts
|
|
7870
|
+
/**
|
|
7871
|
+
* `amba apply` / `amba diff` / `amba export` — declarative
|
|
7872
|
+
* infrastructure-as-code for a project's reusable configuration.
|
|
7873
|
+
*
|
|
7874
|
+
* A declarative bundle (default `amba.config.json`) captures the entities a
|
|
7875
|
+
* developer hand-builds once and wants to stand up identically elsewhere:
|
|
7876
|
+
* remote configs, collection schemas, content libraries + items, currencies,
|
|
7877
|
+
* achievements, streaks, challenges, leaderboards, and xp_rules. It carries
|
|
7878
|
+
* CONFIGURATION ONLY — no secrets, no per-user data.
|
|
7879
|
+
*
|
|
7880
|
+
* These commands wrap the platform's promotion endpoints:
|
|
7881
|
+
* GET /v1/admin/projects/:p/promotion/export
|
|
7882
|
+
* POST /v1/admin/projects/:p/promotion/import (mode=skip_existing|merge)
|
|
7883
|
+
*
|
|
7884
|
+
* - `amba export [file]` writes the current project's bundle to disk.
|
|
7885
|
+
* - `amba apply [file]` POSTs the local bundle to the import endpoint and
|
|
7886
|
+
* prints a `{ created, updated, skipped }` summary
|
|
7887
|
+
* per section (the server's authoritative result).
|
|
7888
|
+
* - `amba diff [file]` shows what `apply` WOULD change without writing.
|
|
7889
|
+
* The import route has NO server-side dry-run, so
|
|
7890
|
+
* `diff` exports the current project and computes the
|
|
7891
|
+
* created/updated/skipped per section LOCALLY.
|
|
7892
|
+
*/
|
|
7893
|
+
/** Default declarative bundle filename when no `[file]` is passed. */
|
|
7894
|
+
const DEFAULT_BUNDLE_FILE = "amba.config.json";
|
|
7895
|
+
/** The ordered list of bundle sections + their natural-key field. */
|
|
7896
|
+
const BUNDLE_SECTIONS = [
|
|
7897
|
+
{
|
|
7898
|
+
key: "remote_configs",
|
|
7899
|
+
naturalKey: "key"
|
|
7900
|
+
},
|
|
7901
|
+
{
|
|
7902
|
+
key: "collections",
|
|
7903
|
+
naturalKey: "name"
|
|
7904
|
+
},
|
|
7905
|
+
{
|
|
7906
|
+
key: "currencies",
|
|
7907
|
+
naturalKey: "code"
|
|
7908
|
+
},
|
|
7909
|
+
{
|
|
7910
|
+
key: "achievements",
|
|
7911
|
+
naturalKey: "key"
|
|
7912
|
+
},
|
|
7913
|
+
{
|
|
7914
|
+
key: "streaks",
|
|
7915
|
+
naturalKey: "key"
|
|
7916
|
+
},
|
|
7917
|
+
{
|
|
7918
|
+
key: "leaderboards",
|
|
7919
|
+
naturalKey: "name"
|
|
7920
|
+
},
|
|
7921
|
+
{
|
|
7922
|
+
key: "xp_rules",
|
|
7923
|
+
naturalKey: "name"
|
|
7924
|
+
},
|
|
7925
|
+
{
|
|
7926
|
+
key: "challenges",
|
|
7927
|
+
naturalKey: "name"
|
|
7928
|
+
},
|
|
7929
|
+
{
|
|
7930
|
+
key: "content_libraries",
|
|
7931
|
+
naturalKey: "name"
|
|
7932
|
+
},
|
|
7933
|
+
{
|
|
7934
|
+
key: "monetization",
|
|
7935
|
+
naturalKey: "key"
|
|
7936
|
+
}
|
|
7937
|
+
];
|
|
7938
|
+
/**
|
|
7939
|
+
* Read + parse a declarative bundle from disk. JSON only — we deliberately
|
|
7940
|
+
* don't pull in a YAML dependency the CLI doesn't already ship (the package
|
|
7941
|
+
* has no YAML lib), so `.yaml`/`.yml` files are rejected with a clear hint
|
|
7942
|
+
* rather than silently mis-parsed.
|
|
7943
|
+
*/
|
|
7944
|
+
async function readBundleFile(file) {
|
|
7945
|
+
const path = resolve(file);
|
|
7946
|
+
if (/\.ya?ml$/i.test(path)) throw new Error(`YAML bundles are not supported. Convert ${file} to JSON (the bundle format is plain JSON).`);
|
|
7947
|
+
let raw;
|
|
7948
|
+
try {
|
|
7949
|
+
raw = await readFile(path, "utf-8");
|
|
7950
|
+
} catch {
|
|
7951
|
+
throw new Error(`Bundle file not found: ${file}. Run \`amba export\` to scaffold one from your current project.`);
|
|
7952
|
+
}
|
|
7953
|
+
let parsed;
|
|
7954
|
+
try {
|
|
7955
|
+
parsed = JSON.parse(raw);
|
|
7956
|
+
} catch (err) {
|
|
7957
|
+
throw new Error(`Failed to parse ${file} as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
7958
|
+
}
|
|
7959
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Bundle in ${file} must be a JSON object with a \`version\` field.`);
|
|
7960
|
+
const bundle = parsed;
|
|
7961
|
+
if (typeof bundle["version"] !== "number") throw new Error(`Bundle in ${file} is missing a numeric \`version\` field. Re-export it with \`amba export\`.`);
|
|
7962
|
+
return bundle;
|
|
7963
|
+
}
|
|
7964
|
+
/**
|
|
7965
|
+
* Index a section array by its natural key. Definitions missing the key (or
|
|
7966
|
+
* non-object entries) are skipped — they can't be matched and the server
|
|
7967
|
+
* will reject them on apply.
|
|
7968
|
+
*/
|
|
7969
|
+
function indexByNaturalKey(items, naturalKey) {
|
|
7970
|
+
const out = /* @__PURE__ */ new Map();
|
|
7971
|
+
if (!Array.isArray(items)) return out;
|
|
7972
|
+
for (const item of items) {
|
|
7973
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
|
|
7974
|
+
const rec = item;
|
|
7975
|
+
const k = rec[naturalKey];
|
|
7976
|
+
if (typeof k !== "string" && typeof k !== "number") continue;
|
|
7977
|
+
out.set(String(k), rec);
|
|
7978
|
+
}
|
|
7979
|
+
return out;
|
|
7980
|
+
}
|
|
7981
|
+
/**
|
|
7982
|
+
* Pure section-by-section diff of a desired bundle against the current
|
|
7983
|
+
* project's exported bundle. The classification MIRRORS the server's import
|
|
7984
|
+
* (`apps/api/src/lib/promotion-bundle.ts`) so `amba diff` agrees with what
|
|
7985
|
+
* `amba apply` will report:
|
|
7986
|
+
* - created — natural key absent in current.
|
|
7987
|
+
* - updated — natural key present AND `mode === 'merge'`. The server runs an
|
|
7988
|
+
* UNCONDITIONAL update for every existing key in merge mode and
|
|
7989
|
+
* increments `updated` — it does NOT compare field values, so we
|
|
7990
|
+
* don't either (a local field comparison made `diff` under-report).
|
|
7991
|
+
* - skipped — natural key present under `skip_existing`; OR an existing
|
|
7992
|
+
* collection (always — collections are create-if-absent, never
|
|
7993
|
+
* merged); OR a `remote_configs` row flagged `is_system: true`
|
|
7994
|
+
* (the server always skips those before any existence check); OR
|
|
7995
|
+
* a desired row with no valid natural key (can't be matched
|
|
7996
|
+
* against the export, and the server import dedups by that key —
|
|
7997
|
+
* so it is never a reliable create).
|
|
7998
|
+
*
|
|
7999
|
+
* Within one bundle section, the server walks rows sequentially and each
|
|
8000
|
+
* insert mutates the DB, so a SECOND row sharing a natural key already created
|
|
8001
|
+
* by an EARLIER row in the same section is seen as existing (→ skip under
|
|
8002
|
+
* skip_existing, update under merge) rather than created a second time. We
|
|
8003
|
+
* track already-seen keys per section to mirror that, so duplicate-keyed local
|
|
8004
|
+
* rows don't inflate the `created` total versus what `apply` does.
|
|
8005
|
+
*/
|
|
8006
|
+
function computeLocalDiff(desired, current, mode) {
|
|
8007
|
+
const diff = {};
|
|
8008
|
+
for (const { key, naturalKey } of BUNDLE_SECTIONS) {
|
|
8009
|
+
const desiredItems = desired[key];
|
|
8010
|
+
if (!Array.isArray(desiredItems) || desiredItems.length === 0) continue;
|
|
8011
|
+
const currentIndex = indexByNaturalKey(current[key], naturalKey);
|
|
8012
|
+
const section = {
|
|
8013
|
+
created: 0,
|
|
8014
|
+
updated: 0,
|
|
8015
|
+
skipped: 0
|
|
8016
|
+
};
|
|
8017
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8018
|
+
for (const item of desiredItems) {
|
|
8019
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
|
|
8020
|
+
const rec = item;
|
|
8021
|
+
if (key === "remote_configs" && rec["is_system"] === true) {
|
|
8022
|
+
section.skipped++;
|
|
8023
|
+
continue;
|
|
8024
|
+
}
|
|
8025
|
+
const k = rec[naturalKey];
|
|
8026
|
+
const matchKey = typeof k === "string" || typeof k === "number" ? String(k) : null;
|
|
8027
|
+
if (matchKey === null) {
|
|
8028
|
+
section.skipped++;
|
|
8029
|
+
continue;
|
|
8030
|
+
}
|
|
8031
|
+
const existing = currentIndex.has(matchKey) || seen.has(matchKey);
|
|
8032
|
+
seen.add(matchKey);
|
|
8033
|
+
if (!existing) {
|
|
8034
|
+
section.created++;
|
|
8035
|
+
continue;
|
|
8036
|
+
}
|
|
8037
|
+
if (key === "collections" || mode === "skip_existing") {
|
|
8038
|
+
section.skipped++;
|
|
8039
|
+
continue;
|
|
8040
|
+
}
|
|
8041
|
+
section.updated++;
|
|
8042
|
+
}
|
|
8043
|
+
diff[key] = section;
|
|
8044
|
+
}
|
|
8045
|
+
const monDiff = computeMonetizationLocalDiff(desired["monetization"], current["monetization"], mode);
|
|
8046
|
+
if (monDiff) diff["monetization"] = monDiff;
|
|
8047
|
+
return diff;
|
|
8048
|
+
}
|
|
8049
|
+
/**
|
|
8050
|
+
* Natural keys per monetization sub-collection. All key on `key` except the
|
|
8051
|
+
* package join, which is unique within an offering — but for diff purposes the
|
|
8052
|
+
* package counts roll into their owning offering, so only the four top-level
|
|
8053
|
+
* collections are counted here.
|
|
8054
|
+
*/
|
|
8055
|
+
const MONETIZATION_SUBSECTIONS = [
|
|
8056
|
+
"entitlements",
|
|
8057
|
+
"products",
|
|
8058
|
+
"offerings",
|
|
8059
|
+
"paywalls"
|
|
8060
|
+
];
|
|
8061
|
+
function computeMonetizationLocalDiff(desired, current, mode) {
|
|
8062
|
+
if (desired === null || typeof desired !== "object" || Array.isArray(desired)) return null;
|
|
8063
|
+
const desiredObj = desired;
|
|
8064
|
+
const currentObj = current !== null && typeof current === "object" && !Array.isArray(current) ? current : {};
|
|
8065
|
+
const section = {
|
|
8066
|
+
created: 0,
|
|
8067
|
+
updated: 0,
|
|
8068
|
+
skipped: 0
|
|
8069
|
+
};
|
|
8070
|
+
let any = false;
|
|
8071
|
+
for (const sub of MONETIZATION_SUBSECTIONS) {
|
|
8072
|
+
const desiredItems = desiredObj[sub];
|
|
8073
|
+
if (!Array.isArray(desiredItems) || desiredItems.length === 0) continue;
|
|
8074
|
+
any = true;
|
|
8075
|
+
const currentIndex = indexByNaturalKey(currentObj[sub], "key");
|
|
8076
|
+
const seen = /* @__PURE__ */ new Set();
|
|
8077
|
+
for (const item of desiredItems) {
|
|
8078
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
|
|
8079
|
+
const k = item["key"];
|
|
8080
|
+
const matchKey = typeof k === "string" || typeof k === "number" ? String(k) : null;
|
|
8081
|
+
if (matchKey === null) {
|
|
8082
|
+
section.skipped++;
|
|
8083
|
+
continue;
|
|
8084
|
+
}
|
|
8085
|
+
const existing = currentIndex.has(matchKey) || seen.has(matchKey);
|
|
8086
|
+
seen.add(matchKey);
|
|
8087
|
+
if (!existing) section.created++;
|
|
8088
|
+
else if (mode === "skip_existing") section.skipped++;
|
|
8089
|
+
else section.updated++;
|
|
8090
|
+
}
|
|
8091
|
+
}
|
|
8092
|
+
return any ? section : null;
|
|
8093
|
+
}
|
|
8094
|
+
/** Roll a per-section diff up into grand totals across every section. */
|
|
8095
|
+
function summarizeDiff(diff) {
|
|
8096
|
+
return Object.values(diff).reduce((acc, s) => ({
|
|
8097
|
+
created: acc.created + s.created,
|
|
8098
|
+
updated: acc.updated + s.updated,
|
|
8099
|
+
skipped: acc.skipped + s.skipped
|
|
8100
|
+
}), {
|
|
8101
|
+
created: 0,
|
|
8102
|
+
updated: 0,
|
|
8103
|
+
skipped: 0
|
|
8104
|
+
});
|
|
8105
|
+
}
|
|
8106
|
+
function renderSummary(summary, verb) {
|
|
8107
|
+
let created = 0;
|
|
8108
|
+
let updated = 0;
|
|
8109
|
+
let skipped = 0;
|
|
8110
|
+
const sections = Object.keys(summary);
|
|
8111
|
+
if (sections.length === 0) console.log(pc.dim(" Nothing to " + verb + " — bundle is empty."));
|
|
8112
|
+
for (const section of sections) {
|
|
8113
|
+
const s = summary[section];
|
|
8114
|
+
created += s.created;
|
|
8115
|
+
updated += s.updated;
|
|
8116
|
+
skipped += s.skipped;
|
|
8117
|
+
console.log(` ${pc.bold(section)} ${pc.green(`${s.created} created`)} · ${pc.cyan(`${s.updated} updated`)} · ` + pc.dim(`${s.skipped} skipped`));
|
|
8118
|
+
}
|
|
8119
|
+
console.log();
|
|
8120
|
+
console.log(pc.dim(` Total: ${created} created · ${updated} updated · ${skipped} skipped`));
|
|
8121
|
+
}
|
|
8122
|
+
/**
|
|
8123
|
+
* `amba export [file]` — write the current project's declarative bundle to
|
|
8124
|
+
* disk so it can be checked in, diffed, and re-applied.
|
|
8125
|
+
*/
|
|
8126
|
+
async function exportConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
|
|
8127
|
+
const projectId = await requireProjectId(process.cwd(), options.project);
|
|
8128
|
+
const path = resolve(file);
|
|
8129
|
+
console.log();
|
|
8130
|
+
console.log(pc.bold(` amba export ${pc.cyan(file)}`));
|
|
8131
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
8132
|
+
console.log();
|
|
8133
|
+
console.log(pc.dim(` Project: ${projectId}`));
|
|
8134
|
+
console.log(pc.dim(" Fetching current configuration…"));
|
|
8135
|
+
const bundle = (await exportPromotionBundle(projectId)).data;
|
|
8136
|
+
await writeFile(path, JSON.stringify(bundle, null, 2) + "\n", "utf-8");
|
|
8137
|
+
const sectionCount = BUNDLE_SECTIONS.reduce((sum, { key }) => {
|
|
8138
|
+
const v = bundle[key];
|
|
8139
|
+
if (Array.isArray(v)) return sum + v.length;
|
|
8140
|
+
if (key === "monetization" && v !== null && typeof v === "object") {
|
|
8141
|
+
const m = v;
|
|
8142
|
+
return sum + MONETIZATION_SUBSECTIONS.reduce((n, sub) => n + (Array.isArray(m[sub]) ? m[sub].length : 0), 0);
|
|
8143
|
+
}
|
|
8144
|
+
return sum;
|
|
8145
|
+
}, 0);
|
|
8146
|
+
console.log();
|
|
8147
|
+
console.log(pc.green(" ✓") + ` Wrote ${pc.cyan(file)} (${sectionCount} definitions).`);
|
|
8148
|
+
console.log(pc.dim(" Edit it, then ") + pc.cyan("amba diff") + pc.dim(" to preview and ") + pc.cyan("amba apply") + pc.dim(" to apply."));
|
|
8149
|
+
console.log();
|
|
8150
|
+
}
|
|
8151
|
+
/**
|
|
8152
|
+
* `amba apply [file]` — read a local declarative bundle and POST it to the
|
|
8153
|
+
* import endpoint. Prints the server's authoritative `{ created, updated,
|
|
8154
|
+
* skipped }` per section.
|
|
8155
|
+
*/
|
|
8156
|
+
async function applyConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
|
|
8157
|
+
const projectId = await requireProjectId(process.cwd(), options.project);
|
|
8158
|
+
const mode = options.mode ?? "skip_existing";
|
|
8159
|
+
const bundle = await readBundleFile(file);
|
|
8160
|
+
console.log();
|
|
8161
|
+
console.log(pc.bold(` amba apply ${pc.cyan(file)}`));
|
|
8162
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
8163
|
+
console.log();
|
|
8164
|
+
console.log(pc.dim(` Project: ${projectId}`));
|
|
8165
|
+
console.log(pc.dim(` Mode: ${mode}`));
|
|
8166
|
+
console.log(pc.dim(" Applying…"));
|
|
8167
|
+
console.log();
|
|
8168
|
+
let res;
|
|
8169
|
+
try {
|
|
8170
|
+
res = await importPromotionBundle(projectId, bundle, mode);
|
|
8171
|
+
} catch (err) {
|
|
8172
|
+
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.`);
|
|
8173
|
+
throw err;
|
|
8174
|
+
}
|
|
8175
|
+
renderSummary(res.data.summary, "apply");
|
|
8176
|
+
console.log();
|
|
8177
|
+
}
|
|
8178
|
+
/**
|
|
8179
|
+
* `amba diff [file]` — show what `apply` WOULD change without writing.
|
|
8180
|
+
*
|
|
8181
|
+
* The import route has no server-side dry-run, so this fetches the current
|
|
8182
|
+
* project's exported bundle and computes the diff LOCALLY. Output mirrors the
|
|
8183
|
+
* `apply` summary shape so the two read identically.
|
|
8184
|
+
*/
|
|
8185
|
+
async function diffConfigCommand(file = DEFAULT_BUNDLE_FILE, options = {}) {
|
|
8186
|
+
const projectId = await requireProjectId(process.cwd(), options.project);
|
|
8187
|
+
const mode = options.mode ?? "skip_existing";
|
|
8188
|
+
const desired = await readBundleFile(file);
|
|
8189
|
+
console.log();
|
|
8190
|
+
console.log(pc.bold(` amba diff ${pc.cyan(file)}`));
|
|
8191
|
+
console.log(pc.dim(" ─────────────────────────────────"));
|
|
8192
|
+
console.log();
|
|
8193
|
+
console.log(pc.dim(` Project: ${projectId}`));
|
|
8194
|
+
console.log(pc.dim(` Mode: ${mode}`));
|
|
8195
|
+
console.log(pc.dim(" (computed locally — the import route has no server dry-run)"));
|
|
8196
|
+
console.log(pc.dim(" Fetching current configuration…"));
|
|
8197
|
+
console.log();
|
|
8198
|
+
const diff = computeLocalDiff(desired, (await exportPromotionBundle(projectId)).data, mode);
|
|
8199
|
+
renderSummary(diff, "change");
|
|
8200
|
+
const totals = summarizeDiff(diff);
|
|
8201
|
+
console.log();
|
|
8202
|
+
if (totals.created > 0 || totals.updated > 0) console.log(pc.dim(" Run ") + pc.cyan(`amba apply ${file}`) + pc.dim(" to apply these changes."));
|
|
8203
|
+
else if (totals.skipped > 0) if (mode === "skip_existing") {
|
|
8204
|
+
console.log(pc.green(" ✓") + " apply would make no changes (skip_existing leaves existing entities untouched).");
|
|
8205
|
+
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."));
|
|
8206
|
+
} else console.log(pc.green(" ✓") + " apply would make no changes.");
|
|
8207
|
+
else console.log(pc.green(" ✓") + " No changes — nothing in the bundle to apply.");
|
|
8208
|
+
console.log();
|
|
8209
|
+
}
|
|
8210
|
+
//#endregion
|
|
6648
8211
|
//#region src/index.ts
|
|
6649
8212
|
const program = new Command();
|
|
6650
|
-
program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("0.
|
|
8213
|
+
program.name("amba").description("amba — agent-native backend-as-a-service for mobile apps.").version("4.0.4");
|
|
6651
8214
|
program.option("--token <pat>", "Use a Personal Access Token for headless / CI / agent use (overrides ~/.amba/credentials.json + AMBA_PAT env)");
|
|
6652
8215
|
program.hook("preAction", (thisCommand) => {
|
|
6653
8216
|
setBearerOverride(resolveTokenSource({
|
|
@@ -6789,8 +8352,8 @@ program.command("schema").description("Schema export commands").command("export"
|
|
|
6789
8352
|
}));
|
|
6790
8353
|
});
|
|
6791
8354
|
const functions = program.command("functions").description("Customer serverless functions deployed to the edge");
|
|
6792
|
-
functions.command("deploy <
|
|
6793
|
-
await runAction(() => functionsDeployCommand(
|
|
8355
|
+
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) => {
|
|
8356
|
+
await runAction(() => functionsDeployCommand(target, opts));
|
|
6794
8357
|
});
|
|
6795
8358
|
functions.command("list").description("List active functions for the current project").action(async () => {
|
|
6796
8359
|
await runAction(functionsListCommand);
|
|
@@ -6807,11 +8370,12 @@ functions.command("dev <file>").description("Run a local dev server for your fun
|
|
|
6807
8370
|
noWatch: opts.watch === false
|
|
6808
8371
|
}));
|
|
6809
8372
|
});
|
|
6810
|
-
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) => {
|
|
8373
|
+
functions.command("logs <name>").description("Stream log events for a deployed function").option("--since <iso>", "Start of the time range (default: 1 hour ago)").option("--until <iso>", "End of the time range (default: now). Ignored on --tail.").option("--limit <n>", "Max events per fetch (default 100, max 1000)", (v) => parseInt(v, 10)).option("--request-id <id>", "Only show events whose logs mention this request id (e.g. r_...). Works with --tail.").option("--tail", "Follow new events; polls every 3s. Ctrl+C to stop.").option("--follow", "Alias for --tail (kept for backwards compatibility with v1 log commands).").option("--json", "NDJSON output to stdout (one event per line)").action(async (name, opts) => {
|
|
6811
8374
|
await runAction(() => functionsLogsCommand(name, {
|
|
6812
8375
|
since: opts.since,
|
|
6813
8376
|
until: opts.until,
|
|
6814
8377
|
limit: opts.limit,
|
|
8378
|
+
requestId: opts.requestId,
|
|
6815
8379
|
tail: opts.tail || opts.follow,
|
|
6816
8380
|
json: opts.json
|
|
6817
8381
|
}));
|
|
@@ -6827,17 +8391,17 @@ consumers.command("unbind <queue-name>").description("Remove the binding for a q
|
|
|
6827
8391
|
await runAction(() => functionsConsumersUnbindCommand(queueName));
|
|
6828
8392
|
});
|
|
6829
8393
|
const secrets = program.command("secrets").description("Function secrets (GCP canonical, Workers-Secret synced)");
|
|
6830
|
-
secrets.command("set <name> [value]").description("
|
|
8394
|
+
secrets.command("set <name> [value]").description("Store a secret and queue its sync to your deployed function(s). Omit --function for a project-wide secret. Use --from-stdin to keep the value out of shell history.").option("--function <name>", "Function the secret binds to. OMIT this flag to set a PROJECT-WIDE secret — one value visible to every function in the project (and to any function deployed later). With it, the secret is scoped to just that function. Use the same name as the entry passed to 'amba functions deploy'. Secrets may be set before the function is deployed; the sync drains once you deploy.").option("--env <env>", "'dev' | 'prod' — INFORMATIONAL ONLY. Both share one secret namespace per project. Use distinct secret names (e.g. STRIPE_KEY_DEV / STRIPE_KEY_PROD) or two amba projects for real env isolation.").option("--from-stdin", "Read the secret value from stdin instead of the positional arg. Mutually exclusive with <value>. Pipe in: `echo $KEY | amba secrets set NAME --from-stdin`.").action(async (name, value, opts) => {
|
|
6831
8395
|
await runAction(() => secretsSetCommand(name, value, {
|
|
6832
8396
|
function: opts.function,
|
|
6833
8397
|
env: opts.env ?? "dev",
|
|
6834
8398
|
fromStdin: opts.fromStdin
|
|
6835
8399
|
}));
|
|
6836
8400
|
});
|
|
6837
|
-
secrets.command("list").description("List secret sync status for the current project").action(async () => {
|
|
6838
|
-
await runAction(secretsListCommand);
|
|
8401
|
+
secrets.command("list").description("List secret sync status for the current project").option("--function <name>", "Filter to secrets for a single function (avoids listing all functions at once)").action(async (opts) => {
|
|
8402
|
+
await runAction(() => secretsListCommand({ function: opts.function }));
|
|
6839
8403
|
});
|
|
6840
|
-
secrets.command("unset <name>").description("Remove a secret
|
|
8404
|
+
secrets.command("unset <name>").description("Remove a secret (Worker binding cleared on next deploy)").option("--function <name>", "Function the secret was bound to. Omit to remove the project-wide secret of this name.").action(async (name, opts) => {
|
|
6841
8405
|
await runAction(() => secretsUnsetCommand(name, opts));
|
|
6842
8406
|
});
|
|
6843
8407
|
const billing = program.command("billing").description("Per-project subscription, headroom, and spend controls");
|
|
@@ -6874,6 +8438,60 @@ billing.command("set-ceiling <amount>").description("Cap the monthly bill at <am
|
|
|
6874
8438
|
}
|
|
6875
8439
|
await runAction(() => billingSetCeilingCommand({ ceiling }));
|
|
6876
8440
|
});
|
|
8441
|
+
const payments = program.command("payments").description("Accept payments in your app (Amba is the rail; your app is the seller)");
|
|
8442
|
+
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) => {
|
|
8443
|
+
if (opts.country && !/^[A-Za-z]{2}$/.test(opts.country)) {
|
|
8444
|
+
console.log(" ✗ --country must be a two-letter ISO code (e.g. US)");
|
|
8445
|
+
process.exit(1);
|
|
8446
|
+
}
|
|
8447
|
+
await runAction(() => paymentsSetupCommand({
|
|
8448
|
+
country: opts.country ? opts.country.toUpperCase() : void 0,
|
|
8449
|
+
email: opts.email
|
|
8450
|
+
}));
|
|
8451
|
+
});
|
|
8452
|
+
payments.command("status").description("Show onboarding stage + whether charges/payouts are enabled").action(async () => {
|
|
8453
|
+
await runAction(paymentsStatusCommand);
|
|
8454
|
+
});
|
|
8455
|
+
payments.command("set-fee <bps>").description("Set the default platform fee in basis points (250 = 2.5%), or 'off' to clear").action(async (bps) => {
|
|
8456
|
+
let value;
|
|
8457
|
+
if (bps.toLowerCase() === "off" || bps === "") value = null;
|
|
8458
|
+
else {
|
|
8459
|
+
const parsed = Number(bps);
|
|
8460
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 1e4) {
|
|
8461
|
+
console.log(" ✗ bps must be an integer between 0 and 10000, or \"off\"");
|
|
8462
|
+
process.exit(1);
|
|
8463
|
+
}
|
|
8464
|
+
value = parsed;
|
|
8465
|
+
}
|
|
8466
|
+
await runAction(() => paymentsSetFeeCommand({ bps: value }));
|
|
8467
|
+
});
|
|
8468
|
+
payments.command("balance").description("Show the connected account's available and pending balance").action(async () => {
|
|
8469
|
+
await runAction(paymentsBalanceCommand);
|
|
8470
|
+
});
|
|
8471
|
+
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) => {
|
|
8472
|
+
const limit = opts.limit ? Number(opts.limit) : void 0;
|
|
8473
|
+
if (limit !== void 0 && (!Number.isInteger(limit) || limit < 1 || limit > 100)) {
|
|
8474
|
+
console.log(" ✗ --limit must be an integer between 1 and 100");
|
|
8475
|
+
process.exit(1);
|
|
8476
|
+
}
|
|
8477
|
+
await runAction(() => paymentsPayoutsCommand({ limit }));
|
|
8478
|
+
});
|
|
8479
|
+
const monetization = program.command("monetization").description("Monetization control plane: plan, drift, export, adopt, and apply your subscription config as Infrastructure-as-Code.");
|
|
8480
|
+
monetization.command("plan").description("Preview the three-way diff (declared vs adopted vs live) + store-floor + drift").action(async () => {
|
|
8481
|
+
await runAction(monetizationPlanCommand);
|
|
8482
|
+
});
|
|
8483
|
+
monetization.command("drift").description("Show managed objects changed out-of-band in the provider").action(async () => {
|
|
8484
|
+
await runAction(monetizationDriftCommand);
|
|
8485
|
+
});
|
|
8486
|
+
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) => {
|
|
8487
|
+
await runAction(() => monetizationExportCommand(opts));
|
|
8488
|
+
});
|
|
8489
|
+
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) => {
|
|
8490
|
+
await runAction(() => monetizationAdoptCommand(opts));
|
|
8491
|
+
});
|
|
8492
|
+
monetization.command("apply").description("Apply the declared config to the provider. Additive ops (create entitlements/offerings/packages/paywall-drafts + attach products) apply automatically; destructive ops (detach/archive/remove) need --confirm.").option("--confirm", "Confirm and execute the gated destructive ops (detach/archive/remove). Without it they are reported but not run.").option("--allow-detach-live", "Accept detaching a product a live customer currently resolves (otherwise refused).").option("--reconcile <mode>", "Out-of-band drift mode: 'amba' (default) or 'adopt'.").action(async (opts) => {
|
|
8493
|
+
await runAction(() => monetizationApplyCommand(opts));
|
|
8494
|
+
});
|
|
6877
8495
|
const collections = program.command("collections").description("Customer collections (schema-first Postgres in each tenant database)");
|
|
6878
8496
|
collections.command("create <name>").description("Create a collection with the given fields").option("--field <spec>", "Field spec: name:type[:nullable] (e.g. user_id:uuid, parsed:jsonb:nullable). Repeatable.", (val, prev) => [...prev ?? [], val], []).option("--index <spec>", "Index spec: \"col1 [asc|desc], col2 [asc|desc]\". Repeatable.", (val, prev) => [...prev ?? [], val], []).action(async (name, opts) => {
|
|
6879
8497
|
await runAction(() => collectionsCreateCommand(name, opts));
|
|
@@ -6930,6 +8548,25 @@ sitesDomain.command("remove <hostname>").description("Detach a custom hostname (
|
|
|
6930
8548
|
zoneId: opts.zoneId
|
|
6931
8549
|
}));
|
|
6932
8550
|
});
|
|
8551
|
+
const domains = program.command("domains").description("Buy a domain through Amba and connect it to a site");
|
|
8552
|
+
domains.command("search <query>").description("Search for available domains + prices (free)").option("--limit <n>", "Max suggestions (1-50, default 20)", (v) => parseInt(v, 10)).action(async (query, opts) => {
|
|
8553
|
+
await runAction(() => domainsSearchCommand(query, { limit: opts.limit }));
|
|
8554
|
+
});
|
|
8555
|
+
domains.command("check <domains...>").description("Authoritative availability + price for specific domains (free)").action(async (domainArgs) => {
|
|
8556
|
+
await runAction(() => domainsCheckCommand(domainArgs));
|
|
8557
|
+
});
|
|
8558
|
+
domains.command("buy <domain>").description("Buy a domain + connect it to a site (surfaces cost, asks to confirm)").requiredOption("--site <name>", "Site name to connect the domain to").option("--yes", "Skip the interactive cost confirmation prompt").option("--no-privacy", "Disable WHOIS privacy (privacy is on by default)").option("--no-auto-renew", "Disable auto-renew (on by default)").option("--years <n>", "Registration length in years (default 1)", (v) => parseInt(v, 10)).action(async (domain, opts) => {
|
|
8559
|
+
await runAction(() => domainsBuyCommand(domain, {
|
|
8560
|
+
site: opts.site,
|
|
8561
|
+
yes: opts.yes,
|
|
8562
|
+
noPrivacy: opts.privacy === false,
|
|
8563
|
+
noAutoRenew: opts.autoRenew === false,
|
|
8564
|
+
years: opts.years
|
|
8565
|
+
}));
|
|
8566
|
+
});
|
|
8567
|
+
domains.command("list").description("List domains this project has purchased through Amba").action(async () => {
|
|
8568
|
+
await runAction(domainsListCommand);
|
|
8569
|
+
});
|
|
6933
8570
|
const aiProviders = program.command("ai").description("AI gateway — manage provider keys + prompts").command("providers").description("Per-project provider key registration (Anthropic, OpenAI)");
|
|
6934
8571
|
aiProviders.command("add <provider>").description("Register an AI provider key. Plaintext stored securely server-side; a preview (first-6+last-4) is printed back. Use --from-stdin to keep the key out of shell history.").option("--key <value>", "Provider API key plaintext (alternative: --from-stdin)").option("--from-stdin", "Read the key from stdin instead of --key").action(async (provider, opts) => {
|
|
6935
8572
|
await runAction(() => aiProvidersAddCommand(provider, opts));
|
|
@@ -6940,6 +8577,29 @@ aiProviders.command("list").description("List registered AI providers for the cu
|
|
|
6940
8577
|
aiProviders.command("delete <provider>").description("Remove a provider registration (refuses if active prompts reference it)").action(async (provider) => {
|
|
6941
8578
|
await runAction(() => aiProvidersDeleteCommand(provider));
|
|
6942
8579
|
});
|
|
8580
|
+
function parseImportMode(raw) {
|
|
8581
|
+
if (raw === void 0) return "skip_existing";
|
|
8582
|
+
if (raw === "merge" || raw === "skip_existing") return raw;
|
|
8583
|
+
console.log(" ✗ --mode must be \"skip_existing\" (default) or \"merge\"");
|
|
8584
|
+
process.exit(1);
|
|
8585
|
+
}
|
|
8586
|
+
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) => {
|
|
8587
|
+
await runAction(() => exportConfigCommand(file, { project: opts.project }));
|
|
8588
|
+
});
|
|
8589
|
+
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) => {
|
|
8590
|
+
const mode = parseImportMode(opts.mode);
|
|
8591
|
+
await runAction(() => diffConfigCommand(file, {
|
|
8592
|
+
project: opts.project,
|
|
8593
|
+
mode
|
|
8594
|
+
}));
|
|
8595
|
+
});
|
|
8596
|
+
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) => {
|
|
8597
|
+
const mode = parseImportMode(opts.mode);
|
|
8598
|
+
await runAction(() => applyConfigCommand(file, {
|
|
8599
|
+
project: opts.project,
|
|
8600
|
+
mode
|
|
8601
|
+
}));
|
|
8602
|
+
});
|
|
6943
8603
|
program.parse();
|
|
6944
8604
|
//#endregion
|
|
6945
8605
|
export {};
|