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,183 @@
1
+ export const SCHEMA_USAGE = 'gapi schema <api> <resource>[.<sub-resource>…].<method> | <SchemaName> [--resolve-refs]';
2
+ export const DISCOVERY_USAGE = 'gapi discovery refresh [<api>…] | path';
3
+ export const AUTH_USAGE = 'gapi auth setup | login [--manual] | status | logout';
4
+ const USAGE = `gapi <api> <resource> [<sub-resource>…] <method> [--params '<json>'] [--body '<json>']
5
+ [--upload <file> [--upload-content-type <type>] [--upload-protocol media|multipart|resumable]]
6
+ [--output <file>] [--page-all [--page-limit <n>]] [--dry-run] [--project <id>] [--api-key <key>]
7
+ [<entry header flags, e.g. ads --login-customer-id <id>>]
8
+ gapi <api> [<resource>…] list sub-resources and methods
9
+ gapi <api> <resource>… <method> --help method help
10
+ gapi <api> +<helper> … [--dry-run] [--project <id>] [--api-key <key>] [<entry header flags>]
11
+ ${SCHEMA_USAGE}
12
+ ${DISCOVERY_USAGE}
13
+ ${AUTH_USAGE}
14
+ `;
15
+ export function rootHelp(apis) {
16
+ return `${USAGE}\napis:\n${apis
17
+ .toSorted()
18
+ .map((api) => ` ${api}\n`)
19
+ .join('')}`;
20
+ }
21
+ const markList = (m, { reasons = false } = {}) => [
22
+ m.deprecated && 'deprecated',
23
+ m.retired && (reasons ? `retired: ${m.retired.reason}` : 'retired'),
24
+ m.unavailable && 'unavailable: scope not grantable',
25
+ m.paid && (reasons ? `paid: ${m.paid.reason}` : 'paid'),
26
+ ].filter((s) => typeof s === 'string');
27
+ const marks = (m) => markList(m)
28
+ .map((s) => ` (${s})`)
29
+ .join('');
30
+ export function levelText(model, words, node, helpers = {}) {
31
+ const lines = [`gapi ${[model.api, ...words].join(' ')}`];
32
+ if (Object.keys(helpers).length > 0) {
33
+ lines.push('', 'helpers:', ...Object.entries(helpers).map(([k, h]) => ` ${k} ${h.summary}`));
34
+ }
35
+ if (node.resources.size > 0) {
36
+ lines.push('', 'resources:', ...[...node.resources.keys()].map((k) => ` ${k}`));
37
+ }
38
+ if (node.methods.size > 0) {
39
+ lines.push('', 'methods:', ...[...node.methods].map(([k, m]) => ` ${k}${marks(m)}`));
40
+ }
41
+ return `${lines.join('\n')}\n`;
42
+ }
43
+ // Many documents state "Required." only in the description, not as `required: true`.
44
+ const requiredFact = (p) => p.required
45
+ ? 'required'
46
+ : p.description?.startsWith('Required.')
47
+ ? 'required (per description)'
48
+ : undefined;
49
+ export function methodHelp(model, m) {
50
+ const lines = [`gapi ${model.api} ${m.words.join(' ')}${marks(m)}`];
51
+ if (m.description)
52
+ lines.push('', m.description);
53
+ lines.push('', `${m.httpMethod} ${model.rootUrl}${model.servicePath}${m.path}`, `id: ${m.id}`);
54
+ lines.push('', m.parameters.size > 0 ? 'params (--params):' : 'params: none');
55
+ for (const [name, p] of m.parameters) {
56
+ const facts = [
57
+ p.location,
58
+ p.type,
59
+ requiredFact(p),
60
+ p.repeated && 'repeated',
61
+ p.enum && `enum: ${p.enum.join('|')}`,
62
+ p.pattern && `pattern: ${p.pattern}`,
63
+ ].filter(Boolean);
64
+ lines.push(` ${name} ${facts.join(', ')}`);
65
+ // Descriptions carry Markdown (tables, lists): keep their lines, indented.
66
+ if (p.description)
67
+ lines.push(p.description.trim().replace(/^/gm, ' '));
68
+ }
69
+ lines.push('', `body (--body): ${m.request ?? 'none'}`, `response: ${m.response ?? 'none'}`);
70
+ if (m.upload) {
71
+ const protocols = m.upload.resumable ? 'media, multipart, resumable' : 'media, multipart';
72
+ const max = m.upload.maxSize ? `; max ${m.upload.maxSize}` : '';
73
+ lines.push(`upload: ${protocols}; accept ${m.upload.accept.join(', ') || 'any'}${max}`);
74
+ }
75
+ if (m.download)
76
+ lines.push('download: yes');
77
+ lines.push(`scopes: ${m.scopes.join(', ') || 'none'}`);
78
+ if (m.deprecated)
79
+ lines.push(`deprecated${m.deprecated.source ? `: ${m.deprecated.source}` : ''}`);
80
+ if (m.retired)
81
+ lines.push(`retired: ${m.retired.reason} (${m.retired.source})`);
82
+ if (m.paid)
83
+ lines.push(`paid: ${m.paid.reason} (${m.paid.source})`);
84
+ if (m.unavailable)
85
+ lines.push('unavailable: scope not grantable');
86
+ if (model.documentationLink)
87
+ lines.push(`docs: ${model.documentationLink}`);
88
+ return `${lines.join('\n')}\n`;
89
+ }
90
+ const GENERATED = 'Generated by `pnpm gen:skill` from the discovery document. Do not edit.';
91
+ const AUTH_LABEL = { oauth: 'OAuth', apiKey: 'API key' };
92
+ const cell = (text) => text.replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
93
+ // A table cell holds one sentence; the full text is in `--help`.
94
+ const oneLine = (description = '') => description
95
+ .trim()
96
+ .split('\n')[0]
97
+ .split(/(?<=\.)\s+(?=[A-Z*])/)[0];
98
+ // Code-point order, so the output does not depend on the machine's locale.
99
+ const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
100
+ const byKey = (map) => [...map].sort(([a], [b]) => compare(a, b));
101
+ export function indexMarkdown(models) {
102
+ const rows = models
103
+ .toSorted((a, b) => compare(a.api, b.api))
104
+ .map((m) => `| [\`${m.api}\`](${m.api}.md) | ${cell(m.title ?? m.name)} | ${m.version} | ${m.byId.size} | ${AUTH_LABEL[m.auth]} |`);
105
+ return [
106
+ '# gapi API reference',
107
+ '',
108
+ GENERATED,
109
+ '',
110
+ '| api | title | version | methods | auth |',
111
+ '|---|---|---|---|---|',
112
+ ...rows,
113
+ '',
114
+ ].join('\n');
115
+ }
116
+ export function referenceMarkdown(model) {
117
+ const lines = [
118
+ `# gapi ${model.api}: ${model.title ?? model.name} ${model.version}`,
119
+ '',
120
+ GENERATED,
121
+ '',
122
+ `Auth: ${AUTH_LABEL[model.auth]}.${model.documentationLink ? ` Docs: ${model.documentationLink}` : ''}`,
123
+ ];
124
+ // Sorted by key: Google serves the same document with its keys in varying order.
125
+ const visit = (node, words) => {
126
+ if (words.length > 0 || node.methods.size > 0) {
127
+ lines.push('', `## ${['gapi', model.api, ...words].join(' ')}`);
128
+ }
129
+ if (node.methods.size > 0) {
130
+ lines.push('', '| command | HTTP | description | required params | body | marks |', '|---|---|---|---|---|---|', ...byKey(node.methods).map(([, m]) => referenceRow(model, m)));
131
+ }
132
+ for (const [key, child] of byKey(node.resources))
133
+ visit(child, [...words, key]);
134
+ };
135
+ visit(model, []);
136
+ return `${lines.join('\n')}\n`;
137
+ }
138
+ function referenceRow(model, m) {
139
+ const required = byKey(m.parameters)
140
+ .filter(([, p]) => requiredFact(p))
141
+ .map(([name, p]) => `\`${name}\`${p.required ? '' : ' (per description)'}`);
142
+ const marked = markList(m, { reasons: true }).join('; ');
143
+ return `| \`gapi ${model.api} ${m.words.join(' ')}\` | ${m.httpMethod} | ${cell(oneLine(m.description))} | ${required.join(', ')} | ${m.request ? `\`${m.request}\`` : ''} | ${cell(marked)} |`;
144
+ }
145
+ // D9: a method's shape (request/response as schema names), or one document schema.
146
+ export function schemaView(model, target, { resolveRefs }) {
147
+ // A method shape holds schema names, never a $ref, so --resolve-refs leaves it as is.
148
+ if ('method' in target) {
149
+ const m = target.method;
150
+ const shape = {
151
+ id: m.id,
152
+ httpMethod: m.httpMethod,
153
+ path: m.path,
154
+ description: m.description,
155
+ parameters: Object.fromEntries(m.parameters),
156
+ request: m.request,
157
+ response: m.response,
158
+ scopes: m.scopes,
159
+ supportsMediaUpload: m.upload !== undefined,
160
+ supportsMediaDownload: m.download !== undefined,
161
+ };
162
+ return `${JSON.stringify(shape, null, 2)}\n`;
163
+ }
164
+ const schema = model.schemas.get(target.schema);
165
+ const out = resolveRefs && schema ? expandObject(schema, model.schemas, [target.schema]) : schema;
166
+ return `${JSON.stringify(out, null, 2)}\n`;
167
+ }
168
+ function expandRefs(value, schemas, path) {
169
+ if (Array.isArray(value))
170
+ return value.map((v) => expandRefs(v, schemas, path));
171
+ return typeof value === 'object' && value !== null ? expandObject(value, schemas, path) : value;
172
+ }
173
+ // Replaces {"$ref": X, …} with schema X expanded, keeping the sibling keys.
174
+ // A name already on the expansion path stays a {"$ref": X} with its siblings (cycle guard).
175
+ function expandObject(obj, schemas, path) {
176
+ const ref = '$ref' in obj ? obj.$ref : undefined;
177
+ const name = typeof ref === 'string' && !path.includes(ref) ? ref : undefined;
178
+ const schema = name && schemas.get(name);
179
+ const own = Object.fromEntries(Object.entries(obj)
180
+ .filter(([k]) => !(schema && k === '$ref'))
181
+ .map(([k, v]) => [k, expandRefs(v, schemas, path)]));
182
+ return name && schema ? { ...expandObject(schema, schemas, [...path, name]), ...own } : own;
183
+ }
@@ -0,0 +1,263 @@
1
+ // Process io is injected so tests drive the whole CLI in-process.
2
+ import { readFile } from 'node:fs/promises';
3
+ import { parseArgs } from 'node:util';
4
+ import { openCatalog } from './catalog.js';
5
+ import { configDir, openCredentials } from './credentials.js';
6
+ import { GapiError, messageOf, reasonOf } from './errors.js';
7
+ import { isRecord } from './json.js';
8
+ import { parseJson, plan, resolve } from './plan.js';
9
+ import { AUTH_USAGE, DISCOVERY_USAGE, levelText, methodHelp, rootHelp, SCHEMA_USAGE, schemaView, } from './render.js';
10
+ import { send } from './transport.js';
11
+ const GLOBAL_OPTIONS = {
12
+ params: { type: 'string' },
13
+ body: { type: 'string' },
14
+ 'dry-run': { type: 'boolean' },
15
+ 'page-all': { type: 'boolean' },
16
+ 'page-limit': { type: 'string' },
17
+ upload: { type: 'string' },
18
+ 'upload-content-type': { type: 'string' },
19
+ 'upload-protocol': { type: 'string' },
20
+ output: { type: 'string' },
21
+ project: { type: 'string' },
22
+ 'api-key': { type: 'string' },
23
+ help: { type: 'boolean' },
24
+ };
25
+ // D10: a helper takes the global flags that do not shape the request, plus its own options.
26
+ const HELPER_OPTIONS = {
27
+ 'dry-run': GLOBAL_OPTIONS['dry-run'],
28
+ project: GLOBAL_OPTIONS.project,
29
+ 'api-key': GLOBAL_OPTIONS['api-key'],
30
+ help: GLOBAL_OPTIONS.help,
31
+ };
32
+ const isHelperName = (word) => word.startsWith('+');
33
+ // D4: an entry header flag is accepted only on the APIs whose entry declares it.
34
+ const headerOptions = (entry) => Object.fromEntries((entry?.headers ?? []).map((h) => [h.flag, { type: 'string' }]));
35
+ export async function run(argv, io) {
36
+ try {
37
+ await dispatch(argv, io);
38
+ return 0;
39
+ }
40
+ catch (e) {
41
+ const err = e instanceof GapiError ? e : new GapiError(5, messageOf(e));
42
+ const { body } = err;
43
+ const shown = isRecord(body) ? (body.error ?? body) : body;
44
+ const detail = body ? `\n${JSON.stringify(shown, null, 2)}` : '';
45
+ io.stderr.write(`gapi: ${err.message}${detail}\n`);
46
+ return err.exit;
47
+ }
48
+ }
49
+ // Strict argv parsing; an unknown flag or a missing flag value is a validation error.
50
+ function parse(args, options) {
51
+ try {
52
+ return parseArgs({ args, options, allowPositionals: true, strict: true });
53
+ }
54
+ catch (e) {
55
+ throw new GapiError(3, messageOf(e));
56
+ }
57
+ }
58
+ async function runAuth(sub, values, { io, creds, catalog }) {
59
+ // --help never starts a side effect: no server, no fetch, no file write.
60
+ if (values.help) {
61
+ io.stdout.write(`${AUTH_USAGE}\n`);
62
+ return;
63
+ }
64
+ if (sub === 'login') {
65
+ const consent = await consentScopes(catalog);
66
+ const tokens = await creds.login({ consent, manual: values.manual === true });
67
+ io.stderr.write(`gapi: authorized (${tokens.refresh_token ? 'refresh token stored' : 'no refresh token'})\n`);
68
+ return;
69
+ }
70
+ if (sub === 'setup') {
71
+ // Header prompts come from the entries' `headers` overlays, in api order.
72
+ const headers = Object.values(catalog.entries)
73
+ .toSorted((a, b) => a.api.localeCompare(b.api))
74
+ .flatMap((e) => (e.headers ?? []).map((h) => ({ ...h, api: e.api })));
75
+ io.stderr.write(`gapi: saved to ${await creds.setup(headers)}\n`);
76
+ return;
77
+ }
78
+ if (sub === 'status') {
79
+ const consent = await consentScopes(catalog);
80
+ io.stdout.write(`${JSON.stringify(await creds.status(consent), null, 2)}\n`);
81
+ return;
82
+ }
83
+ if (sub === 'logout') {
84
+ await creds.logout();
85
+ io.stderr.write('gapi: credentials removed\n');
86
+ return;
87
+ }
88
+ throw new GapiError(3, `unknown auth command: ${sub}`);
89
+ }
90
+ // D8 consent set: every entry's per-method scope plus its entry scopes (identity is added by login).
91
+ async function consentScopes(catalog) {
92
+ const models = await Promise.all(Object.keys(catalog.entries).map((api) => catalog.load(api)));
93
+ const scopes = new Set();
94
+ for (const model of models) {
95
+ for (const s of model.scopes)
96
+ scopes.add(s);
97
+ for (const m of model.byId.values())
98
+ if (m.scope)
99
+ scopes.add(m.scope);
100
+ }
101
+ return [...scopes];
102
+ }
103
+ // D9 order: a method path, else a schema name. Only a document-level method can share a
104
+ // schema's name, since method paths of nested resources contain a dot.
105
+ function schemaTarget(model, path) {
106
+ if (!model.methods.has(path) && model.schemas.has(path))
107
+ return { schema: path };
108
+ const found = resolve(model, path.split('.'));
109
+ if (!found.method)
110
+ throw new GapiError(3, `${path} is a resource, not a method or schema`);
111
+ return { method: found.method };
112
+ }
113
+ async function dispatch(argv, io) {
114
+ const dir = configDir(io.env);
115
+ const warn = (message) => io.stderr.write(`gapi: warning: ${message}\n`);
116
+ const catalog = await openCatalog({ dir, fetch: io.fetch, warn, apisDir: io.apisDir });
117
+ const creds = openCredentials({
118
+ dir,
119
+ env: io.env,
120
+ warn,
121
+ stdin: io.stdin,
122
+ stderr: io.stderr,
123
+ });
124
+ if (argv.length === 0 || (argv[0] === '--help' && argv.length === 1)) {
125
+ io.stdout.write(rootHelp(Object.keys(catalog.entries)));
126
+ return;
127
+ }
128
+ const [api, ...rest] = argv;
129
+ if (api === 'auth') {
130
+ const { values, positionals } = parse(rest, {
131
+ manual: { type: 'boolean' },
132
+ help: { type: 'boolean' },
133
+ });
134
+ return runAuth(positionals[0], values, { io, creds, catalog });
135
+ }
136
+ if (api === 'schema') {
137
+ const { values, positionals } = parse(rest, {
138
+ 'resolve-refs': { type: 'boolean' },
139
+ help: { type: 'boolean' },
140
+ });
141
+ if (values.help) {
142
+ io.stdout.write(`${SCHEMA_USAGE}\n`);
143
+ return;
144
+ }
145
+ if (positionals.length !== 2)
146
+ throw new GapiError(3, `usage: ${SCHEMA_USAGE}`);
147
+ const [name, path] = positionals;
148
+ const model = await catalog.load(name);
149
+ const resolveRefs = values['resolve-refs'] === true;
150
+ io.stdout.write(schemaView(model, schemaTarget(model, path), { resolveRefs }));
151
+ return;
152
+ }
153
+ if (api === 'discovery') {
154
+ const { values, positionals } = parse(rest, { help: { type: 'boolean' } });
155
+ if (values.help) {
156
+ io.stdout.write(`${DISCOVERY_USAGE}\n`);
157
+ return;
158
+ }
159
+ const [sub, ...apis] = positionals;
160
+ if (sub === 'path') {
161
+ if (apis.length > 0)
162
+ throw new GapiError(3, `unexpected argument '${apis[0]}'`);
163
+ io.stdout.write(`${catalog.cacheDir}\n`);
164
+ return;
165
+ }
166
+ if (sub !== 'refresh')
167
+ throw new GapiError(3, `usage: ${DISCOVERY_USAGE}`);
168
+ // Validated and deduplicated before any fetch: parallel loads of one api share a temp file.
169
+ const names = apis.length > 0 ? [...new Set(apis)] : Object.keys(catalog.entries);
170
+ for (const a of names) {
171
+ if (!Object.hasOwn(catalog.entries, a))
172
+ throw new GapiError(3, `unknown api: ${a}`);
173
+ }
174
+ await Promise.all(names.map((a) => catalog.load(a, { refresh: true })));
175
+ for (const a of names)
176
+ io.stdout.write(`refreshed ${a}\n`);
177
+ return;
178
+ }
179
+ const [word, ...helperArgs] = rest;
180
+ if (word !== undefined && isHelperName(word)) {
181
+ if (!Object.hasOwn(catalog.entries, api))
182
+ throw new GapiError(3, `unknown api: ${api}`);
183
+ const helpers = catalog.entries[api].helpers ?? {};
184
+ const helper = Object.hasOwn(helpers, word) ? helpers[word] : undefined;
185
+ if (!helper) {
186
+ const valid = Object.keys(helpers).join(', ') || 'none';
187
+ throw new GapiError(3, `unknown helper '${word}' (valid: ${valid})`);
188
+ }
189
+ const { values, positionals } = parse(helperArgs, {
190
+ ...HELPER_OPTIONS,
191
+ ...headerOptions(catalog.entries[api]),
192
+ ...helper.options,
193
+ });
194
+ if (values.help) {
195
+ io.stdout.write(`gapi ${api} ${word} ${helper.summary}\n`);
196
+ return;
197
+ }
198
+ const model = await catalog.load(api);
199
+ const call = await helper.plan({
200
+ model,
201
+ args: positionals,
202
+ flags: values,
203
+ settings: await creds.settings(values, model.headers),
204
+ read: (path) => readFile(path, 'utf8').catch((e) => {
205
+ throw new GapiError(3, `cannot read ${path}: ${reasonOf(e)}`);
206
+ }),
207
+ });
208
+ return perform(call, values['dry-run'] === true, io, creds.authorize);
209
+ }
210
+ const entry = Object.hasOwn(catalog.entries, api) ? catalog.entries[api] : undefined;
211
+ const { values, positionals } = parse(rest, { ...GLOBAL_OPTIONS, ...headerOptions(entry) });
212
+ const model = await catalog.load(api);
213
+ const target = resolve(model, positionals);
214
+ if (!target.method) {
215
+ const helpers = positionals.length === 0 ? catalog.entries[api].helpers : undefined;
216
+ io.stdout.write(levelText(model, positionals, target.node, helpers));
217
+ return;
218
+ }
219
+ if (values.help) {
220
+ io.stdout.write(methodHelp(model, target.method));
221
+ return;
222
+ }
223
+ const dryRun = values['dry-run'] === true;
224
+ const call = plan(model, target.method.id, {
225
+ params: parseJson('--params', values.params),
226
+ body: parseJson('--body', values.body),
227
+ dryRun,
228
+ pageAll: values['page-all'],
229
+ pageLimit: values['page-limit'],
230
+ upload: values.upload,
231
+ uploadContentType: values['upload-content-type'],
232
+ uploadProtocol: values['upload-protocol'],
233
+ output: values.output,
234
+ }, await creds.settings(values, model.headers));
235
+ return perform(call, dryRun, io, creds.authorize);
236
+ }
237
+ // Prints the plan's warnings, then the redacted request (--dry-run) or the response.
238
+ async function perform(call, dryRun, io, authorize) {
239
+ for (const w of call.warnings)
240
+ io.stderr.write(`gapi: warning: ${w}\n`);
241
+ if (dryRun) {
242
+ const headers = { ...call.headers, ...(await authorize(call.auth, { redact: true })) };
243
+ const request = {
244
+ method: call.httpMethod,
245
+ url: call.url,
246
+ headers,
247
+ body: call.body,
248
+ upload: call.send === 'upload' ? call.upload : undefined,
249
+ output: call.send === 'download' ? call.output : undefined,
250
+ parts: call.send === 'batch'
251
+ ? call.parts.map((p) => ({
252
+ method: p.httpMethod,
253
+ url: p.url,
254
+ headers: p.headers,
255
+ body: p.body,
256
+ }))
257
+ : undefined,
258
+ };
259
+ io.stdout.write(`${JSON.stringify(request, null, 2)}\n`);
260
+ return;
261
+ }
262
+ await send(call, { fetch: io.fetch, authorize, out: io.stdout });
263
+ }
@@ -0,0 +1,75 @@
1
+ // Batch (epic #30 D7): parts go out as one multipart/mixed request per 100, sequentially.
2
+ // Format: https://developers.google.com/search/apis/indexing-api/v3/using-api#batch
3
+ import { randomUUID } from 'node:crypto';
4
+ import { GapiError } from '../errors.js';
5
+ import { parseOrText } from '../json.js';
6
+ // Google's per-call part limit for the Indexing batch endpoint.
7
+ const MAX_PARTS = 100;
8
+ // A failed call stops the run but keeps the results of the calls before it: Google has
9
+ // already acted on those parts, so the caller must still see them.
10
+ export async function batch(plan, call) {
11
+ const results = [];
12
+ for (let start = 0; start < plan.parts.length; start += MAX_PARTS) {
13
+ try {
14
+ results.push(...(await chunk(plan, plan.parts.slice(start, start + MAX_PARTS), call)));
15
+ }
16
+ catch (error) {
17
+ return { results, error };
18
+ }
19
+ }
20
+ return { results };
21
+ }
22
+ async function chunk(plan, parts, call) {
23
+ const boundary = randomUUID();
24
+ const body = `${parts.map((p, i) => request(boundary, i + 1, p)).join('')}--${boundary}--`;
25
+ const headers = { ...plan.headers, 'Content-Type': `multipart/mixed; boundary=${boundary}` };
26
+ const res = await call(plan.url, { method: 'POST', headers, body });
27
+ // Google picks its own response boundary.
28
+ const responseBoundary = /boundary="?([^";]+)"?/i.exec(res.headers.get('Content-Type') ?? '')?.[1];
29
+ // A response gapi cannot map to its parts is Google's surprise: exit 1 with the body.
30
+ const text = await res.text();
31
+ const surprise = (message) => new GapiError(1, message, parseOrText(text));
32
+ if (!responseBoundary)
33
+ throw surprise('batch: the response has no multipart boundary');
34
+ const byId = responses(text, responseBoundary);
35
+ if (!byId)
36
+ throw surprise('batch: malformed response part');
37
+ return parts.map((_, i) => {
38
+ const result = byId.get(String(i + 1));
39
+ if (!result)
40
+ throw surprise(`batch: no response for part ${i + 1}`);
41
+ return result;
42
+ });
43
+ }
44
+ // One `application/http` part: the inner request line (path only), its headers and JSON body.
45
+ function request(boundary, id, part) {
46
+ const { pathname, search } = new URL(part.url);
47
+ const headers = Object.entries(part.headers).map(([k, v]) => `${k}: ${v}\r\n`);
48
+ return (`--${boundary}\r\nContent-Type: application/http\r\nContent-Transfer-Encoding: binary\r\n` +
49
+ `Content-ID: <${id}>\r\n\r\n${part.httpMethod} ${pathname}${search}\r\n${headers.join('')}\r\n` +
50
+ `${part.body === undefined ? '' : JSON.stringify(part.body)}\r\n`);
51
+ }
52
+ // Response parts keyed by request Content-ID: each answers `<response-n>` with an inner
53
+ // `HTTP/1.1 <status> …`, headers, blank line, body. Google may answer in any order.
54
+ // A part with no Content-ID or status line makes the whole response unreadable: undefined.
55
+ function responses(text, boundary) {
56
+ const byId = new Map();
57
+ for (const raw of text.split(`--${boundary}`).slice(1)) {
58
+ if (raw.startsWith('--'))
59
+ break;
60
+ const [head, response] = splitAtBlankLine(raw.replace(/^\r?\n/, ''));
61
+ const id = /^content-id:\s*<response-([^>]+)>\s*$/im.exec(head)?.[1];
62
+ const [statusHead, content] = splitAtBlankLine(response);
63
+ const status = Number(/^HTTP\/\S+ (\d{3})/.exec(statusHead)?.[1]);
64
+ if (id === undefined || !status)
65
+ return undefined;
66
+ byId.set(id, { status, text: content.trim() });
67
+ }
68
+ return byId;
69
+ }
70
+ function splitAtBlankLine(text) {
71
+ const blank = /\r?\n\r?\n/.exec(text);
72
+ return blank
73
+ ? [text.slice(0, blank.index), text.slice(blank.index + blank[0].length)]
74
+ : [text, ''];
75
+ }
@@ -0,0 +1,15 @@
1
+ // Media download (epic #30 D7): the response body streams to the --output file.
2
+ import { createWriteStream } from 'node:fs';
3
+ import { rm } from 'node:fs/promises';
4
+ import { Readable } from 'node:stream';
5
+ import { pipeline } from 'node:stream/promises';
6
+ // The file is created only after a 2xx, so a Google error leaves no empty file behind.
7
+ export async function download(plan, call) {
8
+ const res = await call(plan.url, { method: plan.httpMethod, headers: plan.headers });
9
+ const file = createWriteStream(plan.output);
10
+ await pipeline(res.body ?? Readable.from([]), file).catch(async (e) => {
11
+ await rm(plan.output, { force: true }); // a cut-off stream leaves no partial file
12
+ throw e;
13
+ });
14
+ return { path: plan.output, bytes: file.bytesWritten };
15
+ }
@@ -0,0 +1,81 @@
1
+ // Media upload (epic #30 D7): the file streams from disk as a Blob backed by it.
2
+ import { randomUUID } from 'node:crypto';
3
+ import { openAsBlob } from 'node:fs';
4
+ import { open } from 'node:fs/promises';
5
+ import { GapiError, reasonOf } from '../errors.js';
6
+ import { parseOrText } from '../json.js';
7
+ // Google wants chunks in multiples of 256 KiB; 8 MiB keeps the request count low.
8
+ const CHUNK = 8 * 1024 * 1024;
9
+ // Consecutive 308s that move the upload no further before gapi gives up.
10
+ const MAX_STALLS = 3;
11
+ // Returns the final response; Transport prints it like a `once` call.
12
+ export async function upload(plan, call) {
13
+ const file = await readable(plan.upload.path);
14
+ const { protocol, contentType } = plan.upload;
15
+ if (protocol === 'media') {
16
+ return call(plan.url, { method: plan.httpMethod, headers: plan.headers, body: file });
17
+ }
18
+ if (protocol === 'resumable')
19
+ return resumable(plan, file, call);
20
+ // multipart/related: the JSON metadata part, then the media part.
21
+ const boundary = randomUUID();
22
+ const body = new Blob([
23
+ `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n`,
24
+ JSON.stringify(plan.body),
25
+ `\r\n--${boundary}\r\nContent-Type: ${contentType}\r\n\r\n`,
26
+ file,
27
+ `\r\n--${boundary}--`,
28
+ ]);
29
+ const headers = { ...plan.headers, 'Content-Type': `multipart/related; boundary=${boundary}` };
30
+ return call(plan.url, { method: plan.httpMethod, headers, body });
31
+ }
32
+ // Checked before any request: the Blob reads lazily, so a missing, unreadable or non-regular
33
+ // file would otherwise fail mid-upload.
34
+ async function readable(path) {
35
+ const handle = await open(path, 'r').catch((e) => {
36
+ throw new GapiError(3, `--upload: cannot read ${path}: ${reasonOf(e)}`);
37
+ });
38
+ try {
39
+ if (!(await handle.stat()).isFile())
40
+ throw new GapiError(3, `--upload: ${path} is not a file`);
41
+ }
42
+ finally {
43
+ await handle.close();
44
+ }
45
+ return openAsBlob(path);
46
+ }
47
+ // Session request on the upload URL, then PUT chunks to the session URI; 308 = send more.
48
+ async function resumable(plan, file, call) {
49
+ const session = await call(plan.url, {
50
+ method: plan.httpMethod,
51
+ headers: { ...plan.headers, 'X-Upload-Content-Length': String(file.size) },
52
+ body: plan.body === undefined ? undefined : JSON.stringify(plan.body),
53
+ });
54
+ const location = session.headers.get('Location');
55
+ if (!location) {
56
+ const body = parseOrText(await session.text());
57
+ throw new GapiError(1, 'resumable upload: the session response has no Location header', body);
58
+ }
59
+ // undici holds the connection until a body is read or cancelled; only headers matter here.
60
+ await session.body?.cancel();
61
+ let start = 0;
62
+ let stalls = 0;
63
+ for (;;) {
64
+ const end = Math.min(start + CHUNK, file.size);
65
+ const range = file.size === 0 ? 'bytes */0' : `bytes ${start}-${end - 1}/${file.size}`;
66
+ const headers = { 'Content-Range': range };
67
+ const res = await call(location, { method: 'PUT', headers, body: file.slice(start, end) }, 308);
68
+ if (res.status !== 308)
69
+ return res;
70
+ // `Range: bytes=0-<last>` names the bytes Google kept; no Range means it kept none.
71
+ const kept = /^bytes=0-(\d+)$/.exec(res.headers.get('Range') ?? '');
72
+ const next = kept ? Number(kept[1]) + 1 : 0;
73
+ stalls = next > start ? 0 : stalls + 1;
74
+ if (stalls === MAX_STALLS || next >= file.size) {
75
+ const body = parseOrText(await res.text());
76
+ throw new GapiError(1, `resumable upload: 308 at byte ${next} of ${file.size}, no progress`, body);
77
+ }
78
+ await res.body?.cancel();
79
+ start = next;
80
+ }
81
+ }