cli-meta-ads 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/AGENTS.md +188 -0
  2. package/AI_CONTEXT.md +144 -0
  3. package/CLAUDE.md +183 -0
  4. package/README.md +590 -0
  5. package/REQUIREMENTS.md +148 -0
  6. package/dist/auth/constants.d.ts +1 -0
  7. package/dist/auth/constants.js +1 -0
  8. package/dist/auth/guards.d.ts +5 -0
  9. package/dist/auth/guards.js +16 -0
  10. package/dist/auth/login.d.ts +28 -0
  11. package/dist/auth/login.js +222 -0
  12. package/dist/cli/action.d.ts +11 -0
  13. package/dist/cli/action.js +77 -0
  14. package/dist/cli/build-cli.d.ts +2 -0
  15. package/dist/cli/build-cli.js +110 -0
  16. package/dist/cli/context.d.ts +24 -0
  17. package/dist/cli/context.js +19 -0
  18. package/dist/client/meta-api-client.d.ts +50 -0
  19. package/dist/client/meta-api-client.js +258 -0
  20. package/dist/client/meta-discovery.d.ts +13 -0
  21. package/dist/client/meta-discovery.js +88 -0
  22. package/dist/commands/accounts.d.ts +4 -0
  23. package/dist/commands/accounts.js +42 -0
  24. package/dist/commands/ads.d.ts +4 -0
  25. package/dist/commands/ads.js +148 -0
  26. package/dist/commands/adsets.d.ts +4 -0
  27. package/dist/commands/adsets.js +49 -0
  28. package/dist/commands/anomalies.d.ts +4 -0
  29. package/dist/commands/anomalies.js +44 -0
  30. package/dist/commands/assets.d.ts +4 -0
  31. package/dist/commands/assets.js +116 -0
  32. package/dist/commands/audiences.d.ts +4 -0
  33. package/dist/commands/audiences.js +40 -0
  34. package/dist/commands/auth.d.ts +4 -0
  35. package/dist/commands/auth.js +139 -0
  36. package/dist/commands/campaigns.d.ts +4 -0
  37. package/dist/commands/campaigns.js +273 -0
  38. package/dist/commands/capi.d.ts +4 -0
  39. package/dist/commands/capi.js +64 -0
  40. package/dist/commands/creatives.d.ts +4 -0
  41. package/dist/commands/creatives.js +49 -0
  42. package/dist/commands/diagnostics.d.ts +4 -0
  43. package/dist/commands/diagnostics.js +88 -0
  44. package/dist/commands/helpers.d.ts +13 -0
  45. package/dist/commands/helpers.js +50 -0
  46. package/dist/commands/launch.d.ts +4 -0
  47. package/dist/commands/launch.js +109 -0
  48. package/dist/commands/performance.d.ts +4 -0
  49. package/dist/commands/performance.js +55 -0
  50. package/dist/commands/pixel.d.ts +4 -0
  51. package/dist/commands/pixel.js +68 -0
  52. package/dist/commands/report.d.ts +4 -0
  53. package/dist/commands/report.js +30 -0
  54. package/dist/config/file-config.d.ts +6 -0
  55. package/dist/config/file-config.js +174 -0
  56. package/dist/config/types.d.ts +32 -0
  57. package/dist/config/types.js +1 -0
  58. package/dist/domain/account-scope.d.ts +7 -0
  59. package/dist/domain/account-scope.js +28 -0
  60. package/dist/domain/analytics.d.ts +52 -0
  61. package/dist/domain/analytics.js +125 -0
  62. package/dist/domain/approval-service.d.ts +10 -0
  63. package/dist/domain/approval-service.js +48 -0
  64. package/dist/domain/asset-feed-compiler.d.ts +43 -0
  65. package/dist/domain/asset-feed-compiler.js +104 -0
  66. package/dist/domain/launch-service.d.ts +200 -0
  67. package/dist/domain/launch-service.js +558 -0
  68. package/dist/domain/meta-ads-service.d.ts +620 -0
  69. package/dist/domain/meta-ads-service.js +841 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +9 -0
  72. package/dist/output/render.d.ts +3 -0
  73. package/dist/output/render.js +103 -0
  74. package/dist/types.d.ts +42 -0
  75. package/dist/types.js +1 -0
  76. package/dist/utils/currency.d.ts +4 -0
  77. package/dist/utils/currency.js +40 -0
  78. package/dist/utils/date-range.d.ts +20 -0
  79. package/dist/utils/date-range.js +115 -0
  80. package/dist/utils/errors.d.ts +35 -0
  81. package/dist/utils/errors.js +68 -0
  82. package/dist/utils/ids.d.ts +4 -0
  83. package/dist/utils/ids.js +23 -0
  84. package/dist/utils/meta-placement-assets.d.ts +44 -0
  85. package/dist/utils/meta-placement-assets.js +315 -0
  86. package/dist/utils/security.d.ts +5 -0
  87. package/dist/utils/security.js +104 -0
  88. package/dist/validators/common.d.ts +10 -0
  89. package/dist/validators/common.js +56 -0
  90. package/dist/validators/create-spec.d.ts +373 -0
  91. package/dist/validators/create-spec.js +394 -0
  92. package/dist/validators/launch-spec.d.ts +229 -0
  93. package/dist/validators/launch-spec.js +371 -0
  94. package/docs/TECHNICAL.md +480 -0
  95. package/examples/README.md +29 -0
  96. package/examples/launch/assets/feed4x5.png +0 -0
  97. package/examples/launch/assets/story9x16.png +0 -0
  98. package/examples/launch/multi-format-launch.json +90 -0
  99. package/examples/single-object/ad.json +6 -0
  100. package/examples/single-object/adset.json +30 -0
  101. package/examples/single-object/campaign.json +6 -0
  102. package/examples/single-object/creative.json +19 -0
  103. package/package.json +62 -0
  104. package/skills/meta-cli-operator/SKILL.md +105 -0
  105. package/skills/meta-cli-operator/agents/openai.yaml +4 -0
  106. package/skills/meta-cli-operator/references/update-matrix.md +117 -0
