google-apis-cli 0.1.0 → 0.2.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 (67) hide show
  1. package/README.md +39 -26
  2. package/dist/bin/gapi.js +6 -137
  3. package/dist/src/apis/ads.js +51 -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 +4 -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 +4 -0
  21. package/dist/src/apis/ga4-data.js +1 -69
  22. package/dist/src/apis/gsc.js +5 -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 +4 -0
  38. package/dist/src/apis/merchant-reviews.js +4 -0
  39. package/dist/src/apis/pagespeed.js +4 -0
  40. package/dist/src/apis/places.js +15 -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 +12 -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 +14 -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 +203 -0
  51. package/dist/src/credentials.js +316 -0
  52. package/dist/src/errors.js +16 -0
  53. package/dist/src/json.js +11 -0
  54. package/dist/src/plan.js +315 -0
  55. package/dist/src/render.js +183 -0
  56. package/dist/src/run.js +263 -0
  57. package/dist/src/transport/batch.js +75 -0
  58. package/dist/src/transport/download.js +15 -0
  59. package/dist/src/transport/upload.js +81 -0
  60. package/dist/src/transport.js +99 -0
  61. package/dist/src/types.js +1 -13
  62. package/package.json +22 -7
  63. package/dist/src/apis/ga4-admin-v1alpha.js +0 -280
  64. package/dist/src/auth.js +0 -172
  65. package/dist/src/config.js +0 -89
  66. package/dist/src/manifest.js +0 -69
  67. package/dist/src/rest.js +0 -90
