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,348 @@
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
+ // gaxios httpMethodsToRetry, without OPTIONS: a resend of these changes nothing more.
19
+ const IDEMPOTENT = new Set(['GET', 'HEAD', 'PUT', 'DELETE']);
20
+ // The document-level parameters a caller may pass (D5).
21
+ const SHARED = ['fields', 'quotaUser'];
22
+ const PROTOCOLS = ['media', 'multipart', 'resumable'];
23
+ // --upload-content-type default by file extension (D7); anything else is octet-stream.
24
+ const CONTENT_TYPES = new Map([
25
+ ['.png', 'image/png'],
26
+ ['.jpg', 'image/jpeg'],
27
+ ['.jpeg', 'image/jpeg'],
28
+ ['.gif', 'image/gif'],
29
+ ['.webp', 'image/webp'],
30
+ ['.mp4', 'video/mp4'],
31
+ ['.mov', 'video/quicktime'],
32
+ ['.webm', 'video/webm'],
33
+ ['.csv', 'text/csv'],
34
+ ['.txt', 'text/plain'],
35
+ ['.json', 'application/json'],
36
+ ['.pdf', 'application/pdf'],
37
+ ['.zip', 'application/zip'],
38
+ ]);
39
+ const invalid = (message) => new GapiError(3, message);
40
+ // Walk the resources tree by exact key; a method key must be the last word.
41
+ export function resolve(model, words) {
42
+ let node = model;
43
+ for (const [i, word] of words.entries()) {
44
+ const method = node.methods.get(word);
45
+ if (method) {
46
+ if (i < words.length - 1)
47
+ throw invalid(`unexpected argument '${words[i + 1]}'`);
48
+ return { method };
49
+ }
50
+ const next = node.resources.get(word);
51
+ if (!next) {
52
+ const kind = node.methods.size === 0
53
+ ? 'resource'
54
+ : node.resources.size === 0
55
+ ? 'method'
56
+ : 'resource or method';
57
+ const valid = [...node.resources.keys(), ...node.methods.keys()].join(', ');
58
+ throw invalid(`unknown ${kind} '${word}' (valid: ${valid})`);
59
+ }
60
+ node = next;
61
+ }
62
+ return { node };
63
+ }
64
+ export function plan(model, target, input, settings) {
65
+ const method = typeof target === 'string' ? model.byId.get(target) : resolve(model, target).method;
66
+ if (!method)
67
+ throw invalid(`not a method: ${typeof target === 'string' ? target : target.join(' ')}`);
68
+ const command = `${model.api} ${method.words.join(' ')}`;
69
+ if (method.retired)
70
+ throw invalid(`retired: ${method.retired.reason} (${method.retired.source})`);
71
+ if (method.unavailable && !input.dryRun) {
72
+ throw new GapiError(2, `${command}: unavailable: scope not grantable`);
73
+ }
74
+ const warnings = method.deprecated
75
+ ? [
76
+ `${command} is deprecated${method.deprecated.source ? ` (${method.deprecated.source})` : ''}`,
77
+ ]
78
+ : [];
79
+ const values = paramValues(model, method, input.params ?? {});
80
+ if (input.body !== undefined && !method.request)
81
+ throw invalid(`--body: ${command} takes no request body`);
82
+ const mode = sendMode(method, command, input);
83
+ const body = mode.send === 'pages' && mode.paging === 'body' ? mode.body : input.body;
84
+ const url = buildUrl(model, method, values, mode);
85
+ const headers = contentHeaders(mode, body !== undefined);
86
+ if (model.auth === 'oauth' && settings.project)
87
+ headers['x-goog-user-project'] = settings.project;
88
+ Object.assign(headers, settings.headers);
89
+ return {
90
+ methodId: method.id,
91
+ httpMethod: method.httpMethod,
92
+ url,
93
+ headers,
94
+ body,
95
+ auth: { kind: model.auth, scopes: method.scopes },
96
+ warnings,
97
+ ...(IDEMPOTENT.has(method.httpMethod) || method.read ? { retry: true } : {}),
98
+ ...mode,
99
+ };
100
+ }
101
+ // The outer CallPlan of a batch call: one POST to the document's `batchPath` endpoint that
102
+ // carries `parts`, each planned by `plan` with its own headers (D7).
103
+ export function batchPlan(model, parts) {
104
+ const [first] = parts;
105
+ return {
106
+ methodId: first.methodId,
107
+ httpMethod: 'POST',
108
+ url: `${model.rootUrl}${model.batchPath}`,
109
+ headers: {},
110
+ auth: first.auth,
111
+ warnings: first.warnings,
112
+ send: 'batch',
113
+ parts,
114
+ };
115
+ }
116
+ // JSON for a body, the file type for a media upload; Transport adds the multipart boundary.
117
+ function contentHeaders(mode, hasBody) {
118
+ const json = hasBody ? { 'Content-Type': 'application/json' } : {};
119
+ if (mode.send !== 'upload')
120
+ return json;
121
+ const { protocol, contentType } = mode.upload;
122
+ if (protocol === 'media')
123
+ return { 'Content-Type': contentType };
124
+ if (protocol === 'multipart')
125
+ return {};
126
+ return { ...json, 'X-Upload-Content-Type': contentType };
127
+ }
128
+ // D6 paging, D7 media: at most one of --page-all, --upload, --output, each only where the
129
+ // method supports it.
130
+ function sendMode(method, command, input) {
131
+ const chosen = [
132
+ input.pageAll && '--page-all',
133
+ input.upload !== undefined && '--upload',
134
+ input.output !== undefined && '--output',
135
+ ].filter((flag) => typeof flag === 'string');
136
+ if (chosen.length > 1)
137
+ throw invalid(`${chosen[0]} and ${chosen[1]} cannot be combined`);
138
+ if (input.pageLimit !== undefined && !input.pageAll) {
139
+ throw invalid('--page-limit needs --page-all');
140
+ }
141
+ if (input.upload !== undefined) {
142
+ const hasBody = input.body !== undefined;
143
+ return { send: 'upload', upload: upload(method, command, input.upload, input, hasBody) };
144
+ }
145
+ for (const [flag, value] of [
146
+ ['--upload-content-type', input.uploadContentType],
147
+ ['--upload-protocol', input.uploadProtocol],
148
+ ]) {
149
+ if (value !== undefined)
150
+ throw invalid(`${flag} needs --upload`);
151
+ }
152
+ if (input.output !== undefined) {
153
+ if (!method.download)
154
+ throw invalid(`--output: ${command} has no media download`);
155
+ return { send: 'download', output: input.output };
156
+ }
157
+ if (!input.pageAll)
158
+ return { send: 'once' };
159
+ if (!method.paging) {
160
+ throw invalid(`--page-all: ${command} has no pageToken parameter or request field`);
161
+ }
162
+ const limit = input.pageLimit ?? '10';
163
+ if (!/^[1-9]\d*$/.test(limit))
164
+ throw invalid('--page-limit must be a positive integer');
165
+ const pageLimit = Number(limit);
166
+ if (method.paging === 'query')
167
+ return { send: 'pages', pageLimit, paging: 'query' };
168
+ // Body paging: every page sends this object, from page 2 on with pageToken set (D6).
169
+ const body = input.body ?? {};
170
+ if (!isRecord(body))
171
+ throw invalid('--page-all: --body must be a JSON object');
172
+ return { send: 'pages', pageLimit, paging: 'body', body };
173
+ }
174
+ // D7: protocol defaults to multipart with --body, else media; the type must fit `accept`.
175
+ function upload(method, command, path, input, hasBody) {
176
+ if (!method.upload)
177
+ throw invalid(`--upload: ${command} takes no media upload`);
178
+ const protocol = input.uploadProtocol ?? (hasBody ? 'multipart' : 'media');
179
+ if (!isProtocol(protocol)) {
180
+ throw invalid(`--upload-protocol must be one of: ${PROTOCOLS.join(', ')}`);
181
+ }
182
+ if (protocol === 'resumable' && !method.upload.resumable) {
183
+ throw invalid(`--upload-protocol resumable: ${command} has no resumable upload`);
184
+ }
185
+ // An explicit protocol that would drop or lack the --body metadata.
186
+ if (protocol === 'multipart' && !hasBody) {
187
+ throw invalid('--upload-protocol multipart needs --body');
188
+ }
189
+ if (protocol === 'media' && hasBody)
190
+ throw invalid('--upload-protocol media takes no --body');
191
+ const contentType = input.uploadContentType ??
192
+ CONTENT_TYPES.get(extname(path).toLowerCase()) ??
193
+ 'application/octet-stream';
194
+ const { accept } = method.upload;
195
+ if (accept.length > 0 && !accept.some((pattern) => accepts(pattern, contentType))) {
196
+ throw invalid(`--upload-content-type ${contentType}: ${command} accepts ${accept.join(', ')}`);
197
+ }
198
+ return { path, contentType, protocol };
199
+ }
200
+ const isProtocol = (p) => PROTOCOLS.includes(p);
201
+ // A discovery `accept` pattern: `*/*`, `<type>/*` or an exact type; MIME types ignore case.
202
+ function accepts(pattern, contentType) {
203
+ const p = pattern.toLowerCase();
204
+ const t = contentType.split(';')[0].trim().toLowerCase();
205
+ return p === '*/*' || p === t || (p.endsWith('/*') && t.startsWith(p.slice(0, -1)));
206
+ }
207
+ // argv JSON for --params / --body; invalid JSON and unsafe integers exit 3.
208
+ export function parseJson(flag, text) {
209
+ if (text === undefined)
210
+ return undefined;
211
+ let value;
212
+ try {
213
+ value = JSON.parse(text);
214
+ }
215
+ catch (e) {
216
+ throw invalid(`${flag} is not valid JSON: ${messageOf(e)}`);
217
+ }
218
+ const path = unsafeInteger(value, '');
219
+ if (path !== undefined) {
220
+ throw invalid(`${flag}${path && ` ${path}`}: integer exceeds 2^53; pass it as a string`);
221
+ }
222
+ return value;
223
+ }
224
+ // JSON.parse rounds integers past 2^53; Discovery sends int64 as a string (discovery/v1/type-format).
225
+ function unsafeInteger(value, path) {
226
+ if (typeof value === 'number') {
227
+ return Math.abs(value) > Number.MAX_SAFE_INTEGER ? path : undefined;
228
+ }
229
+ if (typeof value !== 'object' || value === null)
230
+ return undefined;
231
+ const array = Array.isArray(value);
232
+ for (const [key, item] of Object.entries(value)) {
233
+ const found = unsafeInteger(item, array ? `${path}[${key}]` : path ? `${path}.${key}` : key);
234
+ if (found !== undefined)
235
+ return found;
236
+ }
237
+ return undefined;
238
+ }
239
+ // --params → one list of string values per known parameter (D5 value rules).
240
+ function paramValues(model, method, params) {
241
+ if (typeof params !== 'object' || params === null || Array.isArray(params)) {
242
+ throw invalid('--params must be a JSON object');
243
+ }
244
+ const shared = SHARED.filter((k) => model.parameters.has(k));
245
+ const out = new Map();
246
+ for (const [name, raw] of Object.entries(params)) {
247
+ const spec = method.parameters.get(name) ??
248
+ (shared.includes(name) ? model.parameters.get(name) : undefined);
249
+ if (!spec) {
250
+ if (OWNED.has(name))
251
+ throw invalid(`${name} is set by gapi`);
252
+ const valid = [...method.parameters.keys(), ...shared].join(', ');
253
+ throw invalid(`unknown param ${name} (valid: ${valid})`);
254
+ }
255
+ if (Array.isArray(raw) && !spec.repeated)
256
+ throw invalid(`${name} takes one value, not an array`);
257
+ const values = (Array.isArray(raw) ? raw : [raw]).map((v) => {
258
+ if (typeof v === 'string')
259
+ return v;
260
+ if (typeof v === 'number' || typeof v === 'boolean')
261
+ return String(v);
262
+ throw invalid(`${name} must be a string, number or boolean`);
263
+ });
264
+ // An empty list or a required '' sends nothing; a path would lose its segment.
265
+ if (values.length === 0)
266
+ throw invalid(`${name} needs at least one value`);
267
+ if (spec.required && values.includes(''))
268
+ throw invalid(`${name} needs a value`);
269
+ for (const v of values) {
270
+ if (spec.enum && !spec.enum.includes(v)) {
271
+ throw invalid(`${name} must be one of: ${spec.enum.join(', ')}`);
272
+ }
273
+ const type = spec.type === 'integer' || /int64$/.test(spec.format ?? '') ? 'integer' : spec.type;
274
+ if (type === 'integer' && !/^-?\d+$/.test(v))
275
+ throw invalid(`${name} must be an integer`);
276
+ if (type === 'boolean' && v !== 'true' && v !== 'false') {
277
+ throw invalid(`${name} must be true or false`);
278
+ }
279
+ // Path patterns are checked in buildUrl, after the bare-id hint (#28).
280
+ if (spec.location !== 'path' && spec.pattern && !matches(spec.pattern, v)) {
281
+ throw invalid(`${name} must match ${spec.pattern}`);
282
+ }
283
+ }
284
+ out.set(name, { spec, values });
285
+ }
286
+ for (const [name, spec] of method.parameters) {
287
+ if (spec.required && !out.has(name))
288
+ throw invalid(`missing required param ${name}`);
289
+ }
290
+ return out;
291
+ }
292
+ // rootUrl + servicePath + the discovery `path` with {x}/{+x} expanded, then the query string.
293
+ // D7: an upload goes to rootUrl + `upload/` + …, a download adds alt=media and, on a
294
+ // `useMediaDownloadService` method, goes to rootUrl + `download/` + … (Discovery "Using").
295
+ function buildUrl(model, method, params, mode) {
296
+ const path = method.path.replace(/\{(\+?)([^}]+)\}/g, (_, reserved, name, offset) => {
297
+ const param = params.get(name);
298
+ if (!param)
299
+ throw invalid(`missing required param ${name}`);
300
+ const value = param.values[0];
301
+ if (param.spec.pattern && !matches(param.spec.pattern, value)) {
302
+ throw invalid(`${name} must match ${param.spec.pattern}`);
303
+ }
304
+ // googleapis-common validateAndEncodeParams: a `.`/`..` segment would make the URL
305
+ // parser leave the method path; `%2e` too, since WHATWG URL reads it as a dot.
306
+ const dot = (reserved ? value.split('/') : [value]).find((s) => /^(?:\.|%2e){1,2}$/i.test(s));
307
+ if (dot !== undefined)
308
+ throw invalid(`${name}: '${dot}' is a dot segment; pass a real id`);
309
+ if (reserved)
310
+ return value.split('/').map(encodeURIComponent).join('/');
311
+ // #28: `properties/{property}` + `properties/123` would encode to properties%2F123.
312
+ const segment = /[^/]+\/$/.exec(method.path.slice(0, offset))?.[0];
313
+ if (segment && value.startsWith(segment)) {
314
+ throw invalid(`pass the bare id: ${value.slice(segment.length)} (the path already has ${segment})`);
315
+ }
316
+ return encodeSegment(value);
317
+ });
318
+ const query = new URLSearchParams();
319
+ for (const [name, { spec, values }] of params) {
320
+ if (spec.location !== 'path')
321
+ for (const v of values)
322
+ query.append(name, v);
323
+ }
324
+ if (mode.send === 'upload')
325
+ query.append('uploadType', mode.upload.protocol);
326
+ if (mode.send === 'download')
327
+ query.append('alt', 'media');
328
+ const qs = query.toString();
329
+ const root = mode.send === 'upload'
330
+ ? `${model.rootUrl}upload/`
331
+ : mode.send === 'download' && method.download?.service
332
+ ? `${model.rootUrl}download/`
333
+ : model.rootUrl;
334
+ return `${root}${model.servicePath}${path}${qs ? `?${qs}` : ''}`;
335
+ }
336
+ // shortcut: a Java-only discovery pattern (e.g. `(?i)`) is skipped, not checked; translate
337
+ // Java regex to JS if Google ever relies on one for a path.
338
+ function matches(pattern, value) {
339
+ try {
340
+ return new RegExp(pattern).test(value);
341
+ }
342
+ catch {
343
+ return true;
344
+ }
345
+ }
346
+ // google/api/http.proto: {x} percent-encodes every character except [-_.~0-9a-zA-Z];
347
+ // encodeURIComponent leaves !'()* as is.
348
+ const encodeSegment = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
@@ -0,0 +1,248 @@
1
+ // Renderers: plain text views over an ApiModel (epic #30 D4, story 8).
2
+ import { isRecord } from './json.js';
3
+ export const SCHEMA_USAGE = 'gapi schema <api> <resource>[.<sub-resource>…].<method> | <SchemaName> [--resolve-refs | --find <word>]';
4
+ export const DISCOVERY_USAGE = 'gapi discovery refresh [<api>…] | path';
5
+ export const AUTH_USAGE = 'gapi auth setup | login [--manual] | status | logout';
6
+ export const SEARCH_USAGE = 'gapi search <words…>';
7
+ const USAGE = `gapi <api> <resource> [<sub-resource>…] <method> [--params '<json>'] [--body '<json>']
8
+ [--upload <file> [--upload-content-type <type>] [--upload-protocol media|multipart|resumable]]
9
+ [--output <file>] [--page-all [--page-limit <n>]] [--cache <age>] [--dry-run]
10
+ [--project <id>] [--api-key <key>] [<entry header flags, e.g. ads --login-customer-id <id>>]
11
+ gapi <api> [<resource>…] list sub-resources and methods
12
+ gapi <api> <resource>… <method> --help method help
13
+ gapi <api> +<helper> … [--dry-run] [--cache <age>] [--project <id>] [--api-key <key>] [<entry header flags>]
14
+ ${SEARCH_USAGE} find methods by intent, max 20 hits
15
+ ${SCHEMA_USAGE}
16
+ ${DISCOVERY_USAGE}
17
+ ${AUTH_USAGE}
18
+ `;
19
+ export function rootHelp(apis) {
20
+ return `${USAGE}\napis:\n${apis
21
+ .toSorted()
22
+ .map((api) => ` ${api}\n`)
23
+ .join('')}`;
24
+ }
25
+ const markList = (m, { reasons = false } = {}) => [
26
+ m.deprecated && 'deprecated',
27
+ m.retired && (reasons ? `retired: ${m.retired.reason}` : 'retired'),
28
+ m.unavailable && 'unavailable: scope not grantable',
29
+ m.paid && (reasons ? `paid: ${m.paid.reason}` : 'paid'),
30
+ ].filter((s) => typeof s === 'string');
31
+ const marks = (m) => markList(m)
32
+ .map((s) => ` (${s})`)
33
+ .join('');
34
+ export function levelText(model, words, node, helpers = {}) {
35
+ const lines = [`gapi ${[model.api, ...words].join(' ')}`];
36
+ if (Object.keys(helpers).length > 0) {
37
+ lines.push('', 'helpers:', ...Object.entries(helpers).map(([k, h]) => ` ${k} ${h.summary}`));
38
+ }
39
+ if (node.resources.size > 0) {
40
+ lines.push('', 'resources:', ...[...node.resources.keys()].map((k) => ` ${k}`));
41
+ }
42
+ if (node.methods.size > 0) {
43
+ lines.push('', 'methods:', ...[...node.methods].map(([k, m]) => ` ${k}${marks(m)}`));
44
+ }
45
+ return `${lines.join('\n')}\n`;
46
+ }
47
+ // Many documents state "Required." only in the description, not as `required: true`.
48
+ const requiredFact = (p) => p.required
49
+ ? 'required'
50
+ : p.description?.startsWith('Required.')
51
+ ? 'required (per description)'
52
+ : undefined;
53
+ export function methodHelp(model, m) {
54
+ const lines = [`gapi ${model.api} ${m.words.join(' ')}${marks(m)}`];
55
+ if (m.description)
56
+ lines.push('', m.description);
57
+ lines.push('', `${m.httpMethod} ${model.rootUrl}${model.servicePath}${m.path}`, `id: ${m.id}`);
58
+ lines.push('', m.parameters.size > 0 ? 'params (--params):' : 'params: none');
59
+ for (const [name, p] of m.parameters) {
60
+ const facts = [
61
+ p.location,
62
+ p.type,
63
+ requiredFact(p),
64
+ p.repeated && 'repeated',
65
+ p.enum && `enum: ${p.enum.join('|')}`,
66
+ p.pattern && `pattern: ${p.pattern}`,
67
+ ].filter(Boolean);
68
+ lines.push(` ${name} ${facts.join(', ')}`);
69
+ // Descriptions carry Markdown (tables, lists): keep their lines, indented.
70
+ if (p.description)
71
+ lines.push(p.description.trim().replace(/^/gm, ' '));
72
+ }
73
+ lines.push('', `body (--body): ${m.request ?? 'none'}`, `response: ${m.response ?? 'none'}`);
74
+ if (m.upload) {
75
+ const protocols = m.upload.resumable ? 'media, multipart, resumable' : 'media, multipart';
76
+ const max = m.upload.maxSize ? `; max ${m.upload.maxSize}` : '';
77
+ lines.push(`upload: ${protocols}; accept ${m.upload.accept.join(', ') || 'any'}${max}`);
78
+ }
79
+ if (m.download)
80
+ lines.push('download: yes');
81
+ lines.push(`scopes: ${m.scopes.join(', ') || 'none'}`);
82
+ if (m.deprecated)
83
+ lines.push(`deprecated${m.deprecated.source ? `: ${m.deprecated.source}` : ''}`);
84
+ if (m.retired)
85
+ lines.push(`retired: ${m.retired.reason} (${m.retired.source})`);
86
+ if (m.paid)
87
+ lines.push(`paid: ${m.paid.reason} (${m.paid.source})`);
88
+ if (m.unavailable)
89
+ lines.push('unavailable: scope not grantable');
90
+ if (model.documentationLink)
91
+ lines.push(`docs: ${model.documentationLink}`);
92
+ return `${lines.join('\n')}\n`;
93
+ }
94
+ const GENERATED = 'Generated by `pnpm gen:skill` from the discovery document. Do not edit.';
95
+ const AUTH_LABEL = { oauth: 'OAuth', apiKey: 'API key' };
96
+ const cell = (text) => text.replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
97
+ // A table cell holds one sentence; the full text is in `--help`.
98
+ const oneLine = (description = '') => description
99
+ .trim()
100
+ .split('\n')[0]
101
+ .split(/(?<=\.)\s+(?=[A-Z*])/)[0];
102
+ // The first sentence, max 100 chars: one line of search hits and of `schema --find`.
103
+ const brief = (description) => {
104
+ const sentence = oneLine(description);
105
+ const chars = [...sentence]; // code points: a cut never splits a surrogate pair
106
+ return chars.length > 100 ? `${chars.slice(0, 99).join('')}…` : sentence;
107
+ };
108
+ // Code-point order, so the output does not depend on the machine's locale.
109
+ const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
110
+ const byKey = (map) => [...map].sort(([a], [b]) => compare(a, b));
111
+ export function indexMarkdown(models) {
112
+ const rows = models
113
+ .toSorted((a, b) => compare(a.api, b.api))
114
+ .map((m) => `| [\`${m.api}\`](${m.api}.md) | ${cell(m.title ?? m.name)} | ${m.version} | ${m.byId.size} | ${AUTH_LABEL[m.auth]} |`);
115
+ return [
116
+ '# gapi API reference',
117
+ '',
118
+ GENERATED,
119
+ '',
120
+ '| api | title | version | methods | auth |',
121
+ '|---|---|---|---|---|',
122
+ ...rows,
123
+ '',
124
+ ].join('\n');
125
+ }
126
+ export function referenceMarkdown(model) {
127
+ const lines = [
128
+ `# gapi ${model.api}: ${model.title ?? model.name} ${model.version}`,
129
+ '',
130
+ GENERATED,
131
+ '',
132
+ `Auth: ${AUTH_LABEL[model.auth]}.${model.documentationLink ? ` Docs: ${model.documentationLink}` : ''}`,
133
+ ];
134
+ // Sorted by key: Google serves the same document with its keys in varying order.
135
+ const visit = (node, words) => {
136
+ if (words.length > 0 || node.methods.size > 0) {
137
+ lines.push('', `## ${['gapi', model.api, ...words].join(' ')}`);
138
+ }
139
+ if (node.methods.size > 0) {
140
+ lines.push('', '| command | HTTP | description | required params | body | marks |', '|---|---|---|---|---|---|', ...byKey(node.methods).map(([, m]) => referenceRow(model, m)));
141
+ }
142
+ for (const [key, child] of byKey(node.resources))
143
+ visit(child, [...words, key]);
144
+ };
145
+ visit(model, []);
146
+ return `${lines.join('\n')}\n`;
147
+ }
148
+ function referenceRow(model, m) {
149
+ const required = byKey(m.parameters)
150
+ .filter(([, p]) => requiredFact(p))
151
+ .map(([name, p]) => `\`${name}\`${p.required ? '' : ' (per description)'}`);
152
+ const marked = markList(m, { reasons: true }).join('; ');
153
+ return `| \`gapi ${model.api} ${m.words.join(' ')}\` | ${m.httpMethod} | ${cell(oneLine(m.description))} | ${required.join(', ')} | ${m.request ? `\`${m.request}\`` : ''} | ${cell(marked)} |`;
154
+ }
155
+ // One line per hit: command, marks, HTTP method, first sentence of the description.
156
+ export function searchText(hits) {
157
+ return hits
158
+ .map(({ model, method: m }) => {
159
+ const line = `gapi ${model.api} ${m.words.join(' ')}${marks(m)} ${m.httpMethod} ${brief(m.description)}`;
160
+ return `${line.trimEnd()}\n`;
161
+ })
162
+ .join('');
163
+ }
164
+ // D9: a method's shape (request/response as schema names), or one document schema.
165
+ export function schemaView(model, target, { resolveRefs }) {
166
+ // A method shape holds schema names, never a $ref, so --resolve-refs leaves it as is.
167
+ if ('method' in target) {
168
+ const m = target.method;
169
+ const shape = {
170
+ id: m.id,
171
+ httpMethod: m.httpMethod,
172
+ path: m.path,
173
+ description: m.description,
174
+ parameters: Object.fromEntries(m.parameters),
175
+ request: m.request,
176
+ response: m.response,
177
+ scopes: m.scopes,
178
+ supportsMediaUpload: m.upload !== undefined,
179
+ supportsMediaDownload: m.download !== undefined,
180
+ };
181
+ return `${JSON.stringify(shape, null, 2)}\n`;
182
+ }
183
+ const schema = model.schemas.get(target.schema);
184
+ const out = resolveRefs && schema ? expandObject(schema, model.schemas, [target.schema]) : schema;
185
+ return `${JSON.stringify(out, null, 2)}\n`;
186
+ }
187
+ // `--find`: one line per leaf of the expanded schema (a method: request and response) whose
188
+ // path or printed sentence contains `word`, case-insensitive. A $ref the cycle guard left is a leaf.
189
+ export function schemaFind(model, target, word) {
190
+ const roots = 'method' in target
191
+ ? [
192
+ ['request', target.method.request],
193
+ ['response', target.method.response],
194
+ ]
195
+ : [['', target.schema]];
196
+ const needle = word.toLowerCase();
197
+ const lines = [];
198
+ // An array or map item without a description takes its field's (Google puts it there).
199
+ const walk = (node, path, fieldDescription = '') => {
200
+ const own = typeof node.description === 'string' ? node.description : fieldDescription;
201
+ const props = isRecord(node.properties) ? Object.entries(node.properties) : [];
202
+ const children = [
203
+ ...props.map(([key, child]) => [
204
+ path ? `${path}.${key}` : key,
205
+ child,
206
+ '',
207
+ ]),
208
+ [`${path}[]`, node.items, own],
209
+ [`${path}{}`, node.additionalProperties, own],
210
+ ];
211
+ let leaf = true;
212
+ for (const [childPath, child, description] of children) {
213
+ if (!isRecord(child))
214
+ continue;
215
+ leaf = false;
216
+ walk(child, childPath, description);
217
+ }
218
+ const ref = typeof node.$ref === 'string' ? node.$ref : undefined;
219
+ const text = ref ? '' : brief(own);
220
+ if (!leaf || !`${path}\n${text}`.toLowerCase().includes(needle))
221
+ return;
222
+ const type = ref ? `$ref ${ref}` : `${String(node.type ?? 'any')}${node.enum ? ' enum' : ''}`;
223
+ lines.push(`${path}: ${type}${text ? ` — ${text}` : ''}`);
224
+ };
225
+ for (const [path, name] of roots) {
226
+ const schema = name ? model.schemas.get(name) : undefined;
227
+ const expanded = name && schema ? expandObject(schema, model.schemas, [name]) : undefined;
228
+ if (isRecord(expanded))
229
+ walk(expanded, path);
230
+ }
231
+ return lines.map((line) => `${line}\n`).join('');
232
+ }
233
+ function expandRefs(value, schemas, path) {
234
+ if (Array.isArray(value))
235
+ return value.map((v) => expandRefs(v, schemas, path));
236
+ return typeof value === 'object' && value !== null ? expandObject(value, schemas, path) : value;
237
+ }
238
+ // Replaces {"$ref": X, …} with schema X expanded, keeping the sibling keys.
239
+ // A name already on the expansion path stays a {"$ref": X} with its siblings (cycle guard).
240
+ function expandObject(obj, schemas, path) {
241
+ const ref = '$ref' in obj ? obj.$ref : undefined;
242
+ const name = typeof ref === 'string' && !path.includes(ref) ? ref : undefined;
243
+ const schema = name && schemas.get(name);
244
+ const own = Object.fromEntries(Object.entries(obj)
245
+ .filter(([k]) => !(schema && k === '$ref'))
246
+ .map(([k, v]) => [k, expandRefs(v, schemas, path)]));
247
+ return name && schema ? { ...expandObject(schema, schemas, [...path, name]), ...own } : own;
248
+ }
@@ -0,0 +1,37 @@
1
+ // Response cache (ADR 0001): one file per key under <config dir>/responses/, dir 700, files
2
+ // 600, written by temp file + rename. Holds Google account data: login and logout clear it.
3
+ import { hash } from 'node:crypto';
4
+ import { readdir, readFile, rm, stat } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { writeAtomic } from './catalog.js';
7
+ export const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
8
+ export function openResponses(dir) {
9
+ const file = (key) => join(dir, key);
10
+ // Every value that shapes the request or its output; `url` carries every query value.
11
+ const key = (call, identity) => hash('sha256', JSON.stringify([
12
+ identity,
13
+ call.httpMethod,
14
+ call.url,
15
+ call.headers,
16
+ call.body,
17
+ call.send,
18
+ call.send === 'pages' ? call.pageLimit : undefined,
19
+ call.decode !== undefined, // a helper's decoded output differs from the raw one
20
+ ]));
21
+ // When the entry was stored (epoch ms); undefined when there is none.
22
+ const mtime = (k) => stat(file(k)).then((s) => s.mtimeMs, () => undefined);
23
+ const get = (k) => readFile(file(k), 'utf8').catch(() => undefined);
24
+ const put = (k, text) => writeAtomic(file(k), text);
25
+ // Deletes every file (temp files too) older than the max age. Every run calls it, so
26
+ // account data never outlives 7 days on disk (YouTube policy, ADR 0001).
27
+ async function sweep() {
28
+ for (const name of await readdir(dir).catch(() => [])) {
29
+ const f = join(dir, name);
30
+ const s = await stat(f).catch(() => undefined);
31
+ if (s && Date.now() - s.mtimeMs > MAX_AGE_MS)
32
+ await rm(f, { force: true });
33
+ }
34
+ }
35
+ const clear = () => rm(dir, { recursive: true, force: true });
36
+ return { key, mtime, get, put, sweep, clear };
37
+ }