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
package/REQUIREMENTS.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# CLI-meta-ads Requirements Snapshot
|
|
2
|
+
|
|
3
|
+
This file is a compact product and scope snapshot for the CLI. It is intentionally shorter than the README and more stable than day-to-day implementation notes.
|
|
4
|
+
|
|
5
|
+
## Product intent
|
|
6
|
+
|
|
7
|
+
- Tool: Meta Ads CLI for Facebook and Instagram surfaces
|
|
8
|
+
- API: Meta Marketing API
|
|
9
|
+
- Runtime: Node.js CLI
|
|
10
|
+
- Primary human auth model: Facebook Login for Business user token stored in local config
|
|
11
|
+
- Primary automation auth model: pre-generated Meta access token, typically a system-user token
|
|
12
|
+
- Safety model: read-first, draft-first, approval-gated for sensitive writes
|
|
13
|
+
|
|
14
|
+
## Target users
|
|
15
|
+
|
|
16
|
+
| User or agent | Typical mode | Typical use cases |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| Ad operations | write | Performance review, budget changes, launch execution, creative diagnostics |
|
|
19
|
+
| Designers or creative teams | read | Creative performance and fatigue visibility |
|
|
20
|
+
| Client-facing reporting users | read | Account, campaign, and report visibility |
|
|
21
|
+
| Tracking / QA owners | read | Pixel and best-effort CAPI checks |
|
|
22
|
+
|
|
23
|
+
## Current command scope
|
|
24
|
+
|
|
25
|
+
### Read flows
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
meta performance --account <ad-account-id> --last 7d
|
|
29
|
+
meta performance --account <ad-account-id> --campaign "Retargeting" --last 30d
|
|
30
|
+
meta performance --account all --last 7d
|
|
31
|
+
meta performance --account <id> --breakdown age,gender --last 30d
|
|
32
|
+
|
|
33
|
+
meta campaigns list --account <id>
|
|
34
|
+
meta campaigns get <campaign-id>
|
|
35
|
+
|
|
36
|
+
meta ads list --campaign <id>
|
|
37
|
+
meta ads performance --campaign <id> --last 30d --sort ctr
|
|
38
|
+
meta ads fatigue --campaign <id> --last 30d
|
|
39
|
+
meta ads preview <ad-id>
|
|
40
|
+
|
|
41
|
+
meta anomalies --account <id> --threshold 20
|
|
42
|
+
meta anomalies --account all --threshold 30
|
|
43
|
+
|
|
44
|
+
meta pixel status --account <id>
|
|
45
|
+
meta pixel events --account <id> --last 24h
|
|
46
|
+
meta capi status --account <id>
|
|
47
|
+
meta capi events --account <id> --last 24h
|
|
48
|
+
|
|
49
|
+
meta audiences list --account <id>
|
|
50
|
+
meta audiences size --audience <id>
|
|
51
|
+
|
|
52
|
+
meta report daily --account <id>
|
|
53
|
+
meta report weekly --account <id>
|
|
54
|
+
meta report monthly --account <id>
|
|
55
|
+
|
|
56
|
+
meta auth login
|
|
57
|
+
meta auth status
|
|
58
|
+
meta auth logout
|
|
59
|
+
meta whoami
|
|
60
|
+
meta doctor --account <id>
|
|
61
|
+
meta verify-api --account <id>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Create, upload, and launch flows
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
meta assets images upload --account <id> --file ./hero.png
|
|
68
|
+
meta assets videos upload --account <id> --file ./spot.mp4 --wait
|
|
69
|
+
|
|
70
|
+
meta campaigns create --account <id> --spec ./campaign.json
|
|
71
|
+
meta adsets create --account <id> --spec ./adset.json
|
|
72
|
+
meta creatives create --account <id> --spec ./creative.json
|
|
73
|
+
meta ads create --account <id> --spec ./ad.json
|
|
74
|
+
|
|
75
|
+
meta launch validate --account <id> --spec ./launch.json
|
|
76
|
+
meta launch plan --account <id> --spec ./launch.json
|
|
77
|
+
meta launch apply --account <id> --spec ./launch.json
|
|
78
|
+
meta launch resume --receipt ./.meta-launch/<execution-id>.json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Approval-gated changes
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
meta campaigns pause <campaign-id>
|
|
85
|
+
meta campaigns enable <campaign-id>
|
|
86
|
+
meta campaigns budget <campaign-id> --daily 50
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Permission model
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
modes:
|
|
93
|
+
read:
|
|
94
|
+
- performance
|
|
95
|
+
- campaigns list/get
|
|
96
|
+
- ads list/performance/fatigue/preview
|
|
97
|
+
- anomalies
|
|
98
|
+
- pixel/capi status/events
|
|
99
|
+
- audiences list/size
|
|
100
|
+
- reports
|
|
101
|
+
- launch validate/plan
|
|
102
|
+
|
|
103
|
+
write:
|
|
104
|
+
- everything in read
|
|
105
|
+
- assets upload
|
|
106
|
+
- campaigns/adsets/creatives/ads create
|
|
107
|
+
- launch apply/resume
|
|
108
|
+
- campaigns budget direct write only when the change is <= 20%
|
|
109
|
+
|
|
110
|
+
admin:
|
|
111
|
+
- reserved for future expansion
|
|
112
|
+
- not intended as the default agent mode
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Multi-account behavior
|
|
116
|
+
|
|
117
|
+
The CLI supports multiple Meta ad accounts through Business Manager discovery:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
meta accounts list
|
|
121
|
+
meta accounts set-default <ad-account-id>
|
|
122
|
+
meta performance --account all
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Only explicit multi-account read surfaces support `--account all`. Single-account commands reject it.
|
|
126
|
+
|
|
127
|
+
## Environment variables
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
META_ACCESS_TOKEN=
|
|
131
|
+
META_APP_ID=
|
|
132
|
+
META_AUTH_CONFIG_ID=
|
|
133
|
+
META_AUTH_REDIRECT_URI=https://www.facebook.com/connect/login_success.html
|
|
134
|
+
META_APP_SECRET=
|
|
135
|
+
META_CLI_MODE=read
|
|
136
|
+
META_APPROVAL_WEBHOOK=
|
|
137
|
+
META_API_VERSION=v25.0
|
|
138
|
+
META_DEFAULT_ACCOUNT_ID=
|
|
139
|
+
META_OUTPUT_FORMAT=text
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Delivery constraints
|
|
143
|
+
|
|
144
|
+
- No live credentials are stored in the repository.
|
|
145
|
+
- No hidden host-local env files should be auto-loaded.
|
|
146
|
+
- Approval webhooks must remain HTTPS-only.
|
|
147
|
+
- Draft mode must remain the default for mutations.
|
|
148
|
+
- The npm artifact should stay free of repo-local artifacts and accidental internal files.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DEFAULT_AUTH_REDIRECT_URI = "https://www.facebook.com/connect/login_success.html";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_AUTH_REDIRECT_URI = "https://www.facebook.com/connect/login_success.html";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PermissionMode } from "../types.js";
|
|
2
|
+
import type { ResolvedConfig } from "../config/types.js";
|
|
3
|
+
export declare function hasPermission(current: PermissionMode, required: PermissionMode): boolean;
|
|
4
|
+
export declare function requirePermission(config: ResolvedConfig, required: PermissionMode, operation: string): void;
|
|
5
|
+
export declare function requireAccessToken(config: ResolvedConfig): string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AppError, ExitCode } from "../utils/errors.js";
|
|
2
|
+
const PERMISSION_ORDER = ["read", "write", "admin"];
|
|
3
|
+
export function hasPermission(current, required) {
|
|
4
|
+
return PERMISSION_ORDER.indexOf(current) >= PERMISSION_ORDER.indexOf(required);
|
|
5
|
+
}
|
|
6
|
+
export function requirePermission(config, required, operation) {
|
|
7
|
+
if (!hasPermission(config.permissionMode, required)) {
|
|
8
|
+
throw new AppError(`${operation} requires ${required} mode. Current mode is ${config.permissionMode}.`, ExitCode.Permission);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function requireAccessToken(config) {
|
|
12
|
+
if (!config.accessToken) {
|
|
13
|
+
throw new AppError("A Meta access token is required for Meta API operations. Set META_ACCESS_TOKEN or run auth login.", ExitCode.Auth);
|
|
14
|
+
}
|
|
15
|
+
return config.accessToken;
|
|
16
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type FetchLike } from "../client/meta-api-client.js";
|
|
2
|
+
import type { ResolvedConfig } from "../config/types.js";
|
|
3
|
+
export interface InteractiveAuthLoginOptions {
|
|
4
|
+
appId?: string | undefined;
|
|
5
|
+
configId?: string | undefined;
|
|
6
|
+
openBrowser?: boolean | undefined;
|
|
7
|
+
redirectUri?: string | undefined;
|
|
8
|
+
}
|
|
9
|
+
export interface InteractiveAuthLoginResult {
|
|
10
|
+
accessToken: string;
|
|
11
|
+
accessTokenExpiresAt?: string | undefined;
|
|
12
|
+
appId: string;
|
|
13
|
+
authConfigId: string;
|
|
14
|
+
authRedirectUri: string;
|
|
15
|
+
browserOpened: boolean;
|
|
16
|
+
codeExchangeUsed: boolean;
|
|
17
|
+
loginUrl: string;
|
|
18
|
+
}
|
|
19
|
+
export interface AuthLoginDeps {
|
|
20
|
+
fetchImpl?: FetchLike | undefined;
|
|
21
|
+
now?: (() => Date) | undefined;
|
|
22
|
+
openUrl?: ((url: string) => Promise<boolean>) | undefined;
|
|
23
|
+
promptForCallback?: ((loginUrl: string) => Promise<string>) | undefined;
|
|
24
|
+
randomBytes?: ((size: number) => Buffer) | undefined;
|
|
25
|
+
}
|
|
26
|
+
export declare function defaultPromptForCallback(loginUrl: string): Promise<string>;
|
|
27
|
+
export declare function openExternalBrowser(url: string): Promise<boolean>;
|
|
28
|
+
export declare function runInteractiveAuthLogin(config: ResolvedConfig, options?: InteractiveAuthLoginOptions, deps?: AuthLoginDeps): Promise<InteractiveAuthLoginResult>;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomBytes as defaultRandomBytes } from "node:crypto";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { AppError, ExitCode } from "../utils/errors.js";
|
|
7
|
+
import { normalizeAuthRedirectUri } from "../utils/security.js";
|
|
8
|
+
import { DEFAULT_AUTH_REDIRECT_URI } from "./constants.js";
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
function normalizeOptionalString(value) {
|
|
11
|
+
if (value === undefined) {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const normalized = value.trim();
|
|
15
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
16
|
+
}
|
|
17
|
+
function resolveLoginOptions(config, options) {
|
|
18
|
+
const appId = normalizeOptionalString(options.appId) ?? normalizeOptionalString(config.appId);
|
|
19
|
+
if (!appId) {
|
|
20
|
+
throw new AppError("auth login requires a Meta app ID. Set META_APP_ID, store appId in config, or pass --app-id.", ExitCode.Config);
|
|
21
|
+
}
|
|
22
|
+
const authConfigId = normalizeOptionalString(options.configId) ?? normalizeOptionalString(config.authConfigId);
|
|
23
|
+
if (!authConfigId) {
|
|
24
|
+
throw new AppError("auth login requires a Facebook Login for Business configuration ID. Set META_AUTH_CONFIG_ID, store authConfigId in config, or pass --config-id.", ExitCode.Config);
|
|
25
|
+
}
|
|
26
|
+
const authRedirectUri = normalizeOptionalString(options.redirectUri)
|
|
27
|
+
? normalizeAuthRedirectUri(options.redirectUri ?? "", "--redirect-uri")
|
|
28
|
+
: normalizeOptionalString(config.authRedirectUri)
|
|
29
|
+
?? DEFAULT_AUTH_REDIRECT_URI;
|
|
30
|
+
return {
|
|
31
|
+
appId,
|
|
32
|
+
authConfigId,
|
|
33
|
+
authRedirectUri
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function buildLoginUrl(config, options, state) {
|
|
37
|
+
const url = new URL(`https://www.facebook.com/${config.apiVersion}/dialog/oauth`);
|
|
38
|
+
url.searchParams.set("client_id", options.appId);
|
|
39
|
+
url.searchParams.set("redirect_uri", options.authRedirectUri);
|
|
40
|
+
url.searchParams.set("state", state);
|
|
41
|
+
url.searchParams.set("config_id", options.authConfigId);
|
|
42
|
+
return url.toString();
|
|
43
|
+
}
|
|
44
|
+
function promptLabel(loginUrl) {
|
|
45
|
+
return [
|
|
46
|
+
"",
|
|
47
|
+
"Complete the Meta login in your browser.",
|
|
48
|
+
"After the flow finishes, copy the final redirect URL from the browser address bar and paste it below.",
|
|
49
|
+
"",
|
|
50
|
+
`Login URL: ${loginUrl}`,
|
|
51
|
+
"",
|
|
52
|
+
"Paste final redirect URL: "
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
export async function defaultPromptForCallback(loginUrl) {
|
|
56
|
+
if (!process.stdin.isTTY) {
|
|
57
|
+
throw new AppError("auth login requires interactive stdin so the browser callback URL can be pasted back into the CLI.", ExitCode.Auth);
|
|
58
|
+
}
|
|
59
|
+
const prompt = createInterface({
|
|
60
|
+
input: process.stdin,
|
|
61
|
+
output: process.stderr
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
const response = await prompt.question(promptLabel(loginUrl));
|
|
65
|
+
return response.trim();
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
prompt.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function openExternalBrowser(url) {
|
|
72
|
+
try {
|
|
73
|
+
if (process.platform === "darwin") {
|
|
74
|
+
await execFileAsync("open", [url]);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (process.platform === "win32") {
|
|
78
|
+
await execFileAsync("rundll32", ["url.dll,FileProtocolHandler", url]);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
await execFileAsync("xdg-open", [url]);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function readResponseParams(callbackUrl) {
|
|
89
|
+
if (callbackUrl.hash.startsWith("#")) {
|
|
90
|
+
return new URLSearchParams(callbackUrl.hash.slice(1));
|
|
91
|
+
}
|
|
92
|
+
return callbackUrl.searchParams;
|
|
93
|
+
}
|
|
94
|
+
function parsePositiveInteger(value) {
|
|
95
|
+
if (!value) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const parsed = Number.parseInt(value, 10);
|
|
99
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
100
|
+
}
|
|
101
|
+
function parseCallbackUrl(rawValue) {
|
|
102
|
+
const trimmed = rawValue.trim();
|
|
103
|
+
try {
|
|
104
|
+
return new URL(trimmed);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new AppError("Could not parse the login callback. Paste the full final redirect URL from the browser address bar.", ExitCode.Auth);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function hasExpectedQueryParams(callbackUrl, expectedRedirectUrl) {
|
|
111
|
+
for (const [key, expectedValue] of expectedRedirectUrl.searchParams.entries()) {
|
|
112
|
+
const callbackValues = callbackUrl.searchParams.getAll(key);
|
|
113
|
+
if (!callbackValues.includes(expectedValue)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
function assertCallbackMatchesRedirectUri(callbackUrl, expectedRedirectUri) {
|
|
120
|
+
const expectedRedirectUrl = new URL(expectedRedirectUri);
|
|
121
|
+
const matchesTarget = callbackUrl.origin === expectedRedirectUrl.origin
|
|
122
|
+
&& callbackUrl.pathname === expectedRedirectUrl.pathname
|
|
123
|
+
&& callbackUrl.username === expectedRedirectUrl.username
|
|
124
|
+
&& callbackUrl.password === expectedRedirectUrl.password
|
|
125
|
+
&& hasExpectedQueryParams(callbackUrl, expectedRedirectUrl);
|
|
126
|
+
if (!matchesTarget) {
|
|
127
|
+
throw new AppError(`The pasted callback URL did not match the configured redirect URI (${expectedRedirectUri}).`, ExitCode.Auth);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function parseAuthCallback(rawValue, expectedState, expectedRedirectUri) {
|
|
131
|
+
const callbackUrl = parseCallbackUrl(rawValue);
|
|
132
|
+
assertCallbackMatchesRedirectUri(callbackUrl, expectedRedirectUri);
|
|
133
|
+
const responseParams = readResponseParams(callbackUrl);
|
|
134
|
+
const queryParams = callbackUrl.searchParams;
|
|
135
|
+
const returnedState = responseParams.get("state") ?? queryParams.get("state");
|
|
136
|
+
if (!returnedState || returnedState !== expectedState) {
|
|
137
|
+
throw new AppError("The login callback state did not match. Cancel the flow and run auth login again.", ExitCode.Auth);
|
|
138
|
+
}
|
|
139
|
+
const errorReason = responseParams.get("error_reason") ?? queryParams.get("error_reason");
|
|
140
|
+
const errorDescription = responseParams.get("error_description") ?? queryParams.get("error_description");
|
|
141
|
+
if (errorReason || errorDescription) {
|
|
142
|
+
throw new AppError(`Meta login failed: ${errorDescription ?? errorReason ?? "unknown error"}.`, ExitCode.Auth);
|
|
143
|
+
}
|
|
144
|
+
const accessToken = responseParams.get("access_token") ?? queryParams.get("access_token");
|
|
145
|
+
if (accessToken) {
|
|
146
|
+
return {
|
|
147
|
+
accessToken,
|
|
148
|
+
expiresInSeconds: parsePositiveInteger(responseParams.get("expires_in") ?? queryParams.get("expires_in"))
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const code = queryParams.get("code") ?? responseParams.get("code");
|
|
152
|
+
if (code) {
|
|
153
|
+
return {
|
|
154
|
+
code
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
throw new AppError("Meta login did not return an access token or authorization code. Paste the full final redirect URL from the browser.", ExitCode.Auth);
|
|
158
|
+
}
|
|
159
|
+
async function exchangeAuthorizationCode(config, options, code, fetchImpl) {
|
|
160
|
+
const appSecret = normalizeOptionalString(config.appSecret);
|
|
161
|
+
if (!appSecret) {
|
|
162
|
+
throw new AppError("This login flow returned an authorization code. Configure META_APP_SECRET to exchange it, or switch the Meta login configuration back to the default user-token response flow.", ExitCode.Auth);
|
|
163
|
+
}
|
|
164
|
+
const exchangeUrl = new URL(`https://graph.facebook.com/${config.apiVersion}/oauth/access_token`);
|
|
165
|
+
exchangeUrl.searchParams.set("client_id", options.appId);
|
|
166
|
+
exchangeUrl.searchParams.set("redirect_uri", options.authRedirectUri);
|
|
167
|
+
exchangeUrl.searchParams.set("client_secret", appSecret);
|
|
168
|
+
exchangeUrl.searchParams.set("code", code);
|
|
169
|
+
const response = await fetchImpl(exchangeUrl.toString(), {
|
|
170
|
+
method: "GET"
|
|
171
|
+
});
|
|
172
|
+
const body = await response.json().catch(() => null);
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
const providerMessage = body && "error" in body ? body.error?.message : undefined;
|
|
175
|
+
throw new AppError(providerMessage
|
|
176
|
+
? `Meta auth code exchange failed: ${providerMessage}`
|
|
177
|
+
: `Meta auth code exchange failed with HTTP ${response.status}.`, ExitCode.Auth);
|
|
178
|
+
}
|
|
179
|
+
if (!body || !("access_token" in body) || !body.access_token) {
|
|
180
|
+
throw new AppError("Meta auth code exchange completed without returning an access token.", ExitCode.Auth);
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
accessToken: body.access_token,
|
|
184
|
+
expiresInSeconds: typeof body.expires_in === "number" ? body.expires_in : undefined
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function toExpiresAt(now, expiresInSeconds) {
|
|
188
|
+
if (!expiresInSeconds) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
return new Date(now.getTime() + (expiresInSeconds * 1000)).toISOString();
|
|
192
|
+
}
|
|
193
|
+
export async function runInteractiveAuthLogin(config, options = {}, deps = {}) {
|
|
194
|
+
const resolvedOptions = resolveLoginOptions(config, options);
|
|
195
|
+
const randomBytes = deps.randomBytes ?? defaultRandomBytes;
|
|
196
|
+
const state = randomBytes(16).toString("hex");
|
|
197
|
+
const loginUrl = buildLoginUrl(config, resolvedOptions, state);
|
|
198
|
+
const openBrowser = options.openBrowser ?? true;
|
|
199
|
+
const browserOpened = openBrowser
|
|
200
|
+
? await (deps.openUrl ?? openExternalBrowser)(loginUrl)
|
|
201
|
+
: false;
|
|
202
|
+
const callbackValue = await (deps.promptForCallback ?? defaultPromptForCallback)(loginUrl);
|
|
203
|
+
let authCallback = parseAuthCallback(callbackValue, state, resolvedOptions.authRedirectUri);
|
|
204
|
+
const usedAuthorizationCode = Boolean(authCallback.code);
|
|
205
|
+
if (authCallback.code) {
|
|
206
|
+
authCallback = await exchangeAuthorizationCode(config, resolvedOptions, authCallback.code, deps.fetchImpl ?? fetch);
|
|
207
|
+
}
|
|
208
|
+
if (!authCallback.accessToken) {
|
|
209
|
+
throw new AppError("Meta login did not return an access token.", ExitCode.Auth);
|
|
210
|
+
}
|
|
211
|
+
const now = deps.now ? deps.now() : new Date();
|
|
212
|
+
return {
|
|
213
|
+
accessToken: authCallback.accessToken,
|
|
214
|
+
accessTokenExpiresAt: toExpiresAt(now, authCallback.expiresInSeconds),
|
|
215
|
+
appId: resolvedOptions.appId,
|
|
216
|
+
authConfigId: resolvedOptions.authConfigId,
|
|
217
|
+
authRedirectUri: resolvedOptions.authRedirectUri,
|
|
218
|
+
browserOpened,
|
|
219
|
+
codeExchangeUsed: usedAuthorizationCode,
|
|
220
|
+
loginUrl
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { createCommandContext, type CliDeps } from "./context.js";
|
|
3
|
+
import type { CommandResult } from "../types.js";
|
|
4
|
+
export interface CliRuntimeState {
|
|
5
|
+
argv: string[];
|
|
6
|
+
exitCode: number;
|
|
7
|
+
preferJson: boolean;
|
|
8
|
+
}
|
|
9
|
+
type ActionHandler<TArgs extends unknown[]> = (context: Awaited<ReturnType<typeof createCommandContext>>, ...args: TArgs) => Promise<CommandResult<unknown>>;
|
|
10
|
+
export declare function createAction<TArgs extends unknown[]>(commandName: string, deps: CliDeps, state: CliRuntimeState, handler: ActionHandler<TArgs>): (...actionArgs: [...TArgs, Command]) => Promise<void>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createCommandContext } from "./context.js";
|
|
2
|
+
import { renderFailure, renderSuccess } from "../output/render.js";
|
|
3
|
+
import { ExitCode, isAppError, toFailureShape } from "../utils/errors.js";
|
|
4
|
+
import { sanitizeArgvForLogs } from "../utils/security.js";
|
|
5
|
+
function buildDebugPayload(commandName, state, context) {
|
|
6
|
+
return {
|
|
7
|
+
apply: context.apply,
|
|
8
|
+
argv: sanitizeArgvForLogs(state.argv),
|
|
9
|
+
command: commandName,
|
|
10
|
+
config: {
|
|
11
|
+
accessTokenPresent: Boolean(context.config.accessToken),
|
|
12
|
+
apiVersion: context.config.apiVersion,
|
|
13
|
+
approvalWebhookPresent: Boolean(context.config.approvalWebhook),
|
|
14
|
+
configPath: context.config.configPath,
|
|
15
|
+
defaultAccountId: context.config.defaultAccountId,
|
|
16
|
+
outputFormat: context.config.outputFormat,
|
|
17
|
+
permissionMode: context.config.permissionMode
|
|
18
|
+
},
|
|
19
|
+
json: context.json
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function renderDebugMessage(payload) {
|
|
23
|
+
return [
|
|
24
|
+
"debug:",
|
|
25
|
+
` command: ${payload.command}`,
|
|
26
|
+
` argv: ${payload.argv.join(" ")}`,
|
|
27
|
+
` apply: ${payload.apply}`,
|
|
28
|
+
` json: ${payload.json}`,
|
|
29
|
+
` apiVersion: ${payload.config.apiVersion}`,
|
|
30
|
+
` permissionMode: ${payload.config.permissionMode}`,
|
|
31
|
+
` outputFormat: ${payload.config.outputFormat}`,
|
|
32
|
+
` defaultAccountId: ${payload.config.defaultAccountId ?? "-"}`,
|
|
33
|
+
` configPath: ${payload.config.configPath}`,
|
|
34
|
+
` accessTokenPresent: ${payload.config.accessTokenPresent}`,
|
|
35
|
+
` approvalWebhookPresent: ${payload.config.approvalWebhookPresent}`
|
|
36
|
+
].join("\n") + "\n";
|
|
37
|
+
}
|
|
38
|
+
export function createAction(commandName, deps, state, handler) {
|
|
39
|
+
return async (...actionArgs) => {
|
|
40
|
+
const command = actionArgs[actionArgs.length - 1];
|
|
41
|
+
const args = actionArgs.slice(0, -1);
|
|
42
|
+
const options = command.optsWithGlobals();
|
|
43
|
+
let asJson = Boolean(options.json);
|
|
44
|
+
let debugPayload;
|
|
45
|
+
try {
|
|
46
|
+
const context = await createCommandContext(options, deps);
|
|
47
|
+
asJson = context.json;
|
|
48
|
+
if (context.config.debug) {
|
|
49
|
+
debugPayload = buildDebugPayload(commandName, state, context);
|
|
50
|
+
if (!context.json) {
|
|
51
|
+
deps.io.stderr.write(renderDebugMessage(debugPayload));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const result = await handler(context, ...args);
|
|
55
|
+
const success = context.json && debugPayload
|
|
56
|
+
? {
|
|
57
|
+
...result,
|
|
58
|
+
meta: {
|
|
59
|
+
...(result.meta ?? {}),
|
|
60
|
+
debug: debugPayload
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
: result;
|
|
64
|
+
deps.io.stdout.write(renderSuccess(success, context.json));
|
|
65
|
+
const requestedExitCode = typeof result.meta?.exitCode === "number"
|
|
66
|
+
? Number(result.meta.exitCode)
|
|
67
|
+
: undefined;
|
|
68
|
+
state.exitCode = requestedExitCode
|
|
69
|
+
?? (result.partialFailures?.length ? ExitCode.PartialFailure : ExitCode.Ok);
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
const failure = toFailureShape(error, commandName, asJson && debugPayload ? { debug: debugPayload } : undefined);
|
|
73
|
+
deps.io.stderr.write(renderFailure(failure, asJson));
|
|
74
|
+
state.exitCode = isAppError(error) ? error.exitCode : 1;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Command, CommanderError } from "commander";
|
|
2
|
+
import { detectConfiguredOutputFormat } from "../config/file-config.js";
|
|
3
|
+
import { registerAccountsCommands } from "../commands/accounts.js";
|
|
4
|
+
import { registerAdSetCommands } from "../commands/adsets.js";
|
|
5
|
+
import { registerAdsCommands } from "../commands/ads.js";
|
|
6
|
+
import { registerAnomaliesCommand } from "../commands/anomalies.js";
|
|
7
|
+
import { registerAuthCommands } from "../commands/auth.js";
|
|
8
|
+
import { registerAssetCommands } from "../commands/assets.js";
|
|
9
|
+
import { registerAudienceCommands } from "../commands/audiences.js";
|
|
10
|
+
import { registerCampaignCommands } from "../commands/campaigns.js";
|
|
11
|
+
import { registerCapiCommands } from "../commands/capi.js";
|
|
12
|
+
import { registerCreativeCommands } from "../commands/creatives.js";
|
|
13
|
+
import { registerDiagnosticCommands } from "../commands/diagnostics.js";
|
|
14
|
+
import { registerLaunchCommands } from "../commands/launch.js";
|
|
15
|
+
import { registerPerformanceCommand } from "../commands/performance.js";
|
|
16
|
+
import { registerPixelCommands } from "../commands/pixel.js";
|
|
17
|
+
import { registerReportCommands } from "../commands/report.js";
|
|
18
|
+
import { renderFailure } from "../output/render.js";
|
|
19
|
+
import { AppError, ExitCode } from "../utils/errors.js";
|
|
20
|
+
import { toFailureShape } from "../utils/errors.js";
|
|
21
|
+
function readOptionValue(argv, optionName) {
|
|
22
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
23
|
+
const entry = argv[index];
|
|
24
|
+
if (!entry) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (entry === optionName) {
|
|
28
|
+
return argv[index + 1];
|
|
29
|
+
}
|
|
30
|
+
if (entry.startsWith(`${optionName}=`)) {
|
|
31
|
+
return entry.slice(optionName.length + 1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
async function resolveJsonPreference(argv) {
|
|
37
|
+
if (argv.includes("--json")) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (process.env.META_OUTPUT_FORMAT) {
|
|
41
|
+
return process.env.META_OUTPUT_FORMAT === "json";
|
|
42
|
+
}
|
|
43
|
+
const configuredOutputFormat = await detectConfiguredOutputFormat(readOptionValue(argv, "--config"));
|
|
44
|
+
return configuredOutputFormat === "json";
|
|
45
|
+
}
|
|
46
|
+
function buildProgram(deps, state) {
|
|
47
|
+
const program = new Command();
|
|
48
|
+
program
|
|
49
|
+
.name("meta")
|
|
50
|
+
.description("Agent-ready CLI for Meta Ads reads, drafts and approval-gated writes.")
|
|
51
|
+
.showHelpAfterError()
|
|
52
|
+
.exitOverride()
|
|
53
|
+
.configureOutput({
|
|
54
|
+
writeErr: (message) => {
|
|
55
|
+
if (!state.preferJson) {
|
|
56
|
+
deps.io.stderr.write(message);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
writeOut: (message) => {
|
|
60
|
+
deps.io.stdout.write(message);
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
.allowExcessArguments(false)
|
|
64
|
+
.option("--json", "Render machine-readable JSON output.")
|
|
65
|
+
.option("--config <path>", "Override the local CLI config path.")
|
|
66
|
+
.option("--api-version <version>", "Override the Graph/Marketing API version, e.g. v25.0.")
|
|
67
|
+
.option("--mode <mode>", "Override permission mode: read, write or admin.")
|
|
68
|
+
.option("--apply", "Apply a write. Without this flag, mutation commands stay in draft mode.")
|
|
69
|
+
.option("--debug", "Enable debug-oriented config resolution.");
|
|
70
|
+
registerAccountsCommands(program, deps, state);
|
|
71
|
+
registerAuthCommands(program, deps, state);
|
|
72
|
+
registerAssetCommands(program, deps, state);
|
|
73
|
+
registerPerformanceCommand(program, deps, state);
|
|
74
|
+
registerCampaignCommands(program, deps, state);
|
|
75
|
+
registerAdSetCommands(program, deps, state);
|
|
76
|
+
registerCreativeCommands(program, deps, state);
|
|
77
|
+
registerAdsCommands(program, deps, state);
|
|
78
|
+
registerAnomaliesCommand(program, deps, state);
|
|
79
|
+
registerPixelCommands(program, deps, state);
|
|
80
|
+
registerCapiCommands(program, deps, state);
|
|
81
|
+
registerAudienceCommands(program, deps, state);
|
|
82
|
+
registerReportCommands(program, deps, state);
|
|
83
|
+
registerLaunchCommands(program, deps, state);
|
|
84
|
+
registerDiagnosticCommands(program, deps, state);
|
|
85
|
+
return program;
|
|
86
|
+
}
|
|
87
|
+
export async function runCli(argv, deps) {
|
|
88
|
+
const state = {
|
|
89
|
+
argv: [...argv],
|
|
90
|
+
exitCode: 0,
|
|
91
|
+
preferJson: await resolveJsonPreference(argv)
|
|
92
|
+
};
|
|
93
|
+
const program = buildProgram(deps, state);
|
|
94
|
+
try {
|
|
95
|
+
await program.parseAsync(argv, { from: "user" });
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error instanceof CommanderError) {
|
|
99
|
+
state.exitCode = error.code === "commander.helpDisplayed" ? ExitCode.Ok : ExitCode.Usage;
|
|
100
|
+
if (state.preferJson && state.exitCode !== ExitCode.Ok) {
|
|
101
|
+
deps.io.stderr.write(renderFailure(toFailureShape(new AppError(error.message, state.exitCode), "meta"), true));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
deps.io.stderr.write(renderFailure(toFailureShape(error, "meta"), state.preferJson));
|
|
106
|
+
state.exitCode = 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return state.exitCode;
|
|
110
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../config/types.js";
|
|
2
|
+
import { MetaApiClient, type FetchLike } from "../client/meta-api-client.js";
|
|
3
|
+
import type { GlobalOptions } from "../types.js";
|
|
4
|
+
export interface IoLike {
|
|
5
|
+
stderr: {
|
|
6
|
+
write(message: string): void;
|
|
7
|
+
};
|
|
8
|
+
stdout: {
|
|
9
|
+
write(message: string): void;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export interface CliDeps {
|
|
13
|
+
fetchImpl?: FetchLike | undefined;
|
|
14
|
+
io: IoLike;
|
|
15
|
+
}
|
|
16
|
+
export interface CommandContext {
|
|
17
|
+
apply: boolean;
|
|
18
|
+
client: MetaApiClient;
|
|
19
|
+
config: ResolvedConfig;
|
|
20
|
+
fetchImpl?: FetchLike | undefined;
|
|
21
|
+
io: IoLike;
|
|
22
|
+
json: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare function createCommandContext(options: GlobalOptions, deps: CliDeps): Promise<CommandContext>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { loadResolvedConfig } from "../config/file-config.js";
|
|
2
|
+
import { MetaApiClient } from "../client/meta-api-client.js";
|
|
3
|
+
export async function createCommandContext(options, deps) {
|
|
4
|
+
const config = await loadResolvedConfig({
|
|
5
|
+
apiVersion: options.apiVersion,
|
|
6
|
+
configPath: options.config,
|
|
7
|
+
debug: options.debug,
|
|
8
|
+
outputFormat: options.json ? "json" : undefined,
|
|
9
|
+
permissionMode: options.mode
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
apply: Boolean(options.apply),
|
|
13
|
+
client: new MetaApiClient(config, deps.fetchImpl),
|
|
14
|
+
config,
|
|
15
|
+
fetchImpl: deps.fetchImpl,
|
|
16
|
+
io: deps.io,
|
|
17
|
+
json: options.json ?? config.outputFormat === "json"
|
|
18
|
+
};
|
|
19
|
+
}
|