@@ -0,0 +1,558 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { AppError, ExitCode } from "../utils/errors.js";
4
+ import { expandPlacementTargeting, formatPlacementIds, hasPlacementFormats } from "../utils/meta-placement-assets.js";
5
+ import { buildAssetFeedCreativeSpec as compileAssetFeedCreativeSpec } from "./asset-feed-compiler.js";
6
+ function createExecutionId() {
7
+ return `launch_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${Math.random().toString(36).slice(2, 8)}`;
8
+ }
9
+ function defaultReceiptPath(executionId) {
10
+ return path.resolve(".meta-launch", `${executionId}.json`);
11
+ }
12
+ function createStep(kind, ref, detail) {
13
+ return {
14
+ detail,
15
+ id: `${kind}:${ref}`,
16
+ kind,
17
+ ref,
18
+ status: "pending"
19
+ };
20
+ }
21
+ export function buildLaunchSteps(spec) {
22
+ return [
23
+ createStep("campaign.create", spec.campaign.ref, `Create campaign '${spec.campaign.name}'`),
24
+ ...spec.adSets.map((entry) => createStep("adset.create", entry.ref, `Create ad set '${entry.name}'`)),
25
+ ...spec.assets.flatMap((entry) => entry.kind === "image"
26
+ ? [createStep("asset.image.upload", entry.ref, `Upload image asset '${entry.ref}'`)]
27
+ : [
28
+ createStep("asset.video.upload", entry.ref, `Upload video asset '${entry.ref}'`),
29
+ createStep("asset.video.wait", entry.ref, `Wait for video asset '${entry.ref}' to be ready`)
30
+ ]),
31
+ ...spec.creatives.map((entry) => createStep("creative.create", entry.ref, `Create creative '${entry.ref}'`)),
32
+ ...spec.ads.map((entry) => createStep("ad.create", entry.ref, `Create ad '${entry.name}'`))
33
+ ];
34
+ }
35
+ function summarizeCreated(receipt) {
36
+ return {
37
+ ads: Object.entries(receipt.created.ads).map(([ref, value]) => ({ ref, ...value })),
38
+ adSets: Object.entries(receipt.created.adSets).map(([ref, value]) => ({ ref, ...value })),
39
+ assets: Object.entries(receipt.created.assets).map(([ref, value]) => ({ ref, ...value })),
40
+ campaign: receipt.created.campaign,
41
+ creatives: Object.entries(receipt.created.creatives).map(([ref, value]) => ({ ref, ...value }))
42
+ };
43
+ }
44
+ function pendingSteps(receipt) {
45
+ return receipt.steps.filter((entry) => entry.status === "pending");
46
+ }
47
+ async function ensureWritableReceiptPath(receiptPath) {
48
+ const absolutePath = path.resolve(receiptPath);
49
+ try {
50
+ await access(absolutePath);
51
+ throw new AppError(`Receipt path already exists: ${absolutePath}`, ExitCode.Usage, {
52
+ receiptPath: absolutePath
53
+ });
54
+ }
55
+ catch (error) {
56
+ if (error.code === "ENOENT") {
57
+ return absolutePath;
58
+ }
59
+ throw error;
60
+ }
61
+ }
62
+ async function persistReceipt(receipt) {
63
+ await mkdir(path.dirname(receipt.receiptPath), { recursive: true });
64
+ receipt.updatedAt = new Date().toISOString();
65
+ await writeFile(receipt.receiptPath, `${JSON.stringify(receipt, null, 2)}\n`, "utf8");
66
+ }
67
+ async function readReceipt(receiptPath) {
68
+ const absolutePath = path.resolve(receiptPath);
69
+ try {
70
+ const parsed = JSON.parse(await readFile(absolutePath, "utf8"));
71
+ if (!parsed || parsed.version !== 1 || !parsed.executionId || !Array.isArray(parsed.steps)) {
72
+ throw new AppError("Launch receipt is invalid.", ExitCode.Usage, {
73
+ receiptPath: absolutePath
74
+ });
75
+ }
76
+ parsed.receiptPath = absolutePath;
77
+ return parsed;
78
+ }
79
+ catch (error) {
80
+ if (error instanceof AppError) {
81
+ throw error;
82
+ }
83
+ if (error.code === "ENOENT") {
84
+ throw new AppError("Launch receipt does not exist.", ExitCode.Usage, {
85
+ receiptPath: absolutePath
86
+ });
87
+ }
88
+ if (error instanceof SyntaxError) {
89
+ throw new AppError("Launch receipt is not valid JSON.", ExitCode.Usage, {
90
+ receiptPath: absolutePath
91
+ });
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ function requireCreatedId(value, label, ref) {
97
+ if (!value) {
98
+ throw new AppError(`Launch receipt is missing ${label} for ref '${ref}'.`, ExitCode.VerificationFailed, {
99
+ ref
100
+ });
101
+ }
102
+ return value.id;
103
+ }
104
+ function requireCreatedAsset(receipt, ref, assetRef) {
105
+ const asset = receipt.created.assets[assetRef];
106
+ if (!asset) {
107
+ throw new AppError(`Launch receipt is missing asset for creative ref '${ref}'.`, ExitCode.VerificationFailed, {
108
+ assetRef
109
+ });
110
+ }
111
+ return asset;
112
+ }
113
+ function findCreativeEntry(receipt, ref) {
114
+ const entry = receipt.spec.creatives.find((item) => item.ref === ref);
115
+ if (!entry) {
116
+ throw new AppError(`Launch spec is missing creative ref '${ref}'.`, ExitCode.VerificationFailed);
117
+ }
118
+ return entry;
119
+ }
120
+ function resolveCreativeAssetBinding(receipt, creativeRef, override) {
121
+ const asset = requireCreatedAsset(receipt, creativeRef, override.assetRef);
122
+ return asset.kind === "image"
123
+ ? {
124
+ id: override.assetRef,
125
+ assetRef: override.assetRef,
126
+ imageCrops: override.imageCrops,
127
+ imageHash: asset.imageHash,
128
+ kind: "image"
129
+ }
130
+ : {
131
+ assetRef: override.assetRef,
132
+ captionIds: override.captionIds,
133
+ id: override.assetRef,
134
+ kind: "video",
135
+ videoId: asset.videoId
136
+ };
137
+ }
138
+ function resolveCreativeFallbackBinding(receipt, entry) {
139
+ if (entry.assetRef) {
140
+ return resolveCreativeAssetBinding(receipt, entry.ref, {
141
+ assetRef: entry.assetRef
142
+ });
143
+ }
144
+ const fallbackFormat = entry.formats?.feed4x5
145
+ ?? entry.formats?.square1x1
146
+ ?? entry.formats?.story9x16;
147
+ if (!fallbackFormat) {
148
+ return undefined;
149
+ }
150
+ return resolveCreativeAssetBinding(receipt, entry.ref, fallbackFormat);
151
+ }
152
+ function resolveCreativePlatformCustomizations(receipt, ref, platformCustomizations) {
153
+ const instagramOverride = platformCustomizations?.instagram;
154
+ if (!instagramOverride) {
155
+ return undefined;
156
+ }
157
+ const asset = requireCreatedAsset(receipt, ref, instagramOverride.assetRef);
158
+ return {
159
+ instagram: {
160
+ ...(asset.kind === "image" ? { imageHash: asset.imageHash } : { videoId: asset.videoId }),
161
+ captionIds: instagramOverride.captionIds,
162
+ imageCrops: instagramOverride.imageCrops
163
+ }
164
+ };
165
+ }
166
+ function buildLaunchWarnings(spec) {
167
+ const warnings = new Set();
168
+ const adSetsByRef = new Map(spec.adSets.map((entry) => [entry.ref, entry]));
169
+ spec.creatives.forEach((creative) => {
170
+ const adsForCreative = spec.ads.filter((entry) => entry.creativeRef === creative.ref);
171
+ const placements = adsForCreative
172
+ .map((entry) => adSetsByRef.get(entry.adSetRef))
173
+ .filter((entry) => Boolean(entry))
174
+ .map((entry) => expandPlacementTargeting(entry.targeting));
175
+ const automaticPlacements = placements.some((entry) => entry.automatic);
176
+ const unsupportedPlacements = [...new Set(placements.flatMap((entry) => entry.unsupportedPlacementIds))];
177
+ if (automaticPlacements && !hasPlacementFormats(creative.formats)) {
178
+ warnings.add(`Creative '${creative.ref}' uses automatic placements without formats.feed4x5, formats.square1x1, or formats.story9x16. Meta may crop or expand the fallback asset across Feed, Stories, and Reels.`);
179
+ }
180
+ if (unsupportedPlacements.length > 0 && hasPlacementFormats(creative.formats)) {
181
+ warnings.add(`Creative '${creative.ref}' targets placements outside the current formats abstraction (${formatPlacementIds(unsupportedPlacements)}). These placements will fall back to the base/default asset instead of a dedicated format slot.`);
182
+ }
183
+ });
184
+ return [...warnings];
185
+ }
186
+ function resolveSimpleCreativeSpec(receipt, entry) {
187
+ const fallbackAsset = resolveCreativeFallbackBinding(receipt, entry);
188
+ if (!fallbackAsset) {
189
+ throw new AppError(`Creative '${entry.ref}' is missing a fallback asset.`, ExitCode.VerificationFailed);
190
+ }
191
+ const platformCustomizations = resolveCreativePlatformCustomizations(receipt, entry.ref, entry.platformCustomizations);
192
+ if (entry.kind === "link-image") {
193
+ if (fallbackAsset.kind !== "image" || !fallbackAsset.imageHash) {
194
+ throw new AppError(`Creative '${entry.ref}' requires an image asset.`, ExitCode.VerificationFailed, {
195
+ assetRef: fallbackAsset.assetRef
196
+ });
197
+ }
198
+ return {
199
+ imageHash: fallbackAsset.imageHash,
200
+ kind: entry.kind,
201
+ linkData: entry.linkData,
202
+ name: entry.name,
203
+ pageId: entry.pageId,
204
+ platformCustomizations,
205
+ status: entry.status
206
+ };
207
+ }
208
+ if (fallbackAsset.kind !== "video" || !fallbackAsset.videoId) {
209
+ throw new AppError(`Creative '${entry.ref}' requires a video asset.`, ExitCode.VerificationFailed, {
210
+ assetRef: fallbackAsset.assetRef
211
+ });
212
+ }
213
+ return {
214
+ kind: entry.kind,
215
+ name: entry.name,
216
+ pageId: entry.pageId,
217
+ platformCustomizations,
218
+ status: entry.status,
219
+ videoData: entry.videoData,
220
+ videoId: fallbackAsset.videoId
221
+ };
222
+ }
223
+ function buildAssetFeedCreativeSpec(receipt, entry) {
224
+ if (!hasPlacementFormats(entry.formats)) {
225
+ return undefined;
226
+ }
227
+ const adSetsByRef = new Map(receipt.spec.adSets.map((item) => [item.ref, item]));
228
+ const placements = receipt.spec.ads
229
+ .filter((item) => item.creativeRef === entry.ref)
230
+ .map((item) => adSetsByRef.get(item.adSetRef))
231
+ .filter((item) => Boolean(item))
232
+ .map((item) => expandPlacementTargeting(item.targeting));
233
+ const unsupportedPlacements = [...new Set(placements.flatMap((item) => item.unsupportedPlacementIds))];
234
+ const squarePlacements = [...new Set(placements.flatMap((item) => item.squarePlacementIds))];
235
+ const verticalPlacements = [...new Set(placements.flatMap((item) => item.verticalPlacementIds))];
236
+ const feedPlacements = [...new Set(placements.flatMap((item) => item.feedPlacementIds))]
237
+ .filter((placementId) => !squarePlacements.includes(placementId));
238
+ if (unsupportedPlacements.length > 0 && !entry.assetRef) {
239
+ throw new AppError(`Creative '${entry.ref}' targets placements outside the current formats abstraction (${formatPlacementIds(unsupportedPlacements)}) and must provide assetRef as the explicit fallback asset.`, ExitCode.VerificationFailed);
240
+ }
241
+ const fallbackBinding = resolveCreativeFallbackBinding(receipt, entry);
242
+ if (!fallbackBinding) {
243
+ return undefined;
244
+ }
245
+ const feedBinding = entry.formats?.feed4x5
246
+ ? resolveCreativeAssetBinding(receipt, entry.ref, entry.formats.feed4x5)
247
+ : fallbackBinding;
248
+ const squareBinding = entry.formats?.square1x1
249
+ ? resolveCreativeAssetBinding(receipt, entry.ref, entry.formats.square1x1)
250
+ : feedBinding;
251
+ const storyBinding = entry.formats?.story9x16
252
+ ? resolveCreativeAssetBinding(receipt, entry.ref, entry.formats.story9x16)
253
+ : fallbackBinding;
254
+ return entry.kind === "link-image"
255
+ ? compileAssetFeedCreativeSpec({
256
+ fallback: fallbackBinding,
257
+ formatBindings: {
258
+ feed4x5: feedBinding,
259
+ square1x1: squareBinding,
260
+ story9x16: storyBinding
261
+ },
262
+ kind: entry.kind,
263
+ linkData: entry.linkData,
264
+ name: entry.name ?? entry.ref,
265
+ pageId: entry.pageId,
266
+ placementTargets: {
267
+ feed4x5: feedPlacements,
268
+ square1x1: squarePlacements,
269
+ story9x16: verticalPlacements
270
+ }
271
+ })
272
+ : compileAssetFeedCreativeSpec({
273
+ fallback: fallbackBinding,
274
+ formatBindings: {
275
+ feed4x5: feedBinding,
276
+ square1x1: squareBinding,
277
+ story9x16: storyBinding
278
+ },
279
+ kind: entry.kind,
280
+ name: entry.name ?? entry.ref,
281
+ pageId: entry.pageId,
282
+ placementTargets: {
283
+ feed4x5: feedPlacements,
284
+ square1x1: squarePlacements,
285
+ story9x16: verticalPlacements
286
+ },
287
+ videoData: entry.videoData
288
+ });
289
+ }
290
+ function resolveCampaignSpec(spec) {
291
+ return {
292
+ bidStrategy: spec.campaign.bidStrategy,
293
+ dailyBudget: spec.campaign.dailyBudget,
294
+ lifetimeBudget: spec.campaign.lifetimeBudget,
295
+ name: spec.campaign.name,
296
+ objective: spec.campaign.objective,
297
+ specialAdCategories: spec.campaign.specialAdCategories,
298
+ status: spec.campaign.status
299
+ };
300
+ }
301
+ function resolveAdSetSpec(spec, ref, campaignId) {
302
+ const entry = spec.adSets.find((item) => item.ref === ref);
303
+ if (!entry) {
304
+ throw new AppError(`Launch spec is missing ad set ref '${ref}'.`, ExitCode.VerificationFailed);
305
+ }
306
+ return {
307
+ bidAmount: entry.bidAmount,
308
+ bidStrategy: entry.bidStrategy,
309
+ billingEvent: entry.billingEvent,
310
+ campaignId,
311
+ dailyBudget: entry.dailyBudget,
312
+ endTime: entry.endTime,
313
+ lifetimeBudget: entry.lifetimeBudget,
314
+ name: entry.name,
315
+ optimizationGoal: entry.optimizationGoal,
316
+ promotedObject: entry.promotedObject,
317
+ startTime: entry.startTime,
318
+ status: entry.status,
319
+ targeting: entry.targeting
320
+ };
321
+ }
322
+ function resolveCreativeSpec(receipt, ref) {
323
+ const entry = findCreativeEntry(receipt, ref);
324
+ const assetFeedSpec = buildAssetFeedCreativeSpec(receipt, entry);
325
+ if (assetFeedSpec) {
326
+ return {
327
+ mode: "asset-feed",
328
+ spec: assetFeedSpec
329
+ };
330
+ }
331
+ return {
332
+ mode: "simple",
333
+ spec: resolveSimpleCreativeSpec(receipt, entry)
334
+ };
335
+ }
336
+ function resolveAdSpec(receipt, ref) {
337
+ const entry = receipt.spec.ads.find((item) => item.ref === ref);
338
+ if (!entry) {
339
+ throw new AppError(`Launch spec is missing ad ref '${ref}'.`, ExitCode.VerificationFailed);
340
+ }
341
+ return {
342
+ adSetId: requireCreatedId(receipt.created.adSets[entry.adSetRef], "ad set", entry.adSetRef),
343
+ creativeId: requireCreatedId(receipt.created.creatives[entry.creativeRef], "creative", entry.creativeRef),
344
+ name: entry.name,
345
+ status: entry.status
346
+ };
347
+ }
348
+ async function executeStep(service, receipt, step) {
349
+ switch (step.kind) {
350
+ case "campaign.create": {
351
+ const campaign = await service.createCampaign(receipt.accountId, resolveCampaignSpec(receipt.spec));
352
+ receipt.created.campaign = {
353
+ id: campaign.id,
354
+ ref: receipt.spec.campaign.ref
355
+ };
356
+ return;
357
+ }
358
+ case "adset.create": {
359
+ const campaignId = requireCreatedId(receipt.created.campaign, "campaign", receipt.spec.campaign.ref);
360
+ const adSet = await service.createAdSet(receipt.accountId, resolveAdSetSpec(receipt.spec, step.ref, campaignId));
361
+ receipt.created.adSets[step.ref] = {
362
+ id: adSet.id
363
+ };
364
+ return;
365
+ }
366
+ case "asset.image.upload": {
367
+ const assetSpec = receipt.spec.assets.find((entry) => entry.ref === step.ref && entry.kind === "image");
368
+ if (!assetSpec) {
369
+ throw new AppError(`Launch spec is missing image asset ref '${step.ref}'.`, ExitCode.VerificationFailed);
370
+ }
371
+ const uploaded = await service.uploadAdImage(receipt.accountId, assetSpec.file, assetSpec.name);
372
+ if (!uploaded.hash) {
373
+ throw new AppError(`Meta image upload for ref '${step.ref}' did not return an image hash.`, ExitCode.Provider);
374
+ }
375
+ receipt.created.assets[step.ref] = {
376
+ imageHash: uploaded.hash,
377
+ kind: "image",
378
+ name: uploaded.name
379
+ };
380
+ return;
381
+ }
382
+ case "asset.video.upload": {
383
+ const assetSpec = receipt.spec.assets.find((entry) => entry.ref === step.ref && entry.kind === "video");
384
+ if (!assetSpec) {
385
+ throw new AppError(`Launch spec is missing video asset ref '${step.ref}'.`, ExitCode.VerificationFailed);
386
+ }
387
+ const uploaded = await service.uploadAdVideo(receipt.accountId, assetSpec.file, assetSpec.name);
388
+ receipt.created.assets[step.ref] = {
389
+ kind: "video",
390
+ name: uploaded.name,
391
+ status: "uploaded",
392
+ videoId: uploaded.id
393
+ };
394
+ return;
395
+ }
396
+ case "asset.video.wait": {
397
+ const asset = receipt.created.assets[step.ref];
398
+ if (!asset || asset.kind !== "video") {
399
+ throw new AppError(`Launch receipt is missing uploaded video for ref '${step.ref}'.`, ExitCode.VerificationFailed);
400
+ }
401
+ const status = await service.waitForVideoReady(asset.videoId);
402
+ receipt.created.assets[step.ref] = {
403
+ ...asset,
404
+ status: status.status ?? "ready"
405
+ };
406
+ return;
407
+ }
408
+ case "creative.create": {
409
+ const creativeSpec = resolveCreativeSpec(receipt, step.ref);
410
+ const creative = creativeSpec.mode === "asset-feed"
411
+ ? await service.createAssetFeedCreative(receipt.accountId, creativeSpec.spec)
412
+ : await service.createCreative(receipt.accountId, creativeSpec.spec);
413
+ receipt.created.creatives[step.ref] = {
414
+ id: creative.id
415
+ };
416
+ return;
417
+ }
418
+ case "ad.create": {
419
+ const ad = await service.createAd(receipt.accountId, resolveAdSpec(receipt, step.ref));
420
+ receipt.created.ads[step.ref] = {
421
+ id: ad.id
422
+ };
423
+ }
424
+ }
425
+ }
426
+ async function runReceipt(service, receipt) {
427
+ receipt.status = "running";
428
+ receipt.lastError = undefined;
429
+ await persistReceipt(receipt);
430
+ for (const step of pendingSteps(receipt)) {
431
+ try {
432
+ await executeStep(service, receipt, step);
433
+ step.status = "completed";
434
+ receipt.lastError = undefined;
435
+ await persistReceipt(receipt);
436
+ }
437
+ catch (error) {
438
+ receipt.lastError = {
439
+ message: error instanceof Error ? error.message : "Unknown error",
440
+ stepId: step.id
441
+ };
442
+ receipt.status = error instanceof AppError && error.exitCode === ExitCode.VerificationFailed && step.kind === "asset.video.wait"
443
+ ? "waiting_for_video"
444
+ : "failed";
445
+ await persistReceipt(receipt);
446
+ if (error instanceof AppError) {
447
+ throw new AppError(error.message, error.exitCode, {
448
+ ...(error.details ?? {}),
449
+ executionId: receipt.executionId,
450
+ receiptPath: receipt.receiptPath,
451
+ stepId: step.id
452
+ });
453
+ }
454
+ throw new AppError("Launch execution failed.", ExitCode.Provider, {
455
+ executionId: receipt.executionId,
456
+ receiptPath: receipt.receiptPath,
457
+ stepId: step.id
458
+ });
459
+ }
460
+ }
461
+ receipt.status = "completed";
462
+ receipt.lastError = undefined;
463
+ await persistReceipt(receipt);
464
+ return receipt;
465
+ }
466
+ export class LaunchService {
467
+ service;
468
+ constructor(service) {
469
+ this.service = service;
470
+ }
471
+ buildPlan(accountId, specPath, spec) {
472
+ const steps = buildLaunchSteps(spec);
473
+ const warnings = buildLaunchWarnings(spec);
474
+ return {
475
+ data: {
476
+ accountId,
477
+ specPath,
478
+ steps,
479
+ summary: {
480
+ ads: spec.ads.length,
481
+ adSets: spec.adSets.length,
482
+ assets: spec.assets.length,
483
+ creatives: spec.creatives.length
484
+ }
485
+ },
486
+ warnings
487
+ };
488
+ }
489
+ validate(accountId, specPath, spec) {
490
+ return {
491
+ data: {
492
+ accountId,
493
+ specPath,
494
+ summary: {
495
+ ads: spec.ads.length,
496
+ adSets: spec.adSets.length,
497
+ assets: spec.assets.length,
498
+ campaignRef: spec.campaign.ref,
499
+ creatives: spec.creatives.length
500
+ },
501
+ valid: true
502
+ },
503
+ warnings: buildLaunchWarnings(spec)
504
+ };
505
+ }
506
+ async apply(options) {
507
+ const executionId = createExecutionId();
508
+ const receiptPath = await ensureWritableReceiptPath(options.receiptPath ?? defaultReceiptPath(executionId));
509
+ const receipt = {
510
+ version: 1,
511
+ accountId: options.accountId,
512
+ created: {
513
+ ads: {},
514
+ adSets: {},
515
+ assets: {},
516
+ creatives: {}
517
+ },
518
+ executionId,
519
+ receiptPath,
520
+ spec: options.spec,
521
+ specPath: options.specPath,
522
+ status: "running",
523
+ steps: buildLaunchSteps(options.spec),
524
+ updatedAt: new Date().toISOString()
525
+ };
526
+ const result = await runReceipt(this.service, receipt);
527
+ return {
528
+ data: {
529
+ created: summarizeCreated(result),
530
+ executionId: result.executionId,
531
+ receiptPath: result.receiptPath,
532
+ status: result.status
533
+ },
534
+ warnings: buildLaunchWarnings(options.spec)
535
+ };
536
+ }
537
+ async previewResume(receiptPath) {
538
+ const receipt = await readReceipt(receiptPath);
539
+ return {
540
+ created: summarizeCreated(receipt),
541
+ executionId: receipt.executionId,
542
+ pendingSteps: pendingSteps(receipt),
543
+ receiptPath: receipt.receiptPath,
544
+ status: receipt.status
545
+ };
546
+ }
547
+ async resume(receiptPath) {
548
+ const receipt = await readReceipt(receiptPath);
549
+ const result = await runReceipt(this.service, receipt);
550
+ return {
551
+ created: summarizeCreated(result),
552
+ executionId: result.executionId,
553
+ pendingSteps: pendingSteps(result),
554
+ receiptPath: result.receiptPath,
555
+ status: result.status
556
+ };
557
+ }
558
+ }