google-apis-cli 0.1.0 → 0.3.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.
Files changed (70) hide show
  1. package/README.md +42 -26
  2. package/dist/bin/gapi.js +11 -135
  3. package/dist/src/apis/ads.js +59 -49
  4. package/dist/src/apis/bid-manager.js +4 -0
  5. package/dist/src/apis/bigquery-dts.js +4 -0
  6. package/dist/src/apis/business-account-management.js +5 -0
  7. package/dist/src/apis/business-information.js +5 -0
  8. package/dist/src/apis/business-lodging.js +5 -0
  9. package/dist/src/apis/business-notifications.js +5 -0
  10. package/dist/src/apis/business-performance.js +5 -0
  11. package/dist/src/apis/business-place-actions.js +5 -0
  12. package/dist/src/apis/business-v4.js +52 -0
  13. package/dist/src/apis/business-verifications.js +5 -0
  14. package/dist/src/apis/cm360.js +4 -0
  15. package/dist/src/apis/crux.js +8 -0
  16. package/dist/src/apis/data-manager.js +4 -0
  17. package/dist/src/apis/dv360.js +4 -0
  18. package/dist/src/apis/ga4-admin-alpha.js +4 -0
  19. package/dist/src/apis/ga4-admin.js +1 -288
  20. package/dist/src/apis/ga4-data-alpha.js +5 -0
  21. package/dist/src/apis/ga4-data.js +9 -69
  22. package/dist/src/apis/gsc.js +6 -58
  23. package/dist/src/apis/gtm.js +1 -189
  24. package/dist/src/apis/indexing.js +37 -15
  25. package/dist/src/apis/merchant-accounts.js +4 -0
  26. package/dist/src/apis/merchant-conversions.js +4 -0
  27. package/dist/src/apis/merchant-datasources.js +4 -0
  28. package/dist/src/apis/merchant-inventories.js +4 -0
  29. package/dist/src/apis/merchant-issueresolution.js +4 -0
  30. package/dist/src/apis/merchant-lfp.js +4 -0
  31. package/dist/src/apis/merchant-loyaltycustomers.js +4 -0
  32. package/dist/src/apis/merchant-notifications.js +4 -0
  33. package/dist/src/apis/merchant-ordertracking.js +4 -0
  34. package/dist/src/apis/merchant-products.js +4 -0
  35. package/dist/src/apis/merchant-promotions.js +4 -0
  36. package/dist/src/apis/merchant-quota.js +4 -0
  37. package/dist/src/apis/merchant-reports.js +5 -0
  38. package/dist/src/apis/merchant-reviews.js +4 -0
  39. package/dist/src/apis/pagespeed.js +8 -0
  40. package/dist/src/apis/places.js +19 -0
  41. package/dist/src/apis/postmaster.js +4 -0
  42. package/dist/src/apis/sa360-legacy.js +4 -0
  43. package/dist/src/apis/sa360.js +13 -0
  44. package/dist/src/apis/sheets.js +4 -0
  45. package/dist/src/apis/site-verification.js +4 -0
  46. package/dist/src/apis/web-risk.js +18 -0
  47. package/dist/src/apis/youtube-analytics.js +4 -0
  48. package/dist/src/apis/youtube-reporting.js +4 -0
  49. package/dist/src/apis/youtube.js +4 -0
  50. package/dist/src/catalog.js +218 -0
  51. package/dist/src/credentials.js +342 -0
  52. package/dist/src/errors.js +16 -0
  53. package/dist/src/json.js +11 -0
  54. package/dist/src/plan.js +348 -0
  55. package/dist/src/render.js +248 -0
  56. package/dist/src/responses.js +37 -0
  57. package/dist/src/run.js +374 -0
  58. package/dist/src/search.js +53 -0
  59. package/dist/src/transport/batch.js +75 -0
  60. package/dist/src/transport/download.js +21 -0
  61. package/dist/src/transport/retry.js +21 -0
  62. package/dist/src/transport/upload.js +106 -0
  63. package/dist/src/transport.js +125 -0
  64. package/dist/src/types.js +1 -13
  65. package/package.json +23 -8
  66. package/dist/src/apis/ga4-admin-v1alpha.js +0 -280
  67. package/dist/src/auth.js +0 -172
  68. package/dist/src/config.js +0 -89
  69. package/dist/src/manifest.js +0 -69
  70. package/dist/src/rest.js +0 -90