@@ -0,0 +1,316 @@
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 { randomBytes } from 'node:crypto';
4
+ import { chmod, mkdir, readFile, rm, writeFile } 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 } 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: dir 700, file 600. `mode` applies only on create, so a file that
66
+ // already exists with looser bits is narrowed too.
67
+ const writeSecure = async (file, data) => {
68
+ await mkdir(dir, { recursive: true, mode: 0o700 });
69
+ await writeFile(file, JSON.stringify(data, null, 2), { mode: 0o600 });
70
+ await chmod(file, 0o600);
71
+ };
72
+ const readCredentials = async () => {
73
+ let creds;
74
+ try {
75
+ creds = JSON.parse(await readFile(credentialsFile, 'utf8'));
76
+ }
77
+ catch {
78
+ // missing or unreadable reads as logged out, same as a bad shape below
79
+ }
80
+ if (!isCredentials(creds))
81
+ throw new GapiError(2, `not logged in; ${RELOGIN}`);
82
+ return creds;
83
+ };
84
+ const client = async (redirectUri) => {
85
+ const cfg = await readConfig();
86
+ const clientId = env.GAPI_OAUTH_CLIENT_ID || String(cfg.oauth_client_id ?? '');
87
+ const clientSecret = env.GAPI_OAUTH_CLIENT_SECRET || String(cfg.oauth_client_secret ?? '');
88
+ if (!clientId || !clientSecret) {
89
+ throw new GapiError(2, 'OAuth client not configured; run gapi auth setup');
90
+ }
91
+ return oauth({ clientId, clientSecret, redirectUri });
92
+ };
93
+ // Exchange a consent code (with its PKCE verifier) for tokens and persist them.
94
+ const exchange = async (c, code, codeVerifier) => {
95
+ const { tokens } = await c.getToken({ code, codeVerifier }).catch((e) => {
96
+ throw new GapiError(2, `token exchange failed: ${messageOf(e)}`);
97
+ });
98
+ await writeSecure(credentialsFile, tokens);
99
+ return tokens;
100
+ };
101
+ // Precedence flag → env → config file; an empty env var falls through, as in `configDir`.
102
+ const setting = (flags, cfg, { flag, env: name, key }) => {
103
+ const v = flags[flag] ?? (env[name] || cfg[key]);
104
+ return v == null ? undefined : String(v);
105
+ };
106
+ async function settings(flags, headerSpecs = []) {
107
+ const cfg = await readConfig();
108
+ apiKey = setting(flags, cfg, API_KEY_SETTING);
109
+ const headers = {};
110
+ for (const { name, flag, env: envName, configKey } of headerSpecs) {
111
+ const v = setting(flags, cfg, { flag, env: envName, key: configKey });
112
+ if (v !== undefined)
113
+ headers[name] = v;
114
+ }
115
+ const project = setting(flags, cfg, {
116
+ flag: 'project',
117
+ env: 'GOOGLE_CLOUD_PROJECT',
118
+ key: 'project',
119
+ });
120
+ return { project, headers };
121
+ }
122
+ const authorize = async (auth, { redact = false } = {}) => {
123
+ if (auth.kind === 'apiKey') {
124
+ // D8: a method that lists no scope needs the key; one with only never-set scopes may omit it.
125
+ if (apiKey === undefined) {
126
+ if (auth.scopes.length > 0)
127
+ return {};
128
+ if (!redact)
129
+ throw new GapiError(2, NO_KEY);
130
+ warn(NO_KEY);
131
+ }
132
+ return { 'X-Goog-Api-Key': redact ? '<redacted>' : String(apiKey) };
133
+ }
134
+ if (redact)
135
+ return { Authorization: 'Bearer <redacted>' };
136
+ if (!tokenClient) {
137
+ const c = await client();
138
+ c.setCredentials(await readCredentials());
139
+ // An event listener is not awaited: keep the write so the refresh below can await it.
140
+ c.on('tokens', (t) => {
141
+ saving = readCredentials().then((stored) => writeSecure(credentialsFile, { ...stored, ...t }));
142
+ });
143
+ tokenClient = c;
144
+ }
145
+ const { token } = await tokenClient.getAccessToken().catch((e) => {
146
+ const data = isRecord(e) && isRecord(e.response) ? e.response.data : undefined;
147
+ const reason = isRecord(data) ? data.error : undefined;
148
+ if (reason === 'invalid_grant')
149
+ throw new GapiError(2, `token expired or revoked; ${RELOGIN}`);
150
+ throw new GapiError(2, `token refresh failed: ${messageOf(e)}; ${RELOGIN}`);
151
+ });
152
+ const saved = saving;
153
+ saving = undefined;
154
+ await saved?.catch((e) => {
155
+ throw new GapiError(2, `cannot save the refreshed token: ${messageOf(e)}`);
156
+ });
157
+ if (!token)
158
+ throw new GapiError(2, `no access token; ${RELOGIN}`);
159
+ return { Authorization: `Bearer ${token}` };
160
+ };
161
+ // Both flows send `state` and a PKCE S256 challenge, and redeem one redirect URL.
162
+ async function login({ consent, manual = false, prompt = ask, }) {
163
+ const c = await client(REDIRECT_URI);
164
+ const state = randomBytes(32).toString('base64url');
165
+ const { codeVerifier, codeChallenge } = await c.generateCodeVerifierAsync();
166
+ const url = c.generateAuthUrl({
167
+ access_type: 'offline',
168
+ prompt: 'consent',
169
+ scope: [...consent, ...IDENTITY_SCOPES],
170
+ state,
171
+ code_challenge_method: CodeChallengeMethod.S256,
172
+ code_challenge: codeChallenge,
173
+ });
174
+ const redeem = (redirect) => {
175
+ const query = redirect.searchParams;
176
+ const error = query.get('error');
177
+ if (error)
178
+ throw new GapiError(2, `consent failed: ${error}`);
179
+ if (query.get('state') !== state)
180
+ throw new GapiError(2, 'state mismatch');
181
+ const code = query.get('code');
182
+ if (!code)
183
+ throw new GapiError(2, 'no code in redirect');
184
+ return exchange(c, code, codeVerifier);
185
+ };
186
+ if (manual) {
187
+ stderr.write(`Open this URL and grant access:\n${url}\n` +
188
+ `After consent the browser cannot load ${LOOPBACK_HOST}:${LOOPBACK_PORT}; ` +
189
+ 'paste the full URL from its address bar.\n');
190
+ const redirect = URL.parse((await prompt('redirected URL: ')).trim());
191
+ if (!redirect)
192
+ throw new GapiError(2, 'not a URL; paste the full URL from the address bar');
193
+ return redeem(redirect);
194
+ }
195
+ return new Promise((resolve, reject) => {
196
+ const server = createServer(async (req, res) => {
197
+ const redirect = new URL(req.url ?? '/', REDIRECT_URI);
198
+ if (redirect.pathname !== '/') {
199
+ res.statusCode = 404; // e.g. /favicon.ico: keep waiting for the redirect
200
+ res.end();
201
+ return;
202
+ }
203
+ try {
204
+ const tokens = await redeem(redirect);
205
+ res.end('gapi: authorized. You can close this tab.');
206
+ resolve(tokens);
207
+ }
208
+ catch (e) {
209
+ res.statusCode = 400;
210
+ res.end(`gapi: ${messageOf(e)}`);
211
+ reject(e);
212
+ }
213
+ finally {
214
+ server.close();
215
+ }
216
+ });
217
+ server.on('error', (e) => reject(new GapiError(2, `cannot listen on ${LOOPBACK_HOST}:${LOOPBACK_PORT}: ${e.message}`)));
218
+ server.listen(LOOPBACK_PORT, LOOPBACK_HOST, () => {
219
+ const { address, port } = server.address();
220
+ stderr.write(`Listening on ${address}:${port}\nOpen this URL and grant access:\n${url}\n`);
221
+ });
222
+ });
223
+ }
224
+ async function status(consent) {
225
+ const cfg = await readConfig();
226
+ const creds = await readCredentials();
227
+ const granted = new Set((creds.scope ?? '').split(' ').filter(Boolean));
228
+ return {
229
+ account: creds.id_token ? accountFromIdToken(creds.id_token) : null,
230
+ hasRefreshToken: Boolean(creds.refresh_token),
231
+ hasApiKey: setting({}, cfg, API_KEY_SETTING) !== undefined,
232
+ expiryDate: creds.expiry_date ? new Date(creds.expiry_date).toISOString() : null,
233
+ scopes: [...granted].map(shortScope),
234
+ missing: consent.filter((s) => !granted.has(s)).map(shortScope),
235
+ };
236
+ }
237
+ // Revoke, then delete (the `gcloud auth revoke` pattern, #151). Revoking the refresh
238
+ // token ends the whole grant. Google answers 400 for a token already invalid or revoked.
239
+ // Any other failure keeps credentials.json, so the logout can be retried.
240
+ // No stored token (missing or unreadable file) leaves nothing to revoke. The revoke
241
+ // endpoint takes only the token, so no OAuth client id or secret is needed.
242
+ async function logout() {
243
+ const token = (await readCredentials().catch(() => undefined))?.refresh_token;
244
+ if (token) {
245
+ await oauth()
246
+ .revokeToken(token)
247
+ .catch((e) => {
248
+ if (!isRecord(e) || e.status !== 400)
249
+ throw new GapiError(1, `token revoke failed, credentials kept: ${messageOf(e)}`);
250
+ warn('refresh token already invalid or revoked');
251
+ });
252
+ }
253
+ await rm(credentialsFile, { force: true });
254
+ }
255
+ // `headers`: every entry's header specs, tagged with the entry's api.
256
+ async function setup(headers, prompt = ask) {
257
+ const cfg = await readConfig();
258
+ const fields = [
259
+ ...CLIENT_FIELDS,
260
+ ...headers.map((h) => ({
261
+ key: h.configKey,
262
+ label: `${h.api} ${h.configKey} (optional)`,
263
+ required: false,
264
+ secret: false,
265
+ })),
266
+ API_KEY_FIELD,
267
+ ];
268
+ for (const { key, label, required, secret } of fields) {
269
+ const cur = cfg[key];
270
+ const hint = cur != null ? ' [keep current]' : required ? '' : ' (blank to skip)';
271
+ const v = (await prompt(`${label}${hint}: `, { secret })).trim();
272
+ if (v)
273
+ cfg[key] = v;
274
+ else if (required && cur == null)
275
+ throw new GapiError(3, `${label} is required`);
276
+ }
277
+ await writeSecure(configFile, cfg);
278
+ return configFile;
279
+ }
280
+ return { settings, authorize, login, status, logout, setup };
281
+ }
282
+ // Decode a JWT payload without verifying (display only; it is our own token).
283
+ function accountFromIdToken(idToken) {
284
+ try {
285
+ const payload = JSON.parse(Buffer.from(idToken.split('.')[1], 'base64url').toString());
286
+ if (!isRecord(payload))
287
+ return null;
288
+ const account = payload.email || payload.sub;
289
+ return typeof account === 'string' ? account : null;
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ }
295
+ // `secret: true` keeps the typed value off the screen. Node has no masking
296
+ // option, so point readline's echo at a sink and print the question ourselves.
297
+ // `terminal: true` is what makes this safe: it puts the tty in raw mode, so the
298
+ // driver stops echoing too and the sink is the only writer left. Streams are
299
+ // injected (Credentials passes the ones `run` got), so the masking has a test.
300
+ export async function defaultPrompt(q, { secret = false, input, output, terminal, }) {
301
+ if (secret)
302
+ output.write(q);
303
+ const rl = createInterface({
304
+ input,
305
+ output: secret ? new Writable({ write: (_c, _e, cb) => cb() }) : output,
306
+ terminal: secret ? true : terminal,
307
+ });
308
+ try {
309
+ return await rl.question(secret ? '' : q);
310
+ }
311
+ finally {
312
+ rl.close();
313
+ if (secret)
314
+ output.write('\n'); // the swallowed Enter never printed one
315
+ }
316
+ }
@@ -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
+ }
@@ -0,0 +1,315 @@
1
+ // Planner: command words + parsed --params/--body → one validated CallPlan (epic #30 D4, D5).
2
+ // Pure: no I/O; every rejection is a GapiError thrown before any network call.
3
+ import { extname } from 'node:path';
4
+ import { GapiError, messageOf } from './errors.js';
5
+ import { isRecord } from './json.js';
6
+ // Document-level parameters gapi sets itself (D5).
7
+ const OWNED = new Set([
8
+ 'alt',
9
+ 'key',
10
+ 'access_token',
11
+ 'oauth_token',
12
+ 'callback',
13
+ 'prettyPrint',
14
+ 'uploadType',
15
+ 'upload_protocol',
16
+ '$.xgafv',
17
+ ]);
18
+ // The document-level parameters a caller may pass (D5).
19
+ const SHARED = ['fields', 'quotaUser'];
20
+ const PROTOCOLS = ['media', 'multipart', 'resumable'];
21
+ // --upload-content-type default by file extension (D7); anything else is octet-stream.
22
+ const CONTENT_TYPES = new Map([
23
+ ['.png', 'image/png'],
24
+ ['.jpg', 'image/jpeg'],
25
+ ['.jpeg', 'image/jpeg'],
26
+ ['.gif', 'image/gif'],
27
+ ['.webp', 'image/webp'],
28
+ ['.mp4', 'video/mp4'],
29
+ ['.mov', 'video/quicktime'],
30
+ ['.webm', 'video/webm'],
31
+ ['.csv', 'text/csv'],
32
+ ['.txt', 'text/plain'],
33
+ ['.json', 'application/json'],
34
+ ['.pdf', 'application/pdf'],
35
+ ['.zip', 'application/zip'],
36
+ ]);
37
+ const invalid = (message) => new GapiError(3, message);
38
+ // Walk the resources tree by exact key; a method key must be the last word.
39
+ export function resolve(model, words) {
40
+ let node = model;
41
+ for (const [i, word] of words.entries()) {
42
+ const method = node.methods.get(word);
43
+ if (method) {
44
+ if (i < words.length - 1)
45
+ throw invalid(`unexpected argument '${words[i + 1]}'`);
46
+ return { method };
47
+ }
48
+ const next = node.resources.get(word);
49
+ if (!next) {
50
+ const kind = node.methods.size === 0
51
+ ? 'resource'
52
+ : node.resources.size === 0
53
+ ? 'method'
54
+ : 'resource or method';
55
+ const valid = [...node.resources.keys(), ...node.methods.keys()].join(', ');
56
+ throw invalid(`unknown ${kind} '${word}' (valid: ${valid})`);
57
+ }
58
+ node = next;
59
+ }
60
+ return { node };
61
+ }
62
+ export function plan(model, target, input, settings) {
63
+ const method = typeof target === 'string' ? model.byId.get(target) : resolve(model, target).method;
64
+ if (!method)
65
+ throw invalid(`not a method: ${typeof target === 'string' ? target : target.join(' ')}`);
66
+ const command = `${model.api} ${method.words.join(' ')}`;
67
+ if (method.retired)
68
+ throw invalid(`retired: ${method.retired.reason} (${method.retired.source})`);
69
+ if (method.unavailable && !input.dryRun) {
70
+ throw new GapiError(2, `${command}: unavailable: scope not grantable`);
71
+ }
72
+ const warnings = method.deprecated
73
+ ? [
74
+ `${command} is deprecated${method.deprecated.source ? ` (${method.deprecated.source})` : ''}`,
75
+ ]
76
+ : [];
77
+ const values = paramValues(model, method, input.params ?? {});
78
+ if (input.body !== undefined && !method.request)
79
+ throw invalid(`--body: ${command} takes no request body`);
80
+ const mode = sendMode(method, command, input);
81
+ const body = mode.send === 'pages' && mode.paging === 'body' ? mode.body : input.body;
82
+ const url = buildUrl(model, method, values, mode);
83
+ const headers = contentHeaders(mode, body !== undefined);
84
+ if (model.auth === 'oauth' && settings.project)
85
+ headers['x-goog-user-project'] = settings.project;
86
+ Object.assign(headers, settings.headers);
87
+ return {
88
+ methodId: method.id,
89
+ httpMethod: method.httpMethod,
90
+ url,
91
+ headers,
92
+ body,
93
+ auth: { kind: model.auth, scopes: method.scopes },
94
+ warnings,
95
+ ...mode,
96
+ };
97
+ }
98
+ // The outer CallPlan of a batch call: one POST to the documented `batch` endpoint that
99
+ // carries `parts`, each planned by `plan` with its own headers (D7).
100
+ export function batchPlan(model, parts) {
101
+ const [first] = parts;
102
+ return {
103
+ methodId: first.methodId,
104
+ httpMethod: 'POST',
105
+ url: `${model.rootUrl}batch`,
106
+ headers: {},
107
+ auth: first.auth,
108
+ warnings: first.warnings,
109
+ send: 'batch',
110
+ parts,
111
+ };
112
+ }
113
+ // JSON for a body, the file type for a media upload; Transport adds the multipart boundary.
114
+ function contentHeaders(mode, hasBody) {
115
+ const json = hasBody ? { 'Content-Type': 'application/json' } : {};
116
+ if (mode.send !== 'upload')
117
+ return json;
118
+ const { protocol, contentType } = mode.upload;
119
+ if (protocol === 'media')
120
+ return { 'Content-Type': contentType };
121
+ if (protocol === 'multipart')
122
+ return {};
123
+ return { ...json, 'X-Upload-Content-Type': contentType };
124
+ }
125
+ // D6 paging, D7 media: at most one of --page-all, --upload, --output, each only where the
126
+ // method supports it.
127
+ function sendMode(method, command, input) {
128
+ const chosen = [
129
+ input.pageAll && '--page-all',
130
+ input.upload !== undefined && '--upload',
131
+ input.output !== undefined && '--output',
132
+ ].filter((flag) => typeof flag === 'string');
133
+ if (chosen.length > 1)
134
+ throw invalid(`${chosen[0]} and ${chosen[1]} cannot be combined`);
135
+ if (input.pageLimit !== undefined && !input.pageAll) {
136
+ throw invalid('--page-limit needs --page-all');
137
+ }
138
+ if (input.upload !== undefined) {
139
+ const hasBody = input.body !== undefined;
140
+ return { send: 'upload', upload: upload(method, command, input.upload, input, hasBody) };
141
+ }
142
+ for (const [flag, value] of [
143
+ ['--upload-content-type', input.uploadContentType],
144
+ ['--upload-protocol', input.uploadProtocol],
145
+ ]) {
146
+ if (value !== undefined)
147
+ throw invalid(`${flag} needs --upload`);
148
+ }
149
+ if (input.output !== undefined) {
150
+ if (!method.download)
151
+ throw invalid(`--output: ${command} has no media download`);
152
+ return { send: 'download', output: input.output };
153
+ }
154
+ if (!input.pageAll)
155
+ return { send: 'once' };
156
+ if (!method.paging) {
157
+ throw invalid(`--page-all: ${command} has no pageToken parameter or request field`);
158
+ }
159
+ const limit = input.pageLimit ?? '10';
160
+ if (!/^[1-9]\d*$/.test(limit))
161
+ throw invalid('--page-limit must be a positive integer');
162
+ const pageLimit = Number(limit);
163
+ if (method.paging === 'query')
164
+ return { send: 'pages', pageLimit, paging: 'query' };
165
+ // Body paging: every page sends this object, from page 2 on with pageToken set (D6).
166
+ const body = input.body ?? {};
167
+ if (!isRecord(body))
168
+ throw invalid('--page-all: --body must be a JSON object');
169
+ return { send: 'pages', pageLimit, paging: 'body', body };
170
+ }
171
+ // D7: protocol defaults to multipart with --body, else media; the type must fit `accept`.
172
+ function upload(method, command, path, input, hasBody) {
173
+ if (!method.upload)
174
+ throw invalid(`--upload: ${command} takes no media upload`);
175
+ const protocol = input.uploadProtocol ?? (hasBody ? 'multipart' : 'media');
176
+ if (!isProtocol(protocol)) {
177
+ throw invalid(`--upload-protocol must be one of: ${PROTOCOLS.join(', ')}`);
178
+ }
179
+ if (protocol === 'resumable' && !method.upload.resumable) {
180
+ throw invalid(`--upload-protocol resumable: ${command} has no resumable upload`);
181
+ }
182
+ // An explicit protocol that would drop or lack the --body metadata.
183
+ if (protocol === 'multipart' && !hasBody) {
184
+ throw invalid('--upload-protocol multipart needs --body');
185
+ }
186
+ if (protocol === 'media' && hasBody)
187
+ throw invalid('--upload-protocol media takes no --body');
188
+ const contentType = input.uploadContentType ??
189
+ CONTENT_TYPES.get(extname(path).toLowerCase()) ??
190
+ 'application/octet-stream';
191
+ const { accept } = method.upload;
192
+ if (accept.length > 0 && !accept.some((pattern) => accepts(pattern, contentType))) {
193
+ throw invalid(`--upload-content-type ${contentType}: ${command} accepts ${accept.join(', ')}`);
194
+ }
195
+ return { path, contentType, protocol };
196
+ }
197
+ const isProtocol = (p) => PROTOCOLS.includes(p);
198
+ // A discovery `accept` pattern: `*/*`, `<type>/*` or an exact type; MIME types ignore case.
199
+ function accepts(pattern, contentType) {
200
+ const p = pattern.toLowerCase();
201
+ const t = contentType.split(';')[0].trim().toLowerCase();
202
+ return p === '*/*' || p === t || (p.endsWith('/*') && t.startsWith(p.slice(0, -1)));
203
+ }
204
+ // argv JSON for --params / --body; invalid JSON and unsafe integers exit 3.
205
+ export function parseJson(flag, text) {
206
+ if (text === undefined)
207
+ return undefined;
208
+ let value;
209
+ try {
210
+ value = JSON.parse(text);
211
+ }
212
+ catch (e) {
213
+ throw invalid(`${flag} is not valid JSON: ${messageOf(e)}`);
214
+ }
215
+ const path = unsafeInteger(value, '');
216
+ if (path !== undefined) {
217
+ throw invalid(`${flag}${path && ` ${path}`}: integer exceeds 2^53; pass it as a string`);
218
+ }
219
+ return value;
220
+ }
221
+ // JSON.parse rounds integers past 2^53; Discovery sends int64 as a string (discovery/v1/type-format).
222
+ function unsafeInteger(value, path) {
223
+ if (typeof value === 'number') {
224
+ return Math.abs(value) > Number.MAX_SAFE_INTEGER ? path : undefined;
225
+ }
226
+ if (typeof value !== 'object' || value === null)
227
+ return undefined;
228
+ const array = Array.isArray(value);
229
+ for (const [key, item] of Object.entries(value)) {
230
+ const found = unsafeInteger(item, array ? `${path}[${key}]` : path ? `${path}.${key}` : key);
231
+ if (found !== undefined)
232
+ return found;
233
+ }
234
+ return undefined;
235
+ }
236
+ // --params → one list of string values per known parameter (D5 value rules).
237
+ function paramValues(model, method, params) {
238
+ if (typeof params !== 'object' || params === null || Array.isArray(params)) {
239
+ throw invalid('--params must be a JSON object');
240
+ }
241
+ const shared = SHARED.filter((k) => model.parameters.has(k));
242
+ const out = new Map();
243
+ for (const [name, raw] of Object.entries(params)) {
244
+ const spec = method.parameters.get(name) ??
245
+ (shared.includes(name) ? model.parameters.get(name) : undefined);
246
+ if (!spec) {
247
+ if (OWNED.has(name))
248
+ throw invalid(`${name} is set by gapi`);
249
+ const valid = [...method.parameters.keys(), ...shared].join(', ');
250
+ throw invalid(`unknown param ${name} (valid: ${valid})`);
251
+ }
252
+ if (Array.isArray(raw) && !spec.repeated)
253
+ throw invalid(`${name} takes one value, not an array`);
254
+ const values = (Array.isArray(raw) ? raw : [raw]).map((v) => {
255
+ if (typeof v === 'string')
256
+ return v;
257
+ if (typeof v === 'number' || typeof v === 'boolean')
258
+ return String(v);
259
+ throw invalid(`${name} must be a string, number or boolean`);
260
+ });
261
+ for (const v of values) {
262
+ if (spec.enum && !spec.enum.includes(v)) {
263
+ throw invalid(`${name} must be one of: ${spec.enum.join(', ')}`);
264
+ }
265
+ }
266
+ out.set(name, { spec, values });
267
+ }
268
+ for (const [name, spec] of method.parameters) {
269
+ if (spec.required && !out.has(name))
270
+ throw invalid(`missing required param ${name}`);
271
+ }
272
+ return out;
273
+ }
274
+ // rootUrl + servicePath + the discovery `path` with {x}/{+x} expanded, then the query string.
275
+ // D7: an upload goes to rootUrl + `upload/` + …, a download adds alt=media and, on a
276
+ // `useMediaDownloadService` method, goes to rootUrl + `download/` + … (Discovery "Using").
277
+ function buildUrl(model, method, params, mode) {
278
+ const path = method.path.replace(/\{(\+?)([^}]+)\}/g, (_, reserved, name, offset) => {
279
+ const param = params.get(name);
280
+ if (!param)
281
+ throw invalid(`missing required param ${name}`);
282
+ const value = param.values[0];
283
+ if (param.spec.pattern && !new RegExp(param.spec.pattern).test(value)) {
284
+ throw invalid(`${name} must match ${param.spec.pattern}`);
285
+ }
286
+ if (reserved)
287
+ return value.split('/').map(encodeURIComponent).join('/');
288
+ // #28: `properties/{property}` + `properties/123` would encode to properties%2F123.
289
+ const segment = /[^/]+\/$/.exec(method.path.slice(0, offset))?.[0];
290
+ if (segment && value.startsWith(segment)) {
291
+ throw invalid(`pass the bare id: ${value.slice(segment.length)} (the path already has ${segment})`);
292
+ }
293
+ return encodeSegment(value);
294
+ });
295
+ const query = new URLSearchParams();
296
+ for (const [name, { spec, values }] of params) {
297
+ if (spec.location !== 'path')
298
+ for (const v of values)
299
+ query.append(name, v);
300
+ }
301
+ if (mode.send === 'upload')
302
+ query.append('uploadType', mode.upload.protocol);
303
+ if (mode.send === 'download')
304
+ query.append('alt', 'media');
305
+ const qs = query.toString();
306
+ const root = mode.send === 'upload'
307
+ ? `${model.rootUrl}upload/`
308
+ : mode.send === 'download' && method.download?.service
309
+ ? `${model.rootUrl}download/`
310
+ : model.rootUrl;
311
+ return `${root}${model.servicePath}${path}${qs ? `?${qs}` : ''}`;
312
+ }
313
+ // google/api/http.proto: {x} percent-encodes every character except [-_.~0-9a-zA-Z];
314
+ // encodeURIComponent leaves !'()* as is.
315
+ const encodeSegment = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);