google-ads-cli-core 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/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/invoke.d.ts +24 -0
- package/dist/src/invoke.js +38 -0
- package/dist/src/oauth.d.ts +27 -0
- package/dist/src/oauth.js +57 -0
- package/dist/src/profile.d.ts +18 -0
- package/dist/src/profile.js +45 -0
- package/dist/test/invoke.test.d.ts +1 -0
- package/dist/test/invoke.test.js +40 -0
- package/dist/test/oauth.test.d.ts +1 -0
- package/dist/test/oauth.test.js +57 -0
- package/dist/test/profile.test.d.ts +1 -0
- package/dist/test/profile.test.js +72 -0
- package/package.json +20 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface OperationDescriptor {
|
|
2
|
+
operationId: string;
|
|
3
|
+
httpMethod: string;
|
|
4
|
+
path: string;
|
|
5
|
+
}
|
|
6
|
+
export interface BuildGoogleAdsRequestOptions {
|
|
7
|
+
operation: OperationDescriptor;
|
|
8
|
+
accessToken: string;
|
|
9
|
+
developerToken: string;
|
|
10
|
+
loginCustomerId?: string;
|
|
11
|
+
linkedCustomerId?: string;
|
|
12
|
+
pathParams?: Record<string, string>;
|
|
13
|
+
payload?: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface BuiltGoogleAdsRequest {
|
|
16
|
+
operationId: string;
|
|
17
|
+
url: string;
|
|
18
|
+
init: {
|
|
19
|
+
method: string;
|
|
20
|
+
headers: Record<string, string>;
|
|
21
|
+
body?: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export declare function buildGoogleAdsRequest(options: BuildGoogleAdsRequestOptions): BuiltGoogleAdsRequest;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const GOOGLE_ADS_API_BASE_URL = 'https://googleads.googleapis.com';
|
|
2
|
+
const PATH_PARAM_PATTERN = /\{[+]?([^}]+)\}/g;
|
|
3
|
+
function interpolatePath(path, pathParams) {
|
|
4
|
+
return path.replace(PATH_PARAM_PATTERN, (_match, key) => {
|
|
5
|
+
const value = pathParams[key];
|
|
6
|
+
if (!value) {
|
|
7
|
+
throw new Error(`Missing required path param: ${key}`);
|
|
8
|
+
}
|
|
9
|
+
return encodeURIComponent(value);
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function buildGoogleAdsRequest(options) {
|
|
13
|
+
const headers = {
|
|
14
|
+
Authorization: `Bearer ${options.accessToken}`,
|
|
15
|
+
'developer-token': options.developerToken
|
|
16
|
+
};
|
|
17
|
+
if (options.loginCustomerId) {
|
|
18
|
+
headers['login-customer-id'] = options.loginCustomerId;
|
|
19
|
+
}
|
|
20
|
+
if (options.linkedCustomerId) {
|
|
21
|
+
headers['linked-customer-id'] = options.linkedCustomerId;
|
|
22
|
+
}
|
|
23
|
+
const resolvedPath = interpolatePath(options.operation.path, options.pathParams ?? {});
|
|
24
|
+
if (options.payload !== undefined) {
|
|
25
|
+
headers['content-type'] = 'application/json';
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
operationId: options.operation.operationId,
|
|
29
|
+
url: `${GOOGLE_ADS_API_BASE_URL}/${resolvedPath}`,
|
|
30
|
+
init: {
|
|
31
|
+
method: options.operation.httpMethod,
|
|
32
|
+
headers,
|
|
33
|
+
...(options.payload !== undefined
|
|
34
|
+
? { body: JSON.stringify(options.payload) }
|
|
35
|
+
: {})
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface GoogleOAuthTokenResponse {
|
|
2
|
+
access_token: string;
|
|
3
|
+
expires_in: number;
|
|
4
|
+
refresh_token?: string;
|
|
5
|
+
scope: string;
|
|
6
|
+
token_type: string;
|
|
7
|
+
}
|
|
8
|
+
export interface GoogleAuthUrlOptions {
|
|
9
|
+
clientId: string;
|
|
10
|
+
redirectUri: string;
|
|
11
|
+
state?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ExchangeAuthCodeOptions {
|
|
14
|
+
clientId: string;
|
|
15
|
+
clientSecret: string;
|
|
16
|
+
authCode: string;
|
|
17
|
+
redirectUri: string;
|
|
18
|
+
}
|
|
19
|
+
export interface RefreshAccessTokenOptions {
|
|
20
|
+
clientId: string;
|
|
21
|
+
clientSecret: string;
|
|
22
|
+
refreshToken: string;
|
|
23
|
+
}
|
|
24
|
+
export type OAuthFetch = typeof fetch;
|
|
25
|
+
export declare function buildGoogleAdsAuthUrl(options: GoogleAuthUrlOptions): string;
|
|
26
|
+
export declare function exchangeAuthCode(options: ExchangeAuthCodeOptions, fetchImpl?: OAuthFetch): Promise<GoogleOAuthTokenResponse>;
|
|
27
|
+
export declare function refreshGoogleAdsAccessToken(options: RefreshAccessTokenOptions, fetchImpl?: OAuthFetch): Promise<GoogleOAuthTokenResponse>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const GOOGLE_OAUTH_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
2
|
+
const GOOGLE_OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
3
|
+
const GOOGLE_ADS_SCOPE = 'https://www.googleapis.com/auth/adwords';
|
|
4
|
+
function buildTokenRequest(body) {
|
|
5
|
+
return {
|
|
6
|
+
method: 'POST',
|
|
7
|
+
headers: {
|
|
8
|
+
'content-type': 'application/x-www-form-urlencoded'
|
|
9
|
+
},
|
|
10
|
+
body: body.toString()
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
async function parseTokenResponse(response) {
|
|
14
|
+
const json = (await response.json());
|
|
15
|
+
if (!response.ok || !json.access_token) {
|
|
16
|
+
throw new Error(json.error_description ?? json.error ?? `OAuth request failed: ${response.status}`);
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
access_token: json.access_token,
|
|
20
|
+
expires_in: json.expires_in ?? 0,
|
|
21
|
+
refresh_token: json.refresh_token,
|
|
22
|
+
scope: json.scope ?? GOOGLE_ADS_SCOPE,
|
|
23
|
+
token_type: json.token_type ?? 'Bearer'
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function buildGoogleAdsAuthUrl(options) {
|
|
27
|
+
const url = new URL(GOOGLE_OAUTH_AUTHORIZE_URL);
|
|
28
|
+
url.searchParams.set('client_id', options.clientId);
|
|
29
|
+
url.searchParams.set('redirect_uri', options.redirectUri);
|
|
30
|
+
url.searchParams.set('response_type', 'code');
|
|
31
|
+
url.searchParams.set('scope', GOOGLE_ADS_SCOPE);
|
|
32
|
+
url.searchParams.set('access_type', 'offline');
|
|
33
|
+
url.searchParams.set('prompt', 'consent');
|
|
34
|
+
if (options.state) {
|
|
35
|
+
url.searchParams.set('state', options.state);
|
|
36
|
+
}
|
|
37
|
+
return url.toString();
|
|
38
|
+
}
|
|
39
|
+
export async function exchangeAuthCode(options, fetchImpl = fetch) {
|
|
40
|
+
const body = new URLSearchParams({
|
|
41
|
+
client_id: options.clientId,
|
|
42
|
+
client_secret: options.clientSecret,
|
|
43
|
+
code: options.authCode,
|
|
44
|
+
grant_type: 'authorization_code',
|
|
45
|
+
redirect_uri: options.redirectUri
|
|
46
|
+
});
|
|
47
|
+
return parseTokenResponse(await fetchImpl(GOOGLE_OAUTH_TOKEN_URL, buildTokenRequest(body)));
|
|
48
|
+
}
|
|
49
|
+
export async function refreshGoogleAdsAccessToken(options, fetchImpl = fetch) {
|
|
50
|
+
const body = new URLSearchParams({
|
|
51
|
+
client_id: options.clientId,
|
|
52
|
+
client_secret: options.clientSecret,
|
|
53
|
+
refresh_token: options.refreshToken,
|
|
54
|
+
grant_type: 'refresh_token'
|
|
55
|
+
});
|
|
56
|
+
return parseTokenResponse(await fetchImpl(GOOGLE_OAUTH_TOKEN_URL, buildTokenRequest(body)));
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface GoogleAdsProfile {
|
|
2
|
+
api_version: string;
|
|
3
|
+
developer_token: string;
|
|
4
|
+
client_id: string;
|
|
5
|
+
client_secret: string;
|
|
6
|
+
refresh_token?: string;
|
|
7
|
+
default_customer_id?: string;
|
|
8
|
+
login_customer_id?: string;
|
|
9
|
+
linked_customer_id?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface GoogleAdsConfig {
|
|
12
|
+
profiles: Record<string, GoogleAdsProfile>;
|
|
13
|
+
}
|
|
14
|
+
export declare function getDefaultGoogleAdsConfigPath(): string;
|
|
15
|
+
export declare function loadGoogleAdsConfig(configPath?: string): Promise<GoogleAdsConfig>;
|
|
16
|
+
export declare function saveGoogleAdsConfig(configPath: string, config: GoogleAdsConfig): Promise<void>;
|
|
17
|
+
export declare function upsertGoogleAdsProfile(configPath: string, profileName: string, profile: GoogleAdsProfile): Promise<GoogleAdsConfig>;
|
|
18
|
+
export declare function getGoogleAdsProfile(configPath: string, profileName: string): Promise<GoogleAdsProfile>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { parse, stringify } from 'yaml';
|
|
5
|
+
export function getDefaultGoogleAdsConfigPath() {
|
|
6
|
+
return join(homedir(), '.config', 'gads', 'config.yaml');
|
|
7
|
+
}
|
|
8
|
+
export async function loadGoogleAdsConfig(configPath = getDefaultGoogleAdsConfigPath()) {
|
|
9
|
+
try {
|
|
10
|
+
const file = await readFile(configPath, 'utf8');
|
|
11
|
+
const parsed = parse(file);
|
|
12
|
+
return {
|
|
13
|
+
profiles: parsed?.profiles ?? {}
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code === 'ENOENT') {
|
|
18
|
+
return { profiles: {} };
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function saveGoogleAdsConfig(configPath, config) {
|
|
24
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
25
|
+
await writeFile(configPath, stringify(config), 'utf8');
|
|
26
|
+
}
|
|
27
|
+
export async function upsertGoogleAdsProfile(configPath, profileName, profile) {
|
|
28
|
+
const config = await loadGoogleAdsConfig(configPath);
|
|
29
|
+
const nextConfig = {
|
|
30
|
+
profiles: {
|
|
31
|
+
...config.profiles,
|
|
32
|
+
[profileName]: profile
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
await saveGoogleAdsConfig(configPath, nextConfig);
|
|
36
|
+
return nextConfig;
|
|
37
|
+
}
|
|
38
|
+
export async function getGoogleAdsProfile(configPath, profileName) {
|
|
39
|
+
const config = await loadGoogleAdsConfig(configPath);
|
|
40
|
+
const profile = config.profiles[profileName];
|
|
41
|
+
if (!profile) {
|
|
42
|
+
throw new Error(`Unknown profile: ${profileName}`);
|
|
43
|
+
}
|
|
44
|
+
return profile;
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildGoogleAdsRequest } from '../src/invoke.js';
|
|
3
|
+
describe('buildGoogleAdsRequest', () => {
|
|
4
|
+
it('interpolates path params and required Google Ads headers', () => {
|
|
5
|
+
const request = buildGoogleAdsRequest({
|
|
6
|
+
accessToken: 'token-123',
|
|
7
|
+
developerToken: 'dev-456',
|
|
8
|
+
linkedCustomerId: '9998887776',
|
|
9
|
+
loginCustomerId: '1234567890',
|
|
10
|
+
operation: {
|
|
11
|
+
httpMethod: 'POST',
|
|
12
|
+
operationId: 'customers.campaigns.mutate',
|
|
13
|
+
path: 'v22/customers/{+customerId}/campaigns:mutate'
|
|
14
|
+
},
|
|
15
|
+
pathParams: {
|
|
16
|
+
customerId: '5554443333'
|
|
17
|
+
},
|
|
18
|
+
payload: {
|
|
19
|
+
mutateOperations: []
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
expect(request).toEqual({
|
|
23
|
+
init: {
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
mutateOperations: []
|
|
26
|
+
}),
|
|
27
|
+
headers: {
|
|
28
|
+
Authorization: 'Bearer token-123',
|
|
29
|
+
'content-type': 'application/json',
|
|
30
|
+
'developer-token': 'dev-456',
|
|
31
|
+
'linked-customer-id': '9998887776',
|
|
32
|
+
'login-customer-id': '1234567890'
|
|
33
|
+
},
|
|
34
|
+
method: 'POST'
|
|
35
|
+
},
|
|
36
|
+
operationId: 'customers.campaigns.mutate',
|
|
37
|
+
url: 'https://googleads.googleapis.com/v22/customers/5554443333/campaigns:mutate'
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildGoogleAdsAuthUrl, exchangeAuthCode, refreshGoogleAdsAccessToken } from '../src/oauth.js';
|
|
3
|
+
describe('buildGoogleAdsAuthUrl', () => {
|
|
4
|
+
it('creates an installed-app OAuth URL for the adwords scope', () => {
|
|
5
|
+
const authUrl = buildGoogleAdsAuthUrl({
|
|
6
|
+
clientId: 'client-id',
|
|
7
|
+
redirectUri: 'http://127.0.0.1:8085/callback',
|
|
8
|
+
state: 'state-123'
|
|
9
|
+
});
|
|
10
|
+
expect(authUrl).toContain('https://accounts.google.com/o/oauth2/v2/auth');
|
|
11
|
+
expect(authUrl).toContain('client_id=client-id');
|
|
12
|
+
expect(authUrl).toContain('scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fadwords');
|
|
13
|
+
expect(authUrl).toContain('access_type=offline');
|
|
14
|
+
expect(authUrl).toContain('prompt=consent');
|
|
15
|
+
expect(authUrl).toContain('state=state-123');
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
describe('oauth token exchanges', () => {
|
|
19
|
+
it('exchanges an auth code for tokens', async () => {
|
|
20
|
+
const result = await exchangeAuthCode({
|
|
21
|
+
authCode: 'code-123',
|
|
22
|
+
clientId: 'client-id',
|
|
23
|
+
clientSecret: 'client-secret',
|
|
24
|
+
redirectUri: 'http://127.0.0.1:8085/callback'
|
|
25
|
+
}, async (url, init) => {
|
|
26
|
+
expect(url).toBe('https://oauth2.googleapis.com/token');
|
|
27
|
+
expect(init?.method).toBe('POST');
|
|
28
|
+
expect(String(init?.body)).toContain('grant_type=authorization_code');
|
|
29
|
+
return new Response(JSON.stringify({
|
|
30
|
+
access_token: 'access-123',
|
|
31
|
+
expires_in: 3600,
|
|
32
|
+
refresh_token: 'refresh-456',
|
|
33
|
+
scope: 'https://www.googleapis.com/auth/adwords',
|
|
34
|
+
token_type: 'Bearer'
|
|
35
|
+
}), { status: 200 });
|
|
36
|
+
});
|
|
37
|
+
expect(result.refresh_token).toBe('refresh-456');
|
|
38
|
+
expect(result.access_token).toBe('access-123');
|
|
39
|
+
});
|
|
40
|
+
it('refreshes an access token from a refresh token', async () => {
|
|
41
|
+
const result = await refreshGoogleAdsAccessToken({
|
|
42
|
+
clientId: 'client-id',
|
|
43
|
+
clientSecret: 'client-secret',
|
|
44
|
+
refreshToken: 'refresh-456'
|
|
45
|
+
}, async (_url, init) => {
|
|
46
|
+
expect(String(init?.body)).toContain('grant_type=refresh_token');
|
|
47
|
+
expect(String(init?.body)).toContain('refresh_token=refresh-456');
|
|
48
|
+
return new Response(JSON.stringify({
|
|
49
|
+
access_token: 'access-789',
|
|
50
|
+
expires_in: 3600,
|
|
51
|
+
scope: 'https://www.googleapis.com/auth/adwords',
|
|
52
|
+
token_type: 'Bearer'
|
|
53
|
+
}), { status: 200 });
|
|
54
|
+
});
|
|
55
|
+
expect(result.access_token).toBe('access-789');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { mkdtemp, readFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { loadGoogleAdsConfig, saveGoogleAdsConfig, upsertGoogleAdsProfile } from '../src/profile.js';
|
|
6
|
+
describe('google ads profile config', () => {
|
|
7
|
+
it('saves and loads yaml config files', async () => {
|
|
8
|
+
const dir = await mkdtemp(join(tmpdir(), 'gads-profile-'));
|
|
9
|
+
const configPath = join(dir, 'config.yaml');
|
|
10
|
+
await saveGoogleAdsConfig(configPath, {
|
|
11
|
+
profiles: {
|
|
12
|
+
default: {
|
|
13
|
+
api_version: 'v22',
|
|
14
|
+
client_id: 'client-id',
|
|
15
|
+
client_secret: 'client-secret',
|
|
16
|
+
developer_token: 'developer-token',
|
|
17
|
+
refresh_token: 'refresh-token'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
expect(await loadGoogleAdsConfig(configPath)).toEqual({
|
|
22
|
+
profiles: {
|
|
23
|
+
default: {
|
|
24
|
+
api_version: 'v22',
|
|
25
|
+
client_id: 'client-id',
|
|
26
|
+
client_secret: 'client-secret',
|
|
27
|
+
developer_token: 'developer-token',
|
|
28
|
+
refresh_token: 'refresh-token'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
expect(await readFile(configPath, 'utf8')).toContain('developer_token');
|
|
33
|
+
});
|
|
34
|
+
it('upserts a named profile without dropping other profiles', async () => {
|
|
35
|
+
const dir = await mkdtemp(join(tmpdir(), 'gads-profile-'));
|
|
36
|
+
const configPath = join(dir, 'config.yaml');
|
|
37
|
+
await saveGoogleAdsConfig(configPath, {
|
|
38
|
+
profiles: {
|
|
39
|
+
keep: {
|
|
40
|
+
api_version: 'v22',
|
|
41
|
+
client_id: 'keep-client',
|
|
42
|
+
client_secret: 'keep-secret',
|
|
43
|
+
developer_token: 'keep-dev'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
await upsertGoogleAdsProfile(configPath, 'default', {
|
|
48
|
+
api_version: 'v22',
|
|
49
|
+
client_id: 'client-id',
|
|
50
|
+
client_secret: 'client-secret',
|
|
51
|
+
developer_token: 'developer-token',
|
|
52
|
+
refresh_token: 'refresh-token'
|
|
53
|
+
});
|
|
54
|
+
expect(await loadGoogleAdsConfig(configPath)).toEqual({
|
|
55
|
+
profiles: {
|
|
56
|
+
keep: {
|
|
57
|
+
api_version: 'v22',
|
|
58
|
+
client_id: 'keep-client',
|
|
59
|
+
client_secret: 'keep-secret',
|
|
60
|
+
developer_token: 'keep-dev'
|
|
61
|
+
},
|
|
62
|
+
default: {
|
|
63
|
+
api_version: 'v22',
|
|
64
|
+
client_id: 'client-id',
|
|
65
|
+
client_secret: 'client-secret',
|
|
66
|
+
developer_token: 'developer-token',
|
|
67
|
+
refresh_token: 'refresh-token'
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "google-ads-cli-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/src/index.js",
|
|
6
|
+
"types": "dist/src/index.d.ts",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"yaml": "^2.8.3"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
19
|
+
}
|
|
20
|
+
}
|