@@ -0,0 +1,342 @@
1
+ // Credentials: the only reader and writer of config.json and credentials.json (epic #30 D8, D13).
2
+ // Settings precedence, Bearer vs API key, token refresh + persistence, loopback/manual login.
3
+ import { hash, randomBytes } from 'node:crypto';
4
+ import { chmod, readFile, rm } from 'node:fs/promises';
5
+ import { createServer } from 'node:http';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { createInterface } from 'node:readline/promises';
9
+ import { Writable } from 'node:stream';
10
+ import { CodeChallengeMethod, OAuth2Client } from 'google-auth-library';
11
+ import { shortScope, writeAtomic } from './catalog.js';
12
+ import { codeOf, GapiError, messageOf } from './errors.js';
13
+ import { isRecord } from './json.js';
14
+ const LOOPBACK_HOST = '127.0.0.1';
15
+ const LOOPBACK_PORT = 4600; // fixed port; vmCODE forwards it for headless use
16
+ const REDIRECT_URI = `http://${LOOPBACK_HOST}:${LOOPBACK_PORT}`; // both login flows
17
+ const IDENTITY_SCOPES = ['openid', 'email'];
18
+ const RELOGIN = 'run gapi auth login';
19
+ const NO_KEY = 'API key required; pass --api-key, set GAPI_API_KEY or run gapi auth setup';
20
+ // `||`, not `??`: an empty GAPI_CONFIG_DIR must not resolve secrets into the cwd.
21
+ export const configDir = (env) => env.GAPI_CONFIG_DIR || join(homedir(), '.config', 'gapi');
22
+ const API_KEY_SETTING = { flag: 'api-key', env: 'GAPI_API_KEY', key: 'api_key' };
23
+ // `gapi auth setup` asks the OAuth client, then each entry header, then the API key.
24
+ const CLIENT_FIELDS = [
25
+ { key: 'oauth_client_id', label: 'OAuth client id', required: true, secret: false },
26
+ { key: 'oauth_client_secret', label: 'OAuth client secret', required: true, secret: true },
27
+ ];
28
+ const API_KEY_FIELD = {
29
+ key: 'api_key',
30
+ label: 'API key (optional, CrUX/PageSpeed)',
31
+ required: false,
32
+ secret: true,
33
+ };
34
+ const isCredentials = (v) => isRecord(v) && typeof v.refresh_token === 'string';
35
+ export function openCredentials({ dir, env, oauth = (opts) => new OAuth2Client(opts), warn = () => { }, stdin = process.stdin, stderr = process.stderr, }) {
36
+ const configFile = join(dir, 'config.json');
37
+ const credentialsFile = join(dir, 'credentials.json');
38
+ let apiKey;
39
+ let tokenClient;
40
+ let saving; // the refreshed-token write the 'tokens' event starts
41
+ const ask = (q, opts) => defaultPrompt(q, { ...opts, input: stdin, output: stderr });
42
+ // Only a missing file reads as {}: anything else stops before `setup` can overwrite it.
43
+ // The parse error is not echoed, since V8 quotes the file text (secrets) in it.
44
+ const readConfig = async () => {
45
+ let text;
46
+ try {
47
+ text = await readFile(configFile, 'utf8');
48
+ }
49
+ catch (e) {
50
+ if (codeOf(e) === 'ENOENT')
51
+ return {};
52
+ throw new GapiError(2, `cannot read ${configFile}: ${messageOf(e)}`);
53
+ }
54
+ let cfg;
55
+ try {
56
+ cfg = JSON.parse(text);
57
+ }
58
+ catch {
59
+ throw new GapiError(2, `${configFile} is not valid JSON; fix or remove it`);
60
+ }
61
+ if (!isRecord(cfg))
62
+ throw new GapiError(2, `${configFile} is not a JSON object; fix or remove it`);
63
+ return cfg;
64
+ };
65
+ // Owner-only: file 600 by temp file + rename, so a crash or a concurrent gapi never
66
+ // leaves a broken file. `mkdir` mode applies only on create: narrow an existing dir too.
67
+ const writeSecure = async (file, data) => {
68
+ await writeAtomic(file, JSON.stringify(data, null, 2));
69
+ await chmod(dir, 0o700);
70
+ };
71
+ const readCredentials = async () => {
72
+ let creds;
73
+ try {
74
+ creds = JSON.parse(await readFile(credentialsFile, 'utf8'));
75
+ }
76
+ catch {
77
+ // missing or unreadable reads as logged out, same as a bad shape below
78
+ }
79
+ if (!isCredentials(creds))
80
+ throw new GapiError(2, `not logged in; ${RELOGIN}`);
81
+ return creds;
82
+ };
83
+ const client = async (redirectUri) => {
84
+ const cfg = await readConfig();
85
+ const clientId = env.GAPI_OAUTH_CLIENT_ID || String(cfg.oauth_client_id ?? '');
86
+ const clientSecret = env.GAPI_OAUTH_CLIENT_SECRET || String(cfg.oauth_client_secret ?? '');
87
+ if (!clientId || !clientSecret) {
88
+ throw new GapiError(2, 'OAuth client not configured; run gapi auth setup');
89
+ }
90
+ return oauth({ clientId, clientSecret, redirectUri });
91
+ };
92
+ // Exchange a consent code (with its PKCE verifier) for tokens and persist them.
93
+ const exchange = async (c, code, codeVerifier) => {
94
+ const { tokens } = await c.getToken({ code, codeVerifier }).catch((e) => {
95
+ throw new GapiError(2, `token exchange failed: ${messageOf(e)}`);
96
+ });
97
+ // Google: "refresh tokens are always returned for installed applications".
98
+ if (!tokens.refresh_token)
99
+ throw new GapiError(2, `no refresh token returned; ${RELOGIN}`);
100
+ await writeSecure(credentialsFile, tokens);
101
+ return tokens;
102
+ };
103
+ // Precedence flag → env → config file; an empty env var falls through, as in `configDir`.
104
+ const setting = (flags, cfg, { flag, env: name, key }) => {
105
+ const v = flags[flag] ?? (env[name] || cfg[key]);
106
+ return v == null ? undefined : String(v);
107
+ };
108
+ async function settings(flags, headerSpecs = []) {
109
+ const cfg = await readConfig();
110
+ apiKey = setting(flags, cfg, API_KEY_SETTING);
111
+ const headers = {};
112
+ for (const { name, flag, env: envName, configKey } of headerSpecs) {
113
+ const v = setting(flags, cfg, { flag, env: envName, key: configKey });
114
+ if (v !== undefined)
115
+ headers[name] = v;
116
+ }
117
+ const project = setting(flags, cfg, {
118
+ flag: 'project',
119
+ env: 'GOOGLE_CLOUD_PROJECT',
120
+ key: 'project',
121
+ });
122
+ return { project, headers };
123
+ }
124
+ const authorize = async (auth, { redact = false } = {}) => {
125
+ if (auth.kind === 'apiKey') {
126
+ // D8: a method that lists no scope needs the key; one with only never-set scopes may omit it.
127
+ if (apiKey === undefined) {
128
+ if (auth.scopes.length > 0)
129
+ return {};
130
+ if (!redact)
131
+ throw new GapiError(2, NO_KEY);
132
+ warn(NO_KEY);
133
+ }
134
+ return { 'X-Goog-Api-Key': redact ? '<redacted>' : String(apiKey) };
135
+ }
136
+ if (redact)
137
+ return { Authorization: 'Bearer <redacted>' };
138
+ if (!tokenClient) {
139
+ const c = await client();
140
+ c.setCredentials(await readCredentials());
141
+ // An event listener is not awaited: keep the write so the refresh below can await it.
142
+ // Copy now: google-auth-library puts the old refresh token back on this object.
143
+ c.on('tokens', (t) => {
144
+ const fresh = { ...t };
145
+ saving = readCredentials().then((stored) => writeSecure(credentialsFile, { ...stored, ...fresh }));
146
+ });
147
+ tokenClient = c;
148
+ }
149
+ const { token } = await tokenClient.getAccessToken().catch((e) => {
150
+ const data = isRecord(e) && isRecord(e.response) ? e.response.data : undefined;
151
+ const reason = isRecord(data) ? data.error : undefined;
152
+ if (reason === 'invalid_grant')
153
+ throw new GapiError(2, `token expired or revoked; ${RELOGIN}`);
154
+ throw new GapiError(2, `token refresh failed: ${messageOf(e)}; ${RELOGIN}`);
155
+ });
156
+ const saved = saving;
157
+ saving = undefined;
158
+ await saved?.catch((e) => {
159
+ throw new GapiError(2, `cannot save the refreshed token: ${messageOf(e)}`);
160
+ });
161
+ if (!token)
162
+ throw new GapiError(2, `no access token; ${RELOGIN}`);
163
+ return { Authorization: `Bearer ${token}` };
164
+ };
165
+ // Response cache key part (ADR 0001): one per Google account, never the token itself.
166
+ // No stored token → undefined; entries are stored only after an authorized success.
167
+ async function identity(auth) {
168
+ if (auth.kind === 'apiKey')
169
+ return 'apiKey';
170
+ const token = (await readCredentials().catch(() => undefined))?.refresh_token;
171
+ return token ? hash('sha256', token) : undefined;
172
+ }
173
+ // Both flows send `state` and a PKCE S256 challenge, and redeem one redirect URL.
174
+ async function login({ consent, manual = false, prompt = ask, }) {
175
+ const c = await client(REDIRECT_URI);
176
+ const state = randomBytes(32).toString('base64url');
177
+ const { codeVerifier, codeChallenge } = await c.generateCodeVerifierAsync();
178
+ const url = c.generateAuthUrl({
179
+ access_type: 'offline',
180
+ prompt: 'consent',
181
+ scope: [...consent, ...IDENTITY_SCOPES],
182
+ state,
183
+ code_challenge_method: CodeChallengeMethod.S256,
184
+ code_challenge: codeChallenge,
185
+ });
186
+ // `state` first, so only Google's own redirect can end the login (CSRF).
187
+ const redeem = async (redirect) => {
188
+ const query = redirect.searchParams;
189
+ if (query.get('state') !== state)
190
+ throw new GapiError(2, 'state mismatch');
191
+ const error = query.get('error');
192
+ if (error)
193
+ throw new GapiError(2, `consent failed: ${error}`);
194
+ const code = query.get('code');
195
+ if (!code)
196
+ throw new GapiError(2, 'no code in redirect');
197
+ const tokens = await exchange(c, code, codeVerifier);
198
+ // Google: "Your app must verify which scopes were actually granted".
199
+ const granted = new Set((tokens.scope ?? '').split(' '));
200
+ const missing = consent.filter((s) => !granted.has(s)).map(shortScope);
201
+ if (missing.length > 0)
202
+ warn(`scopes not granted: ${missing.join(', ')}`);
203
+ return tokens;
204
+ };
205
+ if (manual) {
206
+ stderr.write(`Open this URL and grant access:\n${url}\n` +
207
+ `After consent the browser cannot load ${LOOPBACK_HOST}:${LOOPBACK_PORT}; ` +
208
+ 'paste the full URL from its address bar.\n');
209
+ const redirect = URL.parse((await prompt('redirected URL: ')).trim());
210
+ if (!redirect)
211
+ throw new GapiError(2, 'not a URL; paste the full URL from the address bar');
212
+ return redeem(redirect);
213
+ }
214
+ return new Promise((resolve, reject) => {
215
+ const server = createServer(async (req, res) => {
216
+ const redirect = new URL(req.url ?? '/', REDIRECT_URI);
217
+ if (redirect.pathname !== '/') {
218
+ res.statusCode = 404; // e.g. /favicon.ico: keep waiting for the redirect
219
+ res.end();
220
+ return;
221
+ }
222
+ if (redirect.searchParams.get('state') !== state) {
223
+ res.statusCode = 400; // not Google's redirect: keep waiting for it
224
+ res.end('gapi: state mismatch');
225
+ return;
226
+ }
227
+ try {
228
+ const tokens = await redeem(redirect);
229
+ res.end('gapi: authorized. You can close this tab.');
230
+ resolve(tokens);
231
+ }
232
+ catch (e) {
233
+ res.statusCode = 400;
234
+ res.end(`gapi: ${messageOf(e)}`);
235
+ reject(e);
236
+ }
237
+ finally {
238
+ server.close();
239
+ }
240
+ });
241
+ server.on('error', (e) => reject(new GapiError(2, `cannot listen on ${LOOPBACK_HOST}:${LOOPBACK_PORT}: ${e.message}`)));
242
+ server.listen(LOOPBACK_PORT, LOOPBACK_HOST, () => {
243
+ const { address, port } = server.address();
244
+ stderr.write(`Listening on ${address}:${port}\nOpen this URL and grant access:\n${url}\n`);
245
+ });
246
+ });
247
+ }
248
+ async function status(consent) {
249
+ const cfg = await readConfig();
250
+ const creds = await readCredentials();
251
+ const granted = new Set((creds.scope ?? '').split(' ').filter(Boolean));
252
+ return {
253
+ account: creds.id_token ? accountFromIdToken(creds.id_token) : null,
254
+ hasRefreshToken: Boolean(creds.refresh_token),
255
+ hasApiKey: setting({}, cfg, API_KEY_SETTING) !== undefined,
256
+ expiryDate: creds.expiry_date ? new Date(creds.expiry_date).toISOString() : null,
257
+ scopes: [...granted].map(shortScope),
258
+ missing: consent.filter((s) => !granted.has(s)).map(shortScope),
259
+ };
260
+ }
261
+ // Revoke, then delete (the `gcloud auth revoke` pattern, #151). Revoking the refresh
262
+ // token ends the whole grant. Google answers 400 `invalid_token` for a token already
263
+ // invalid or revoked; another 400 error code is a failed request.
264
+ // Any other failure keeps credentials.json, so the logout can be retried.
265
+ // No stored token (missing or unreadable file) leaves nothing to revoke. The revoke
266
+ // endpoint takes only the token, so no OAuth client id or secret is needed.
267
+ async function logout() {
268
+ const token = (await readCredentials().catch(() => undefined))?.refresh_token;
269
+ if (token) {
270
+ await oauth()
271
+ .revokeToken(token)
272
+ .catch((e) => {
273
+ const data = isRecord(e) && isRecord(e.response) ? e.response.data : undefined;
274
+ if (!isRecord(data) || data.error !== 'invalid_token')
275
+ throw new GapiError(1, `token revoke failed, credentials kept: ${messageOf(e)}`);
276
+ warn('refresh token already invalid or revoked');
277
+ });
278
+ }
279
+ await rm(credentialsFile, { force: true });
280
+ }
281
+ // `headers`: every entry's header specs, tagged with the entry's api.
282
+ async function setup(headers, prompt = ask) {
283
+ const cfg = await readConfig();
284
+ const fields = [
285
+ ...CLIENT_FIELDS,
286
+ ...headers.map((h) => ({
287
+ key: h.configKey,
288
+ label: `${h.api} ${h.configKey} (optional)`,
289
+ required: false,
290
+ secret: false,
291
+ })),
292
+ API_KEY_FIELD,
293
+ ];
294
+ for (const { key, label, required, secret } of fields) {
295
+ const cur = cfg[key];
296
+ const hint = cur != null ? ' [keep current]' : required ? '' : ' (blank to skip)';
297
+ const v = (await prompt(`${label}${hint}: `, { secret })).trim();
298
+ if (v)
299
+ cfg[key] = v;
300
+ else if (required && cur == null)
301
+ throw new GapiError(3, `${label} is required`);
302
+ }
303
+ await writeSecure(configFile, cfg);
304
+ return configFile;
305
+ }
306
+ return { settings, authorize, identity, login, status, logout, setup };
307
+ }
308
+ // Decode a JWT payload without verifying (display only; it is our own token).
309
+ function accountFromIdToken(idToken) {
310
+ try {
311
+ const payload = JSON.parse(Buffer.from(idToken.split('.')[1], 'base64url').toString());
312
+ if (!isRecord(payload))
313
+ return null;
314
+ const account = payload.email || payload.sub;
315
+ return typeof account === 'string' ? account : null;
316
+ }
317
+ catch {
318
+ return null;
319
+ }
320
+ }
321
+ // `secret: true` keeps the typed value off the screen. Node has no masking
322
+ // option, so point readline's echo at a sink and print the question ourselves.
323
+ // `terminal: true` is what makes this safe: it puts the tty in raw mode, so the
324
+ // driver stops echoing too and the sink is the only writer left. Streams are
325
+ // injected (Credentials passes the ones `run` got), so the masking has a test.
326
+ export async function defaultPrompt(q, { secret = false, input, output, terminal, }) {
327
+ if (secret)
328
+ output.write(q);
329
+ const rl = createInterface({
330
+ input,
331
+ output: secret ? new Writable({ write: (_c, _e, cb) => cb() }) : output,
332
+ terminal: secret ? true : terminal,
333
+ });
334
+ try {
335
+ return await rl.question(secret ? '' : q);
336
+ }
337
+ finally {
338
+ rl.close();
339
+ if (secret)
340
+ output.write('\n'); // the swallowed Enter never printed one
341
+ }
342
+ }
@@ -0,0 +1,16 @@
1
+ export class GapiError extends Error {
2
+ exit;
3
+ body;
4
+ constructor(exit, message, body) {
5
+ super(message);
6
+ this.name = 'GapiError';
7
+ this.exit = exit;
8
+ this.body = body;
9
+ }
10
+ }
11
+ // A caught value's message: an Error's own text (no `Error: ` prefix), anything else as a string.
12
+ export const messageOf = (e) => (e instanceof Error ? e.message : String(e));
13
+ // A Node system error's `code` (`ENOENT`, `EACCES`, …); undefined for anything else.
14
+ export const codeOf = (e) => e instanceof Error && 'code' in e ? e.code : undefined;
15
+ // Why a file operation failed: the system error code when there is one, else the message.
16
+ export const reasonOf = (e) => String(codeOf(e) ?? messageOf(e));
@@ -0,0 +1,11 @@
1
+ // Guards for parsed JSON: narrow `unknown` with `isRecord` + `typeof`, never a cast.
2
+ export const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
3
+ // JSON text parsed; text that is not JSON (an HTML error page, a proxy reply) stays as is.
4
+ export function parseOrText(text) {
5
+ try {
6
+ return JSON.parse(text);
7
+ }
8
+ catch {
9
+ return text;
10
+ }
11
+ }