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.
- package/AGENTS.md +188 -0
- package/AI_CONTEXT.md +144 -0
- package/CLAUDE.md +183 -0
- package/README.md +590 -0
- package/REQUIREMENTS.md +148 -0
- package/dist/auth/constants.d.ts +1 -0
- package/dist/auth/constants.js +1 -0
- package/dist/auth/guards.d.ts +5 -0
- package/dist/auth/guards.js +16 -0
- package/dist/auth/login.d.ts +28 -0
- package/dist/auth/login.js +222 -0
- package/dist/cli/action.d.ts +11 -0
- package/dist/cli/action.js +77 -0
- package/dist/cli/build-cli.d.ts +2 -0
- package/dist/cli/build-cli.js +110 -0
- package/dist/cli/context.d.ts +24 -0
- package/dist/cli/context.js +19 -0
- package/dist/client/meta-api-client.d.ts +50 -0
- package/dist/client/meta-api-client.js +258 -0
- package/dist/client/meta-discovery.d.ts +13 -0
- package/dist/client/meta-discovery.js +88 -0
- package/dist/commands/accounts.d.ts +4 -0
- package/dist/commands/accounts.js +42 -0
- package/dist/commands/ads.d.ts +4 -0
- package/dist/commands/ads.js +148 -0
- package/dist/commands/adsets.d.ts +4 -0
- package/dist/commands/adsets.js +49 -0
- package/dist/commands/anomalies.d.ts +4 -0
- package/dist/commands/anomalies.js +44 -0
- package/dist/commands/assets.d.ts +4 -0
- package/dist/commands/assets.js +116 -0
- package/dist/commands/audiences.d.ts +4 -0
- package/dist/commands/audiences.js +40 -0
- package/dist/commands/auth.d.ts +4 -0
- package/dist/commands/auth.js +139 -0
- package/dist/commands/campaigns.d.ts +4 -0
- package/dist/commands/campaigns.js +273 -0
- package/dist/commands/capi.d.ts +4 -0
- package/dist/commands/capi.js +64 -0
- package/dist/commands/creatives.d.ts +4 -0
- package/dist/commands/creatives.js +49 -0
- package/dist/commands/diagnostics.d.ts +4 -0
- package/dist/commands/diagnostics.js +88 -0
- package/dist/commands/helpers.d.ts +13 -0
- package/dist/commands/helpers.js +50 -0
- package/dist/commands/launch.d.ts +4 -0
- package/dist/commands/launch.js +109 -0
- package/dist/commands/performance.d.ts +4 -0
- package/dist/commands/performance.js +55 -0
- package/dist/commands/pixel.d.ts +4 -0
- package/dist/commands/pixel.js +68 -0
- package/dist/commands/report.d.ts +4 -0
- package/dist/commands/report.js +30 -0
- package/dist/config/file-config.d.ts +6 -0
- package/dist/config/file-config.js +174 -0
- package/dist/config/types.d.ts +32 -0
- package/dist/config/types.js +1 -0
- package/dist/domain/account-scope.d.ts +7 -0
- package/dist/domain/account-scope.js +28 -0
- package/dist/domain/analytics.d.ts +52 -0
- package/dist/domain/analytics.js +125 -0
- package/dist/domain/approval-service.d.ts +10 -0
- package/dist/domain/approval-service.js +48 -0
- package/dist/domain/asset-feed-compiler.d.ts +43 -0
- package/dist/domain/asset-feed-compiler.js +104 -0
- package/dist/domain/launch-service.d.ts +200 -0
- package/dist/domain/launch-service.js +558 -0
- package/dist/domain/meta-ads-service.d.ts +620 -0
- package/dist/domain/meta-ads-service.js +841 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -0
- package/dist/output/render.d.ts +3 -0
- package/dist/output/render.js +103 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +1 -0
- package/dist/utils/currency.d.ts +4 -0
- package/dist/utils/currency.js +40 -0
- package/dist/utils/date-range.d.ts +20 -0
- package/dist/utils/date-range.js +115 -0
- package/dist/utils/errors.d.ts +35 -0
- package/dist/utils/errors.js +68 -0
- package/dist/utils/ids.d.ts +4 -0
- package/dist/utils/ids.js +23 -0
- package/dist/utils/meta-placement-assets.d.ts +44 -0
- package/dist/utils/meta-placement-assets.js +315 -0
- package/dist/utils/security.d.ts +5 -0
- package/dist/utils/security.js +104 -0
- package/dist/validators/common.d.ts +10 -0
- package/dist/validators/common.js +56 -0
- package/dist/validators/create-spec.d.ts +373 -0
- package/dist/validators/create-spec.js +394 -0
- package/dist/validators/launch-spec.d.ts +229 -0
- package/dist/validators/launch-spec.js +371 -0
- package/docs/TECHNICAL.md +480 -0
- package/examples/README.md +29 -0
- package/examples/launch/assets/feed4x5.png +0 -0
- package/examples/launch/assets/story9x16.png +0 -0
- package/examples/launch/multi-format-launch.json +90 -0
- package/examples/single-object/ad.json +6 -0
- package/examples/single-object/adset.json +30 -0
- package/examples/single-object/campaign.json +6 -0
- package/examples/single-object/creative.json +19 -0
- package/package.json +62 -0
- package/skills/meta-cli-operator/SKILL.md +105 -0
- package/skills/meta-cli-operator/agents/openai.yaml +4 -0
- 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
|
+
}
|