google-apis-cli 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.
@@ -0,0 +1,172 @@
1
+ // The only module with real handlers: OAuth login, status, logout, and the
2
+ // tokenProvider the executor injects. One consent grants all 13 scopes; the
3
+ // refresh token is persisted and getAccessToken() rehydrates transparently.
4
+ import { readFileSync, rmSync } from 'node:fs';
5
+ import { createServer } from 'node:http';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { Writable } from 'node:stream';
8
+ import { OAuth2Client } from 'google-auth-library';
9
+ import { CONFIG_PATH, CONSENT_SCOPES, CREDENTIALS_PATH, getOAuthClient, readConfigFile, SCOPES, writeSecureJson, } from './config.js';
10
+ const LOOPBACK_PORT = 4600; // fixed port; vmCODE forwards it for headless use
11
+ function newClient(redirectUri) {
12
+ const { clientId, clientSecret } = getOAuthClient();
13
+ if (!clientId || !clientSecret) {
14
+ throw new Error('OAuth client not configured. Set GAPI_OAUTH_CLIENT_ID and GAPI_OAUTH_CLIENT_SECRET.');
15
+ }
16
+ return new OAuth2Client({ clientId, clientSecret, redirectUri });
17
+ }
18
+ function saveCredentials(tokens) {
19
+ writeSecureJson(CREDENTIALS_PATH, tokens);
20
+ }
21
+ export function loadCredentials() {
22
+ return JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
23
+ }
24
+ const AUTH_OPTS = { access_type: 'offline', prompt: 'consent', scope: CONSENT_SCOPES };
25
+ // Decode a JWT payload without verifying (display only; it is our own token).
26
+ function accountFromIdToken(idToken) {
27
+ try {
28
+ const payload = JSON.parse(Buffer.from(idToken.split('.')[1], 'base64url').toString());
29
+ return payload.email || payload.sub || null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ // The single-consent authorization URL for a client (offline + all scopes).
36
+ export function authUrl(client) {
37
+ return client.generateAuthUrl(AUTH_OPTS);
38
+ }
39
+ // --manual: print the consent URL, read the pasted code, exchange it.
40
+ export async function loginManual({ prompt, } = {}) {
41
+ const client = newClient('urn:ietf:wg:oauth:2.0:oob');
42
+ const url = authUrl(client);
43
+ console.error(`Open this URL, grant access, then paste the code:\n${url}\n`);
44
+ const ask = prompt || defaultPrompt;
45
+ const code = (await ask('code: ')).trim();
46
+ const { tokens } = await client.getToken(code);
47
+ saveCredentials(tokens);
48
+ return tokens;
49
+ }
50
+ // Guided setup: prompt the OAuth Desktop client id/secret and the optional Ads
51
+ // login-customer-id (MCC), then merge into ~/.config/gapi/config.json (mode
52
+ // 600). A blank answer keeps the current stored value. `prompt` is injectable
53
+ // for tests. The secret field is masked at the prompt and goes straight to the
54
+ // file. config.ts reads these back with precedence env → config → embedded.
55
+ const SETUP_FIELDS = [
56
+ { key: 'oauth_client_id', label: 'OAuth client id', required: true, secret: false },
57
+ { key: 'oauth_client_secret', label: 'OAuth client secret', required: true, secret: true },
58
+ {
59
+ key: 'login-customer-id',
60
+ label: 'Ads login-customer-id (optional, MCC)',
61
+ required: false,
62
+ secret: false,
63
+ },
64
+ ];
65
+ export async function configureAuth({ prompt, } = {}) {
66
+ const ask = prompt || defaultPrompt;
67
+ const cfg = readConfigFile();
68
+ for (const { key, label, required, secret } of SETUP_FIELDS) {
69
+ const cur = cfg[key];
70
+ const hint = cur != null ? ' [keep current]' : required ? '' : ' (blank to skip)';
71
+ const v = (await ask(`${label}${hint}: `, { secret })).trim();
72
+ if (v)
73
+ cfg[key] = v;
74
+ else if (required && cur == null)
75
+ throw new Error(`${label} is required`);
76
+ }
77
+ writeSecureJson(CONFIG_PATH, cfg);
78
+ return cfg;
79
+ }
80
+ // `secret: true` keeps the typed value off the screen. Node has no masking
81
+ // option, so point readline's echo at a sink and print the question ourselves.
82
+ // `terminal: true` is what makes this safe: it puts the tty in raw mode, so the
83
+ // driver stops echoing too and the sink is the only writer left. Streams are
84
+ // injectable so the masking has a test.
85
+ export async function defaultPrompt(q, { secret = false, input = process.stdin, output = process.stderr, terminal, } = {}) {
86
+ if (secret)
87
+ output.write(q);
88
+ const rl = createInterface({
89
+ input,
90
+ output: secret ? new Writable({ write: (_c, _e, cb) => cb() }) : output,
91
+ terminal: secret ? true : terminal,
92
+ });
93
+ try {
94
+ return await rl.question(secret ? '' : q);
95
+ }
96
+ finally {
97
+ rl.close();
98
+ if (secret)
99
+ output.write('\n'); // the swallowed Enter never printed one
100
+ }
101
+ }
102
+ // Loopback: run a local server on LOOPBACK_PORT, open the consent URL, capture
103
+ // the redirect's ?code=, exchange it, shut down. `handleRedirect` is exported
104
+ // for tests (simulate GET /?code=... without a real browser).
105
+ export async function loginLoopback({ openUrl, } = {}) {
106
+ const redirectUri = `http://127.0.0.1:${LOOPBACK_PORT}`;
107
+ const client = newClient(redirectUri);
108
+ const url = authUrl(client);
109
+ return new Promise((resolve, reject) => {
110
+ const server = createServer(async (req, res) => {
111
+ try {
112
+ const tokens = await handleRedirect(client, req.url || '', redirectUri);
113
+ res.end('gapi: authorized. You can close this tab.');
114
+ server.close();
115
+ resolve(tokens);
116
+ }
117
+ catch (e) {
118
+ res.statusCode = 400;
119
+ res.end(`gapi: ${e instanceof Error ? e.message : String(e)}`);
120
+ server.close();
121
+ reject(e);
122
+ }
123
+ });
124
+ server.listen(LOOPBACK_PORT, () => {
125
+ console.error(`Waiting for consent at ${redirectUri}\nOpen:\n${url}`);
126
+ (openUrl || (() => { }))(url);
127
+ });
128
+ });
129
+ }
130
+ // Extract ?code=, exchange for tokens, persist. Shared by loopback + tests.
131
+ export async function handleRedirect(client, reqUrl, base) {
132
+ const code = new URL(reqUrl, base).searchParams.get('code');
133
+ if (!code)
134
+ throw new Error('no code in redirect');
135
+ const { tokens } = await client.getToken(code);
136
+ saveCredentials(tokens);
137
+ return tokens;
138
+ }
139
+ // tokenProvider injected into the executor: rehydrate the refresh token and
140
+ // let google-auth-library refresh the access token before each call.
141
+ export function makeTokenProvider({ client: injected, } = {}) {
142
+ let client = injected;
143
+ return async () => {
144
+ if (!client) {
145
+ const c = newClient();
146
+ c.setCredentials(loadCredentials());
147
+ c.on('tokens', (t) => {
148
+ const cur = loadCredentials();
149
+ saveCredentials({ ...cur, ...t });
150
+ });
151
+ client = c;
152
+ }
153
+ const { token } = await client.getAccessToken();
154
+ if (!token)
155
+ throw new Error('no access token; run `gapi auth login`');
156
+ return token;
157
+ };
158
+ }
159
+ export function status() {
160
+ const creds = loadCredentials();
161
+ const granted = (creds.scope || '').split(' ').filter(Boolean);
162
+ const byUrl = Object.fromEntries(Object.entries(SCOPES).map(([k, v]) => [v, k]));
163
+ return {
164
+ account: creds.id_token ? accountFromIdToken(creds.id_token) : null,
165
+ hasRefreshToken: Boolean(creds.refresh_token),
166
+ expiryDate: creds.expiry_date ? new Date(creds.expiry_date).toISOString() : null,
167
+ scopes: granted.map((u) => byUrl[u] || u),
168
+ };
169
+ }
170
+ export function logout() {
171
+ rmSync(CREDENTIALS_PATH, { force: true });
172
+ }
@@ -0,0 +1,89 @@
1
+ // Static config: OAuth client, the 13 scopes, and file paths.
2
+ // Client id/secret follow the public-CLI pattern (gcloud/gh/firebase-tools):
3
+ // embedded, overridable by env for the operator's own Desktop OAuth client.
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ // Embedded Desktop-client credentials (gcloud/gh/firebase-tools pattern),
8
+ // overridable. Precedence: env → ~/.config/gapi/config.json (via `gapi auth
9
+ // setup`) → embedded. Filled once the operator registers the OAuth Desktop
10
+ // client (open item: credential not yet provisioned).
11
+ const EMBEDDED_CLIENT_ID = '';
12
+ const EMBEDDED_CLIENT_SECRET = '';
13
+ // Resolved lazily (not at import) so it reads the config file after CONFIG_PATH
14
+ // is initialized, and picks up a fresh `gapi auth setup` in the same process.
15
+ export function getOAuthClient() {
16
+ const cfg = readConfigFile();
17
+ return {
18
+ clientId: process.env.GAPI_OAUTH_CLIENT_ID || String(cfg.oauth_client_id ?? '') || EMBEDDED_CLIENT_ID,
19
+ clientSecret: process.env.GAPI_OAUTH_CLIENT_SECRET ||
20
+ String(cfg.oauth_client_secret ?? '') ||
21
+ EMBEDDED_CLIENT_SECRET,
22
+ };
23
+ }
24
+ // Short name -> full scope URL. Manifest rows reference the short names;
25
+ // the schema self-check asserts every row scope is a key here.
26
+ const A = 'https://www.googleapis.com/auth/';
27
+ export const SCOPES = {
28
+ adwords: `${A}adwords`,
29
+ 'tagmanager.readonly': `${A}tagmanager.readonly`,
30
+ 'tagmanager.edit.containers': `${A}tagmanager.edit.containers`,
31
+ 'tagmanager.edit.containerversions': `${A}tagmanager.edit.containerversions`,
32
+ 'tagmanager.publish': `${A}tagmanager.publish`,
33
+ 'tagmanager.delete.containers': `${A}tagmanager.delete.containers`,
34
+ 'tagmanager.manage.users': `${A}tagmanager.manage.users`,
35
+ 'tagmanager.manage.accounts': `${A}tagmanager.manage.accounts`,
36
+ 'analytics.readonly': `${A}analytics.readonly`,
37
+ 'analytics.edit': `${A}analytics.edit`,
38
+ 'analytics.manage.users': `${A}analytics.manage.users`,
39
+ webmasters: `${A}webmasters`,
40
+ indexing: `${A}indexing`,
41
+ };
42
+ export const ALL_SCOPE_URLS = Object.values(SCOPES);
43
+ // Identity scopes (non-sensitive) so `auth status` can show the authenticated
44
+ // account: the id_token then carries the email. Separate from the 13 API
45
+ // scopes, which stay the coverage/self-check set.
46
+ export const IDENTITY_SCOPES = ['openid', 'email'];
47
+ export const CONSENT_SCOPES = [...ALL_SCOPE_URLS, ...IDENTITY_SCOPES];
48
+ export const CONFIG_DIR = join(homedir(), '.config', 'gapi');
49
+ export const CREDENTIALS_PATH = join(CONFIG_DIR, 'credentials.json');
50
+ export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
51
+ // Read ~/.config/gapi/config.json (OAuth client id/secret, Ads login-customer-id,
52
+ // quota project).
53
+ export function readConfigFile() {
54
+ try {
55
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
56
+ }
57
+ catch {
58
+ return {};
59
+ }
60
+ }
61
+ // Write JSON under CONFIG_DIR readable only by the owner (dir 700, file 600).
62
+ // Shared by the two things we persist: the config file and the credentials.
63
+ export function writeSecureJson(path, data) {
64
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
65
+ writeFileSync(path, JSON.stringify(data, null, 2), { mode: 0o600 });
66
+ }
67
+ // Precedence flag -> env -> config file. `flags` is the parsed argv values.
68
+ // `cfg` defaults to the config file; injectable so precedence is unit-testable
69
+ // without writing to ~/.config.
70
+ export function resolveSetting(flags, { flag, env, key }, cfg = readConfigFile()) {
71
+ return flags?.[flag] ?? (env && process.env[env]) ?? cfg?.[key];
72
+ }
73
+ export const QUOTA_PROJECT_ENV = 'GOOGLE_CLOUD_PROJECT';
74
+ // Google Ads sends login-customer-id (optional, MCC callers only) as an HTTP
75
+ // header, not an OAuth scope, resolved flag -> env -> config file (header name
76
+ // == flag == config key). developer-token was sunset 2026-09-09 (ignored by the
77
+ // API; access is governed by the Cloud project), so it is not sent.
78
+ const ADS_HEADERS = [
79
+ { name: 'login-customer-id', env: 'GOOGLE_ADS_LOGIN_CUSTOMER_ID' },
80
+ ];
81
+ export function resolveAdsHeaders(flags, cfg = readConfigFile()) {
82
+ const out = {};
83
+ for (const { name, env } of ADS_HEADERS) {
84
+ const v = resolveSetting(flags, { flag: name, env, key: name }, cfg);
85
+ if (v != null)
86
+ out[name] = String(v);
87
+ }
88
+ return out;
89
+ }
@@ -0,0 +1,69 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // Auto-discovery of API manifests. No central registry: a glob of
10
+ // src/apis/*.js finds every surface, so a new API slice never edits a
11
+ // shared file. Each manifest file default-exports:
12
+ // { api, baseUrl, resources: { <resource>: { <verb>: row } } }
13
+ // row = { httpMethod, pathTemplate, scopes, requiredHeaders?, decoder?, listKey?, query? }
14
+ import { readdirSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { SCOPES } from './config.js';
18
+ const APIS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'apis');
19
+ export async function loadManifests(dir = APIS_DIR) {
20
+ // Match dev (.ts) and published (.js); skip declaration files.
21
+ const files = readdirSync(dir).filter((f) => (f.endsWith('.ts') || f.endsWith('.js')) && !f.endsWith('.d.ts'));
22
+ const manifests = {};
23
+ for (const f of files) {
24
+ const mod = await import(__rewriteRelativeImportExtension(join(dir, f)));
25
+ const m = mod.default;
26
+ manifests[m.api] = m;
27
+ }
28
+ return manifests;
29
+ }
30
+ // Flatten one manifest row and inject baseUrl so the executor is self-contained.
31
+ export function findRow(manifests, api, resource, verb) {
32
+ const m = manifests[api];
33
+ const row = m?.resources?.[resource]?.[verb];
34
+ if (!row)
35
+ return null;
36
+ return { ...row, baseUrl: m.baseUrl };
37
+ }
38
+ const REQUIRED = ['httpMethod', 'pathTemplate', 'scopes'];
39
+ // Schema self-check. Throws with all problems found. Substitutes N handler tests.
40
+ export function checkSchema(manifests) {
41
+ const errors = [];
42
+ const known = new Set(Object.keys(SCOPES));
43
+ for (const [api, m] of Object.entries(manifests)) {
44
+ if (!m.baseUrl)
45
+ errors.push(`${api}: missing baseUrl`);
46
+ for (const [resource, verbs] of Object.entries(m.resources || {})) {
47
+ for (const [verb, row] of Object.entries(verbs)) {
48
+ const at = `${api}.${resource}.${verb}`;
49
+ for (const field of REQUIRED) {
50
+ if (row[field] == null)
51
+ errors.push(`${at}: missing ${field}`);
52
+ }
53
+ // pathTemplate placeholders must be simple {name} tokens (params supply them).
54
+ if (typeof row.pathTemplate === 'string') {
55
+ const bad = row.pathTemplate.match(/\{[^}]*[^\w}][^}]*\}/g);
56
+ if (bad)
57
+ errors.push(`${at}: bad path placeholder ${bad.join(',')}`);
58
+ }
59
+ for (const s of row.scopes || []) {
60
+ if (!known.has(s))
61
+ errors.push(`${at}: unknown scope "${s}"`);
62
+ }
63
+ }
64
+ }
65
+ }
66
+ if (errors.length)
67
+ throw new Error(`manifest schema errors:\n${errors.join('\n')}`);
68
+ return true;
69
+ }
@@ -0,0 +1,90 @@
1
+ import { RestError } from './types.js';
2
+ const RESERVED = new Set(['json', 'raw', 'limit', 'body']);
3
+ // The {name} placeholders a pathTemplate declares. Shared with the dispatcher
4
+ // so path params are extracted in exactly one place.
5
+ export function pathParams(pathTemplate) {
6
+ return [...pathTemplate.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
7
+ }
8
+ // Fill {placeholders} in the path from params; return the used keys too.
9
+ export function resolvePath(pathTemplate, params) {
10
+ const used = new Set();
11
+ const path = pathTemplate.replace(/\{(\w+)\}/g, (_, name) => {
12
+ if (params[name] == null)
13
+ throw new Error(`missing path param: ${name}`);
14
+ used.add(name);
15
+ return encodeURIComponent(String(params[name]));
16
+ });
17
+ return { path, used };
18
+ }
19
+ function buildUrl(baseUrl, pathTemplate, params) {
20
+ const { path, used } = resolvePath(pathTemplate, params);
21
+ const url = new URL(baseUrl + path);
22
+ // Leftover non-reserved params become query string, for every method: writes
23
+ // carry query params too (e.g. PATCH `updateMask`, list `pageSize`). The body
24
+ // is separate — RESERVED excludes it.
25
+ for (const [k, v] of Object.entries(params)) {
26
+ if (used.has(k) || RESERVED.has(k) || v == null)
27
+ continue;
28
+ url.searchParams.set(k, String(v));
29
+ }
30
+ return url;
31
+ }
32
+ // Merge a page's list array into acc under listKey. Returns the merged list.
33
+ function mergeList(acc, page, listKey) {
34
+ const items = page?.[listKey];
35
+ if (Array.isArray(items))
36
+ return acc.concat(items);
37
+ return acc;
38
+ }
39
+ export async function execute(row, params, opts) {
40
+ const { tokenProvider, fetch: fetchImpl, quotaProject, extraHeaders = {} } = opts;
41
+ const method = row.httpMethod;
42
+ const headers = { Authorization: `Bearer ${await tokenProvider()}` };
43
+ if (quotaProject)
44
+ headers['x-goog-user-project'] = quotaProject;
45
+ for (const name of row.requiredHeaders || []) {
46
+ if (extraHeaders[name] == null)
47
+ throw new Error(`missing required header: ${name}`);
48
+ }
49
+ Object.assign(headers, extraHeaders);
50
+ const hasBody = method !== 'GET' && params.body != null;
51
+ if (hasBody)
52
+ headers['Content-Type'] = 'application/json';
53
+ const limit = params.limit != null ? Number(params.limit) : undefined;
54
+ let url = buildUrl(row.baseUrl, row.pathTemplate, params);
55
+ let last;
56
+ let merged = [];
57
+ let paged = false;
58
+ while (true) {
59
+ const res = await fetchImpl(url.toString(), {
60
+ method,
61
+ headers,
62
+ body: hasBody ? JSON.stringify(params.body) : undefined,
63
+ });
64
+ const text = await res.text();
65
+ const data = text ? JSON.parse(text) : {};
66
+ if (!res.ok) {
67
+ throw new RestError(data?.error?.message || `HTTP ${res.status}`, res.status, data);
68
+ }
69
+ last = data;
70
+ if (row.listKey) {
71
+ merged = mergeList(merged, data, row.listKey);
72
+ paged = true;
73
+ if (limit != null && merged.length >= limit) {
74
+ merged = merged.slice(0, limit);
75
+ break;
76
+ }
77
+ const next = data.nextPageToken;
78
+ if (!next)
79
+ break;
80
+ url = buildUrl(row.baseUrl, row.pathTemplate, { ...params, pageToken: next });
81
+ continue; // yagni: sequential paging; parallel prefetch if a surface ever needs it
82
+ }
83
+ break;
84
+ }
85
+ const raw = last;
86
+ let payload = paged ? { [row.listKey]: merged } : last;
87
+ if (row.decoder)
88
+ payload = row.decoder(paged ? merged : last, raw);
89
+ return { json: payload, raw };
90
+ }
@@ -0,0 +1,13 @@
1
+ // Shared types for the two seams: the declarative manifest row and the
2
+ // executor's injected dependencies. Kept in one file so an API slice only
3
+ // imports types, never redefines the contract.
4
+ // Carries the Google REST error status + body up to the CLI's catch.
5
+ export class RestError extends Error {
6
+ status;
7
+ body;
8
+ constructor(message, status, body) {
9
+ super(message);
10
+ this.status = status;
11
+ this.body = body;
12
+ }
13
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "google-apis-cli",
3
+ "version": "0.1.0",
4
+ "description": "One CLI (gapi) for Google Ads, GTM, GA4 (Admin+Data), Search Console, and Indexing. One login, all scopes, read+write.",
5
+ "type": "module",
6
+ "bin": {
7
+ "gapi": "dist/bin/gapi.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=24"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test",
14
+ "typecheck": "tsc --noEmit",
15
+ "lint": "biome check",
16
+ "format": "biome check --write",
17
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
18
+ "prepublishOnly": "pnpm typecheck && pnpm lint && pnpm test && pnpm build"
19
+ },
20
+ "files": [
21
+ "dist/"
22
+ ],
23
+ "keywords": [
24
+ "google",
25
+ "google-ads",
26
+ "gtm",
27
+ "tag-manager",
28
+ "ga4",
29
+ "analytics",
30
+ "search-console",
31
+ "indexing",
32
+ "cli"
33
+ ],
34
+ "homepage": "https://github.com/realroboto/google-apis-cli#readme",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/realroboto/google-apis-cli.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/realroboto/google-apis-cli/issues"
41
+ },
42
+ "dependencies": {
43
+ "google-auth-library": "^11.0.2"
44
+ },
45
+ "license": "MIT",
46
+ "devDependencies": {
47
+ "@biomejs/biome": "^2.5.13",
48
+ "@types/node": "^24.13.4",
49
+ "typescript": "^7.0.2"
50
+ },
51
+ "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
52
+ }