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,44 @@
|
|
|
1
|
+
import { createAction } from "../cli/action.js";
|
|
2
|
+
import { collectAcrossAccounts } from "./helpers.js";
|
|
3
|
+
import { detectAnomalies } from "../domain/analytics.js";
|
|
4
|
+
import { resolveAccountScope } from "../domain/account-scope.js";
|
|
5
|
+
import { MetaAdsService } from "../domain/meta-ads-service.js";
|
|
6
|
+
import { parseDateWindow } from "../utils/date-range.js";
|
|
7
|
+
import { parseThreshold } from "../validators/common.js";
|
|
8
|
+
export function registerAnomaliesCommand(program, deps, state) {
|
|
9
|
+
program
|
|
10
|
+
.command("anomalies")
|
|
11
|
+
.description("Detect campaign-level anomalies from recent daily insights.")
|
|
12
|
+
.requiredOption("--account <id|all>", "Ad account id or all.")
|
|
13
|
+
.option("--threshold <percent>", "Percent change threshold.", "20")
|
|
14
|
+
.action(createAction("anomalies", deps, state, async (context, options) => {
|
|
15
|
+
const threshold = parseThreshold(options.threshold);
|
|
16
|
+
const scope = await resolveAccountScope(context, options.account);
|
|
17
|
+
const service = new MetaAdsService(context.client);
|
|
18
|
+
const window = parseDateWindow({
|
|
19
|
+
last: "14d"
|
|
20
|
+
});
|
|
21
|
+
const collected = await collectAcrossAccounts(scope.accounts, async (account) => {
|
|
22
|
+
const rows = await service.getDailyCampaignInsights(account.id, window);
|
|
23
|
+
const anomalies = detectAnomalies(rows, threshold);
|
|
24
|
+
return {
|
|
25
|
+
accountId: account.id,
|
|
26
|
+
accountName: account.name,
|
|
27
|
+
anomalies
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
command: "anomalies",
|
|
33
|
+
data: {
|
|
34
|
+
accounts: collected.results,
|
|
35
|
+
threshold,
|
|
36
|
+
window
|
|
37
|
+
},
|
|
38
|
+
partialFailures: collected.failures.map((failure) => ({
|
|
39
|
+
message: failure.message,
|
|
40
|
+
scope: failure.accountId
|
|
41
|
+
}))
|
|
42
|
+
};
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createAction } from "../cli/action.js";
|
|
2
|
+
import { requirePermission } from "../auth/guards.js";
|
|
3
|
+
import { MetaAdsService } from "../domain/meta-ads-service.js";
|
|
4
|
+
import { parsePositiveNumber, parseVideoId } from "../validators/common.js";
|
|
5
|
+
import { resolveExistingFilePath } from "../validators/create-spec.js";
|
|
6
|
+
import { resolveSingleAccount } from "./helpers.js";
|
|
7
|
+
export function registerAssetCommands(program, deps, state) {
|
|
8
|
+
const assets = program.command("assets").description("Asset uploads and video processing visibility.");
|
|
9
|
+
const images = assets.command("images").description("Image asset uploads.");
|
|
10
|
+
const videos = assets.command("videos").description("Video asset uploads and processing status.");
|
|
11
|
+
images
|
|
12
|
+
.command("upload")
|
|
13
|
+
.requiredOption("--account <id>", "Ad account id.")
|
|
14
|
+
.requiredOption("--file <path>", "Local image file path.")
|
|
15
|
+
.option("--name <name>", "Optional asset name.")
|
|
16
|
+
.description("Draft or upload an image asset to the ad account.")
|
|
17
|
+
.action(createAction("assets images upload", deps, state, async (context, options) => {
|
|
18
|
+
const account = await resolveSingleAccount(context, options.account, "assets images upload");
|
|
19
|
+
const absolutePath = await resolveExistingFilePath(options.file, "Image file");
|
|
20
|
+
const service = new MetaAdsService(context.client);
|
|
21
|
+
const plan = {
|
|
22
|
+
accountId: account.id,
|
|
23
|
+
action: "upload-image",
|
|
24
|
+
file: absolutePath,
|
|
25
|
+
name: options.name
|
|
26
|
+
};
|
|
27
|
+
if (!context.apply) {
|
|
28
|
+
return {
|
|
29
|
+
ok: true,
|
|
30
|
+
command: "assets images upload",
|
|
31
|
+
data: plan,
|
|
32
|
+
meta: {
|
|
33
|
+
mode: "draft"
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
requirePermission(context.config, "write", "Image upload");
|
|
38
|
+
const asset = await service.uploadAdImage(account.id, absolutePath, options.name);
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
command: "assets images upload",
|
|
42
|
+
data: {
|
|
43
|
+
accountId: account.id,
|
|
44
|
+
applied: true,
|
|
45
|
+
asset
|
|
46
|
+
},
|
|
47
|
+
meta: {
|
|
48
|
+
mode: "write"
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}));
|
|
52
|
+
videos
|
|
53
|
+
.command("upload")
|
|
54
|
+
.requiredOption("--account <id>", "Ad account id.")
|
|
55
|
+
.requiredOption("--file <path>", "Local video file path.")
|
|
56
|
+
.option("--name <name>", "Optional asset name.")
|
|
57
|
+
.option("--wait", "Wait until Meta reports the video as ready.")
|
|
58
|
+
.option("--poll-ms <ms>", "Polling interval in milliseconds while waiting.", "3000")
|
|
59
|
+
.option("--timeout-ms <ms>", "Wait timeout in milliseconds.", "90000")
|
|
60
|
+
.description("Draft or upload a video asset to the ad account.")
|
|
61
|
+
.action(createAction("assets videos upload", deps, state, async (context, options) => {
|
|
62
|
+
const account = await resolveSingleAccount(context, options.account, "assets videos upload");
|
|
63
|
+
const absolutePath = await resolveExistingFilePath(options.file, "Video file");
|
|
64
|
+
const pollMs = parsePositiveNumber(options.pollMs ?? "3000", "Poll interval");
|
|
65
|
+
const timeoutMs = parsePositiveNumber(options.timeoutMs ?? "90000", "Timeout");
|
|
66
|
+
const service = new MetaAdsService(context.client);
|
|
67
|
+
const plan = {
|
|
68
|
+
accountId: account.id,
|
|
69
|
+
action: "upload-video",
|
|
70
|
+
file: absolutePath,
|
|
71
|
+
name: options.name,
|
|
72
|
+
wait: Boolean(options.wait)
|
|
73
|
+
};
|
|
74
|
+
if (!context.apply) {
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
command: "assets videos upload",
|
|
78
|
+
data: plan,
|
|
79
|
+
meta: {
|
|
80
|
+
mode: "draft"
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
requirePermission(context.config, "write", "Video upload");
|
|
85
|
+
const asset = await service.uploadAdVideo(account.id, absolutePath, options.name);
|
|
86
|
+
const status = options.wait
|
|
87
|
+
? await service.waitForVideoReady(asset.id, { pollMs, timeoutMs })
|
|
88
|
+
: undefined;
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
command: "assets videos upload",
|
|
92
|
+
data: {
|
|
93
|
+
accountId: account.id,
|
|
94
|
+
applied: true,
|
|
95
|
+
asset,
|
|
96
|
+
status
|
|
97
|
+
},
|
|
98
|
+
meta: {
|
|
99
|
+
mode: "write"
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}));
|
|
103
|
+
videos
|
|
104
|
+
.command("status")
|
|
105
|
+
.argument("<video-id>", "Video id.")
|
|
106
|
+
.description("Show the current processing status of an uploaded video.")
|
|
107
|
+
.action(createAction("assets videos status", deps, state, async (context, videoId) => {
|
|
108
|
+
const service = new MetaAdsService(context.client);
|
|
109
|
+
const status = await service.getAdVideoStatus(parseVideoId(videoId));
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
command: "assets videos status",
|
|
113
|
+
data: status
|
|
114
|
+
};
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createAction } from "../cli/action.js";
|
|
2
|
+
import { resolveSingleAccount } from "./helpers.js";
|
|
3
|
+
import { MetaAdsService } from "../domain/meta-ads-service.js";
|
|
4
|
+
import { parseAudienceId } from "../validators/common.js";
|
|
5
|
+
export function registerAudienceCommands(program, deps, state) {
|
|
6
|
+
const audiences = program.command("audiences").description("Audience discovery and size estimates.");
|
|
7
|
+
audiences
|
|
8
|
+
.command("list")
|
|
9
|
+
.requiredOption("--account <id>", "Ad account id.")
|
|
10
|
+
.description("List audiences for an account.")
|
|
11
|
+
.action(createAction("audiences list", deps, state, async (context, options) => {
|
|
12
|
+
const account = await resolveSingleAccount(context, options.account, "audiences list");
|
|
13
|
+
const service = new MetaAdsService(context.client);
|
|
14
|
+
const rows = await service.listAudiences(account.id);
|
|
15
|
+
return {
|
|
16
|
+
ok: true,
|
|
17
|
+
command: "audiences list",
|
|
18
|
+
data: {
|
|
19
|
+
accountId: account.id,
|
|
20
|
+
audiences: rows
|
|
21
|
+
},
|
|
22
|
+
meta: {
|
|
23
|
+
count: rows.length
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}));
|
|
27
|
+
audiences
|
|
28
|
+
.command("size")
|
|
29
|
+
.requiredOption("--audience <id>", "Audience id.")
|
|
30
|
+
.description("Get audience size estimate details.")
|
|
31
|
+
.action(createAction("audiences size", deps, state, async (context, options) => {
|
|
32
|
+
const service = new MetaAdsService(context.client);
|
|
33
|
+
const audience = await service.getAudience(parseAudienceId(options.audience));
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
command: "audiences size",
|
|
37
|
+
data: audience
|
|
38
|
+
};
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { DEFAULT_AUTH_REDIRECT_URI } from "../auth/constants.js";
|
|
2
|
+
import { createAction } from "../cli/action.js";
|
|
3
|
+
import { runInteractiveAuthLogin } from "../auth/login.js";
|
|
4
|
+
import { writeFileConfig } from "../config/file-config.js";
|
|
5
|
+
import { MetaAdsService } from "../domain/meta-ads-service.js";
|
|
6
|
+
function resolveSettingSource(envName, resolvedValue, fallback = "none") {
|
|
7
|
+
if (process.env[envName]?.trim()) {
|
|
8
|
+
return "env";
|
|
9
|
+
}
|
|
10
|
+
if (resolvedValue) {
|
|
11
|
+
return fallback;
|
|
12
|
+
}
|
|
13
|
+
return "none";
|
|
14
|
+
}
|
|
15
|
+
function isExpired(value) {
|
|
16
|
+
if (!value) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const expiresAt = new Date(value);
|
|
20
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
return expiresAt.getTime() <= Date.now();
|
|
24
|
+
}
|
|
25
|
+
export function registerAuthCommands(program, deps, state) {
|
|
26
|
+
const auth = program.command("auth").description("Authentication and token visibility.");
|
|
27
|
+
auth
|
|
28
|
+
.command("status")
|
|
29
|
+
.description("Show resolved auth state, token source, and a lightweight token probe if possible.")
|
|
30
|
+
.action(createAction("auth status", deps, state, async (context) => {
|
|
31
|
+
const service = new MetaAdsService(context.client);
|
|
32
|
+
let tokenProbe = null;
|
|
33
|
+
let tokenProbeError;
|
|
34
|
+
if (context.config.accessToken) {
|
|
35
|
+
try {
|
|
36
|
+
tokenProbe = await service.whoAmI();
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
tokenProbeError = error instanceof Error ? error.message : "Unknown auth probe failure.";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
command: "auth status",
|
|
45
|
+
data: {
|
|
46
|
+
accessTokenExpiresAt: context.config.accessTokenExpiresAt,
|
|
47
|
+
accessTokenExpired: isExpired(context.config.accessTokenExpiresAt),
|
|
48
|
+
accessTokenPresent: Boolean(context.config.accessToken),
|
|
49
|
+
accessTokenSource: resolveSettingSource("META_ACCESS_TOKEN", context.config.accessToken, "config"),
|
|
50
|
+
apiVersion: context.config.apiVersion,
|
|
51
|
+
appIdPresent: Boolean(context.config.appId),
|
|
52
|
+
appIdSource: resolveSettingSource("META_APP_ID", context.config.appId, "config"),
|
|
53
|
+
appSecretPresent: Boolean(context.config.appSecret),
|
|
54
|
+
approvalWebhookPresent: Boolean(context.config.approvalWebhook),
|
|
55
|
+
authConfigIdPresent: Boolean(context.config.authConfigId),
|
|
56
|
+
authConfigIdSource: resolveSettingSource("META_AUTH_CONFIG_ID", context.config.authConfigId, "config"),
|
|
57
|
+
authRedirectUri: context.config.authRedirectUri,
|
|
58
|
+
authRedirectUriSource: process.env.META_AUTH_REDIRECT_URI?.trim()
|
|
59
|
+
? "env"
|
|
60
|
+
: context.config.authRedirectUri && context.config.authRedirectUri !== DEFAULT_AUTH_REDIRECT_URI
|
|
61
|
+
? "config"
|
|
62
|
+
: "default",
|
|
63
|
+
configPath: context.config.configPath,
|
|
64
|
+
defaultAccountId: context.config.defaultAccountId,
|
|
65
|
+
permissionMode: context.config.permissionMode,
|
|
66
|
+
tokenProbe,
|
|
67
|
+
tokenProbeError
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}));
|
|
71
|
+
auth
|
|
72
|
+
.command("login")
|
|
73
|
+
.description("Run Facebook Login for Business and persist the resulting user token locally.")
|
|
74
|
+
.option("--app-id <id>", "Override the Meta app ID used for the login flow.")
|
|
75
|
+
.option("--config-id <id>", "Override the Facebook Login for Business configuration ID.")
|
|
76
|
+
.option("--redirect-uri <url>", "Override the redirect URI used for the login flow.")
|
|
77
|
+
.option("--no-open-browser", "Do not open a browser automatically; print the login URL and wait for the pasted callback URL.")
|
|
78
|
+
.action(createAction("auth login", deps, state, async (context, options) => {
|
|
79
|
+
const login = await runInteractiveAuthLogin(context.config, {
|
|
80
|
+
appId: options.appId,
|
|
81
|
+
configId: options.configId,
|
|
82
|
+
openBrowser: options.openBrowser,
|
|
83
|
+
redirectUri: options.redirectUri
|
|
84
|
+
}, {
|
|
85
|
+
fetchImpl: context.fetchImpl
|
|
86
|
+
});
|
|
87
|
+
await writeFileConfig(context.config.configPath, {
|
|
88
|
+
accessToken: login.accessToken,
|
|
89
|
+
accessTokenExpiresAt: login.accessTokenExpiresAt,
|
|
90
|
+
appId: login.appId,
|
|
91
|
+
authConfigId: login.authConfigId,
|
|
92
|
+
authRedirectUri: login.authRedirectUri
|
|
93
|
+
});
|
|
94
|
+
const warnings = [];
|
|
95
|
+
if (process.env.META_ACCESS_TOKEN?.trim()) {
|
|
96
|
+
warnings.push("META_ACCESS_TOKEN is currently set in the environment and will continue to override the stored login token until it is unset.");
|
|
97
|
+
}
|
|
98
|
+
if ((options.openBrowser ?? true) && !login.browserOpened) {
|
|
99
|
+
warnings.push("The browser could not be opened automatically. Complete the login flow manually with the printed login URL.");
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
command: "auth login",
|
|
104
|
+
data: {
|
|
105
|
+
accessTokenExpiresAt: login.accessTokenExpiresAt,
|
|
106
|
+
authConfigId: login.authConfigId,
|
|
107
|
+
authRedirectUri: login.authRedirectUri,
|
|
108
|
+
browserOpened: login.browserOpened,
|
|
109
|
+
codeExchangeUsed: login.codeExchangeUsed,
|
|
110
|
+
configPath: context.config.configPath,
|
|
111
|
+
tokenSource: "config"
|
|
112
|
+
},
|
|
113
|
+
warnings
|
|
114
|
+
};
|
|
115
|
+
}));
|
|
116
|
+
auth
|
|
117
|
+
.command("logout")
|
|
118
|
+
.description("Remove any locally stored access token from the CLI config.")
|
|
119
|
+
.action(createAction("auth logout", deps, state, async (context) => {
|
|
120
|
+
await writeFileConfig(context.config.configPath, {
|
|
121
|
+
accessToken: undefined,
|
|
122
|
+
accessTokenExpiresAt: undefined
|
|
123
|
+
});
|
|
124
|
+
const warnings = process.env.META_ACCESS_TOKEN?.trim()
|
|
125
|
+
? [
|
|
126
|
+
"META_ACCESS_TOKEN is still set in the environment and will remain active for this shell until it is unset."
|
|
127
|
+
]
|
|
128
|
+
: undefined;
|
|
129
|
+
return {
|
|
130
|
+
ok: true,
|
|
131
|
+
command: "auth logout",
|
|
132
|
+
data: {
|
|
133
|
+
configPath: context.config.configPath,
|
|
134
|
+
localAccessTokenCleared: true
|
|
135
|
+
},
|
|
136
|
+
warnings
|
|
137
|
+
};
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { createAction } from "../cli/action.js";
|
|
2
|
+
import { requirePermission } from "../auth/guards.js";
|
|
3
|
+
import { resolveSingleAccount } from "./helpers.js";
|
|
4
|
+
import { decideBudgetApproval, decideCampaignStatusApproval, submitApprovalRequest } from "../domain/approval-service.js";
|
|
5
|
+
import { MetaAdsService } from "../domain/meta-ads-service.js";
|
|
6
|
+
import { fromMinorUnits, toMinorUnits } from "../utils/currency.js";
|
|
7
|
+
import { percentChange } from "../utils/date-range.js";
|
|
8
|
+
import { AppError, ExitCode } from "../utils/errors.js";
|
|
9
|
+
import { campaignCreateSpecSchema, readSpecFile } from "../validators/create-spec.js";
|
|
10
|
+
import { parseCampaignId, parsePositiveNumber } from "../validators/common.js";
|
|
11
|
+
function buildApprovalPayload(context, action, resourceId, accountId, changeSummary, reason) {
|
|
12
|
+
const approvalArgv = action === "budget"
|
|
13
|
+
? ["campaigns", "budget", resourceId, "--daily", String(changeSummary.requestedBudget ?? "unknown")]
|
|
14
|
+
: ["campaigns", action, resourceId];
|
|
15
|
+
return {
|
|
16
|
+
action,
|
|
17
|
+
accountId,
|
|
18
|
+
approvalReason: reason,
|
|
19
|
+
argv: approvalArgv,
|
|
20
|
+
changeSummary,
|
|
21
|
+
command: approvalArgv.join(" "),
|
|
22
|
+
requestedAt: new Date().toISOString(),
|
|
23
|
+
requestedMode: context.config.permissionMode,
|
|
24
|
+
resourceId,
|
|
25
|
+
resourceType: "campaign"
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function registerCampaignCommands(program, deps, state) {
|
|
29
|
+
const campaigns = program.command("campaigns").description("Campaign reads, creation, and controlled writes.");
|
|
30
|
+
campaigns
|
|
31
|
+
.command("create")
|
|
32
|
+
.requiredOption("--account <id>", "Ad account id.")
|
|
33
|
+
.requiredOption("--spec <path>", "Path to a JSON campaign create spec.")
|
|
34
|
+
.description("Draft or create a campaign from a JSON spec.")
|
|
35
|
+
.action(createAction("campaigns create", deps, state, async (context, options) => {
|
|
36
|
+
const account = await resolveSingleAccount(context, options.account, "campaigns create");
|
|
37
|
+
const { spec, specPath } = await readSpecFile(options.spec, campaignCreateSpecSchema, "Campaign create");
|
|
38
|
+
const service = new MetaAdsService(context.client);
|
|
39
|
+
const plan = {
|
|
40
|
+
accountId: account.id,
|
|
41
|
+
action: "create-campaign",
|
|
42
|
+
spec,
|
|
43
|
+
specPath
|
|
44
|
+
};
|
|
45
|
+
if (!context.apply) {
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
command: "campaigns create",
|
|
49
|
+
data: plan,
|
|
50
|
+
meta: {
|
|
51
|
+
mode: "draft"
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
requirePermission(context.config, "write", "Campaign create");
|
|
56
|
+
const campaign = await service.createCampaign(account.id, spec);
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
command: "campaigns create",
|
|
60
|
+
data: {
|
|
61
|
+
accountId: account.id,
|
|
62
|
+
applied: true,
|
|
63
|
+
campaign,
|
|
64
|
+
specPath
|
|
65
|
+
},
|
|
66
|
+
meta: {
|
|
67
|
+
mode: "write"
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}));
|
|
71
|
+
campaigns
|
|
72
|
+
.command("list")
|
|
73
|
+
.requiredOption("--account <id>", "Ad account id.")
|
|
74
|
+
.description("List campaigns for an ad account.")
|
|
75
|
+
.action(createAction("campaigns list", deps, state, async (context, options) => {
|
|
76
|
+
const account = await resolveSingleAccount(context, options.account, "campaigns list");
|
|
77
|
+
const service = new MetaAdsService(context.client);
|
|
78
|
+
const campaignsList = await service.listCampaigns(account.id);
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
command: "campaigns list",
|
|
82
|
+
data: {
|
|
83
|
+
accountId: account.id,
|
|
84
|
+
campaigns: campaignsList
|
|
85
|
+
},
|
|
86
|
+
meta: {
|
|
87
|
+
count: campaignsList.length
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}));
|
|
91
|
+
campaigns
|
|
92
|
+
.command("get")
|
|
93
|
+
.argument("<campaign-id>", "Campaign id.")
|
|
94
|
+
.description("Get campaign details.")
|
|
95
|
+
.action(createAction("campaigns get", deps, state, async (context, campaignId) => {
|
|
96
|
+
const service = new MetaAdsService(context.client);
|
|
97
|
+
const campaign = await service.getCampaign(parseCampaignId(campaignId));
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
command: "campaigns get",
|
|
101
|
+
data: campaign
|
|
102
|
+
};
|
|
103
|
+
}));
|
|
104
|
+
campaigns
|
|
105
|
+
.command("pause")
|
|
106
|
+
.argument("<campaign-id>", "Campaign id.")
|
|
107
|
+
.description("Draft or submit approval request to pause a campaign.")
|
|
108
|
+
.action(createAction("campaigns pause", deps, state, async (context, campaignId) => {
|
|
109
|
+
const service = new MetaAdsService(context.client);
|
|
110
|
+
const normalizedId = parseCampaignId(campaignId);
|
|
111
|
+
const campaign = await service.getCampaign(normalizedId);
|
|
112
|
+
const decision = decideCampaignStatusApproval("pause");
|
|
113
|
+
const plan = {
|
|
114
|
+
action: "pause",
|
|
115
|
+
approvalRequired: decision.required,
|
|
116
|
+
campaign,
|
|
117
|
+
mutation: {
|
|
118
|
+
status: "PAUSED"
|
|
119
|
+
},
|
|
120
|
+
reason: decision.reason
|
|
121
|
+
};
|
|
122
|
+
if (!context.apply) {
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
command: "campaigns pause",
|
|
126
|
+
data: plan,
|
|
127
|
+
meta: {
|
|
128
|
+
mode: "draft"
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
requirePermission(context.config, "write", "Campaign pause");
|
|
133
|
+
const approvalPayload = buildApprovalPayload(context, "pause", normalizedId, campaign.accountId, plan.mutation, decision.reason);
|
|
134
|
+
await submitApprovalRequest(context, approvalPayload);
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
command: "campaigns pause",
|
|
138
|
+
data: {
|
|
139
|
+
approvalSubmitted: true,
|
|
140
|
+
payload: approvalPayload
|
|
141
|
+
},
|
|
142
|
+
meta: {
|
|
143
|
+
exitCode: ExitCode.ApprovalRequired,
|
|
144
|
+
mode: "approval"
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}));
|
|
148
|
+
campaigns
|
|
149
|
+
.command("enable")
|
|
150
|
+
.argument("<campaign-id>", "Campaign id.")
|
|
151
|
+
.description("Draft or submit approval request to enable a campaign.")
|
|
152
|
+
.action(createAction("campaigns enable", deps, state, async (context, campaignId) => {
|
|
153
|
+
const service = new MetaAdsService(context.client);
|
|
154
|
+
const normalizedId = parseCampaignId(campaignId);
|
|
155
|
+
const campaign = await service.getCampaign(normalizedId);
|
|
156
|
+
const decision = decideCampaignStatusApproval("enable");
|
|
157
|
+
const plan = {
|
|
158
|
+
action: "enable",
|
|
159
|
+
approvalRequired: decision.required,
|
|
160
|
+
campaign,
|
|
161
|
+
mutation: {
|
|
162
|
+
status: "ACTIVE"
|
|
163
|
+
},
|
|
164
|
+
reason: decision.reason
|
|
165
|
+
};
|
|
166
|
+
if (!context.apply) {
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
command: "campaigns enable",
|
|
170
|
+
data: plan,
|
|
171
|
+
meta: {
|
|
172
|
+
mode: "draft"
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
requirePermission(context.config, "write", "Campaign enable");
|
|
177
|
+
const approvalPayload = buildApprovalPayload(context, "enable", normalizedId, campaign.accountId, plan.mutation, decision.reason);
|
|
178
|
+
await submitApprovalRequest(context, approvalPayload);
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
command: "campaigns enable",
|
|
182
|
+
data: {
|
|
183
|
+
approvalSubmitted: true,
|
|
184
|
+
payload: approvalPayload
|
|
185
|
+
},
|
|
186
|
+
meta: {
|
|
187
|
+
exitCode: ExitCode.ApprovalRequired,
|
|
188
|
+
mode: "approval"
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}));
|
|
192
|
+
campaigns
|
|
193
|
+
.command("budget")
|
|
194
|
+
.argument("<campaign-id>", "Campaign id.")
|
|
195
|
+
.requiredOption("--daily <amount>", "Daily budget amount in account currency.")
|
|
196
|
+
.description("Draft or update a campaign-level daily budget.")
|
|
197
|
+
.action(createAction("campaigns budget", deps, state, async (context, campaignId, options) => {
|
|
198
|
+
const service = new MetaAdsService(context.client);
|
|
199
|
+
const normalizedId = parseCampaignId(campaignId);
|
|
200
|
+
const campaign = await service.getCampaign(normalizedId);
|
|
201
|
+
if (!campaign.accountId) {
|
|
202
|
+
throw new AppError("Campaign account id is missing from the provider response.", ExitCode.Provider);
|
|
203
|
+
}
|
|
204
|
+
const account = await service.getAccount(campaign.accountId);
|
|
205
|
+
const currency = account.currency ?? "USD";
|
|
206
|
+
const currentBudgetMinorUnits = service.requireCampaignBudgetMinorUnits(campaign);
|
|
207
|
+
const currentBudget = fromMinorUnits(currentBudgetMinorUnits, currency);
|
|
208
|
+
const requestedBudget = parsePositiveNumber(options.daily, "Daily budget");
|
|
209
|
+
const change = percentChange(currentBudget, requestedBudget);
|
|
210
|
+
const decision = decideBudgetApproval(change);
|
|
211
|
+
const minorUnits = toMinorUnits(requestedBudget, currency);
|
|
212
|
+
const plan = {
|
|
213
|
+
action: "budget",
|
|
214
|
+
approvalRequired: decision.required,
|
|
215
|
+
campaign,
|
|
216
|
+
changePercent: change,
|
|
217
|
+
currency,
|
|
218
|
+
currentBudget,
|
|
219
|
+
mutation: {
|
|
220
|
+
daily_budget: minorUnits
|
|
221
|
+
},
|
|
222
|
+
requestedBudget,
|
|
223
|
+
reason: decision.reason
|
|
224
|
+
};
|
|
225
|
+
if (!context.apply) {
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
command: "campaigns budget",
|
|
229
|
+
data: plan,
|
|
230
|
+
meta: {
|
|
231
|
+
mode: "draft"
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
requirePermission(context.config, "write", "Campaign budget");
|
|
236
|
+
if (decision.required) {
|
|
237
|
+
const approvalPayload = buildApprovalPayload(context, "budget", normalizedId, campaign.accountId, {
|
|
238
|
+
currentBudget,
|
|
239
|
+
currency,
|
|
240
|
+
requestedBudget
|
|
241
|
+
}, decision.reason);
|
|
242
|
+
await submitApprovalRequest(context, approvalPayload);
|
|
243
|
+
return {
|
|
244
|
+
ok: true,
|
|
245
|
+
command: "campaigns budget",
|
|
246
|
+
data: {
|
|
247
|
+
approvalSubmitted: true,
|
|
248
|
+
payload: approvalPayload
|
|
249
|
+
},
|
|
250
|
+
meta: {
|
|
251
|
+
exitCode: ExitCode.ApprovalRequired,
|
|
252
|
+
mode: "approval"
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
await service.updateCampaignDailyBudget(normalizedId, minorUnits);
|
|
257
|
+
return {
|
|
258
|
+
ok: true,
|
|
259
|
+
command: "campaigns budget",
|
|
260
|
+
data: {
|
|
261
|
+
accountId: campaign.accountId,
|
|
262
|
+
applied: true,
|
|
263
|
+
campaignId: normalizedId,
|
|
264
|
+
currency,
|
|
265
|
+
currentBudget,
|
|
266
|
+
requestedBudget
|
|
267
|
+
},
|
|
268
|
+
meta: {
|
|
269
|
+
mode: "write"
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}));
|
|
273
|
+
}
|