google-apis-cli 0.2.0 → 0.4.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.
@@ -1,13 +1,27 @@
1
- export const SCHEMA_USAGE = 'gapi schema <api> <resource>[.<sub-resource>…].<method> | <SchemaName> [--resolve-refs]';
1
+ import { isRecord } from './json.js';
2
+ export const SCHEMA_USAGE = 'gapi schema <api> <resource>[.<sub-resource>…].<method> | <SchemaName> [--resolve-refs | --find <word>]';
2
3
  export const DISCOVERY_USAGE = 'gapi discovery refresh [<api>…] | path';
3
4
  export const AUTH_USAGE = 'gapi auth setup | login [--manual] | status | logout';
5
+ export const SEARCH_USAGE = 'gapi search <words…>';
6
+ export const FIELDS_USAGE = 'gapi fields ads|sa360 <words…> | ga4-data <words…> --property <id>';
7
+ export const FIELDS_HELP = `${FIELDS_USAGE}
8
+ Max 20 ranked lines: <name> <kind> <first sentence of the description>.
9
+ ads, sa360 GAQL names (resource attributes, metrics., segments.) from the discovery document, no request
10
+ ga4-data GA4 dimensions and metrics, custom ones included: one properties getMetadata request
11
+ (--property 123 or properties/123; 0 = the ones common to all properties)
12
+ No --cache. Discovery has no GAQL compatibility (selectable_with): check it with
13
+ googleAdsFields.search (gapi ads googleAdsFields search; sa360: searchAds360Fields search)`;
14
+ export const ENTITIES_USAGE = 'gapi entities sync [<api>…] [--page-limit <n>] | find <words…> [--api <api>]';
4
15
  const USAGE = `gapi <api> <resource> [<sub-resource>…] <method> [--params '<json>'] [--body '<json>']
5
16
  [--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>>]
17
+ [--output <file>] [--page-all [--page-limit <n>]] [--cache <age>] [--dry-run]
18
+ [--project <id>] [--api-key <key>] [<entry header flags, e.g. ads --login-customer-id <id>>]
8
19
  gapi <api> [<resource>…] list sub-resources and methods
9
20
  gapi <api> <resource>… <method> --help method help
10
- gapi <api> +<helper> … [--dry-run] [--project <id>] [--api-key <key>] [<entry header flags>]
21
+ gapi <api> +<helper> … [--dry-run] [--cache <age>] [--project <id>] [--api-key <key>] [<entry header flags>]
22
+ ${SEARCH_USAGE} find methods by intent, max 20 hits
23
+ ${FIELDS_USAGE}
24
+ ${ENTITIES_USAGE}
11
25
  ${SCHEMA_USAGE}
12
26
  ${DISCOVERY_USAGE}
13
27
  ${AUTH_USAGE}
@@ -95,6 +109,12 @@ const oneLine = (description = '') => description
95
109
  .trim()
96
110
  .split('\n')[0]
97
111
  .split(/(?<=\.)\s+(?=[A-Z*])/)[0];
112
+ // The first sentence, max 100 chars: one line of search hits and of `schema --find`.
113
+ export const brief = (description) => {
114
+ const sentence = oneLine(description);
115
+ const chars = [...sentence]; // code points: a cut never splits a surrogate pair
116
+ return chars.length > 100 ? `${chars.slice(0, 99).join('')}…` : sentence;
117
+ };
98
118
  // Code-point order, so the output does not depend on the machine's locale.
99
119
  const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
100
120
  const byKey = (map) => [...map].sort(([a], [b]) => compare(a, b));
@@ -142,6 +162,31 @@ function referenceRow(model, m) {
142
162
  const marked = markList(m, { reasons: true }).join('; ');
143
163
  return `| \`gapi ${model.api} ${m.words.join(' ')}\` | ${m.httpMethod} | ${cell(oneLine(m.description))} | ${required.join(', ')} | ${m.request ? `\`${m.request}\`` : ''} | ${cell(marked)} |`;
144
164
  }
165
+ // One line per hit: command, marks, HTTP method, first sentence of the description.
166
+ export function searchText(hits) {
167
+ return hits
168
+ .map(({ model, method: m }) => {
169
+ const line = `gapi ${model.api} ${m.words.join(' ')}${marks(m)} ${m.httpMethod} ${brief(m.description)}`;
170
+ return `${line.trimEnd()}\n`;
171
+ })
172
+ .join('');
173
+ }
174
+ // #200 D7: one line per field, the shape of search hits.
175
+ export function fieldsText(fields) {
176
+ return fields
177
+ .map((f) => `${`${f.name} ${f.kind} ${brief(f.description)}`.trimEnd()}\n`)
178
+ .join('');
179
+ }
180
+ // #200 D6: tabs keep empty cells (no name, parent or domain) unambiguous for `cut`.
181
+ export function entitiesText(hits) {
182
+ return hits
183
+ .map((h) => {
184
+ const date = new Date(h.synced).toISOString().slice(0, 10);
185
+ const cells = [h.api, h.kind, h.id, h.name, h.parent, h.domain, date];
186
+ return `${cells.map((c = '') => c.replace(/[\t\r\n]+/g, ' ')).join('\t')}\n`;
187
+ })
188
+ .join('');
189
+ }
145
190
  // D9: a method's shape (request/response as schema names), or one document schema.
146
191
  export function schemaView(model, target, { resolveRefs }) {
147
192
  // A method shape holds schema names, never a $ref, so --resolve-refs leaves it as is.
@@ -165,6 +210,52 @@ export function schemaView(model, target, { resolveRefs }) {
165
210
  const out = resolveRefs && schema ? expandObject(schema, model.schemas, [target.schema]) : schema;
166
211
  return `${JSON.stringify(out, null, 2)}\n`;
167
212
  }
213
+ // `--find`: one line per leaf of the expanded schema (a method: request and response) whose
214
+ // path or printed sentence contains `word`, case-insensitive. A $ref the cycle guard left is a leaf.
215
+ export function schemaFind(model, target, word) {
216
+ const roots = 'method' in target
217
+ ? [
218
+ ['request', target.method.request],
219
+ ['response', target.method.response],
220
+ ]
221
+ : [['', target.schema]];
222
+ const needle = word.toLowerCase();
223
+ const lines = [];
224
+ // An array or map item without a description takes its field's (Google puts it there).
225
+ const walk = (node, path, fieldDescription = '') => {
226
+ const own = typeof node.description === 'string' ? node.description : fieldDescription;
227
+ const props = isRecord(node.properties) ? Object.entries(node.properties) : [];
228
+ const children = [
229
+ ...props.map(([key, child]) => [
230
+ path ? `${path}.${key}` : key,
231
+ child,
232
+ '',
233
+ ]),
234
+ [`${path}[]`, node.items, own],
235
+ [`${path}{}`, node.additionalProperties, own],
236
+ ];
237
+ let leaf = true;
238
+ for (const [childPath, child, description] of children) {
239
+ if (!isRecord(child))
240
+ continue;
241
+ leaf = false;
242
+ walk(child, childPath, description);
243
+ }
244
+ const ref = typeof node.$ref === 'string' ? node.$ref : undefined;
245
+ const text = ref ? '' : brief(own);
246
+ if (!leaf || !`${path}\n${text}`.toLowerCase().includes(needle))
247
+ return;
248
+ const type = ref ? `$ref ${ref}` : `${String(node.type ?? 'any')}${node.enum ? ' enum' : ''}`;
249
+ lines.push(`${path}: ${type}${text ? ` — ${text}` : ''}`);
250
+ };
251
+ for (const [path, name] of roots) {
252
+ const schema = name ? model.schemas.get(name) : undefined;
253
+ const expanded = name && schema ? expandObject(schema, model.schemas, [name]) : undefined;
254
+ if (isRecord(expanded))
255
+ walk(expanded, path);
256
+ }
257
+ return lines.map((line) => `${line}\n`).join('');
258
+ }
168
259
  function expandRefs(value, schemas, path) {
169
260
  if (Array.isArray(value))
170
261
  return value.map((v) => expandRefs(v, schemas, path));
@@ -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
+ }
package/dist/src/run.js CHANGED
@@ -1,13 +1,17 @@
1
1
  // Process io is injected so tests drive the whole CLI in-process.
2
2
  import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
3
4
  import { parseArgs } from 'node:util';
4
5
  import { openCatalog } from './catalog.js';
5
6
  import { configDir, openCredentials } from './credentials.js';
7
+ import { openEntities } from './entities.js';
6
8
  import { GapiError, messageOf, reasonOf } from './errors.js';
7
9
  import { isRecord } from './json.js';
8
10
  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
+ import { AUTH_USAGE, DISCOVERY_USAGE, ENTITIES_USAGE, entitiesText, FIELDS_HELP, FIELDS_USAGE, fieldsText, levelText, methodHelp, rootHelp, SCHEMA_USAGE, SEARCH_USAGE, schemaFind, schemaView, searchText, } from './render.js';
12
+ import { MAX_AGE_MS, openResponses } from './responses.js';
13
+ import { FIELD_APIS, ga4Fields, gaqlFields, matchQuery, rankFields, search, } from './search.js';
14
+ import { receive, send } from './transport.js';
11
15
  const GLOBAL_OPTIONS = {
12
16
  params: { type: 'string' },
13
17
  body: { type: 'string' },
@@ -20,6 +24,7 @@ const GLOBAL_OPTIONS = {
20
24
  output: { type: 'string' },
21
25
  project: { type: 'string' },
22
26
  'api-key': { type: 'string' },
27
+ cache: { type: 'string' },
23
28
  help: { type: 'boolean' },
24
29
  };
25
30
  // D10: a helper takes the global flags that do not shape the request, plus its own options.
@@ -27,8 +32,19 @@ const HELPER_OPTIONS = {
27
32
  'dry-run': GLOBAL_OPTIONS['dry-run'],
28
33
  project: GLOBAL_OPTIONS.project,
29
34
  'api-key': GLOBAL_OPTIONS['api-key'],
35
+ cache: GLOBAL_OPTIONS.cache,
30
36
  help: GLOBAL_OPTIONS.help,
31
37
  };
38
+ const AGE_UNIT_MS = { s: 1e3, m: 60e3, h: 3600e3, d: 86400e3 };
39
+ // `--cache <n>s|m|h|d`, from 1 s to the 7-day store limit (ADR 0001).
40
+ function parseAge(age) {
41
+ const m = /^(\d+)([smhd])$/.exec(age);
42
+ const ms = m ? Number(m[1]) * AGE_UNIT_MS[m[2]] : 0;
43
+ if (ms < 1000 || ms > MAX_AGE_MS) {
44
+ throw new GapiError(3, `--cache: ${age} is not an age from 1s to 7d`);
45
+ }
46
+ return ms;
47
+ }
32
48
  const isHelperName = (word) => word.startsWith('+');
33
49
  // D4: an entry header flag is accepted only on the APIs whose entry declares it.
34
50
  const headerOptions = (entry) => Object.fromEntries((entry?.headers ?? []).map((h) => [h.flag, { type: 'string' }]));
@@ -46,16 +62,22 @@ export async function run(argv, io) {
46
62
  return err.exit;
47
63
  }
48
64
  }
49
- // Strict argv parsing; an unknown flag or a missing flag value is a validation error.
65
+ // Strict argv parsing; an unknown flag, a missing flag value or a repeated flag (parseArgs
66
+ // would keep the last one) is a validation error.
50
67
  function parse(args, options) {
51
68
  try {
52
- return parseArgs({ args, options, allowPositionals: true, strict: true });
69
+ const parsed = parseArgs({ args, options, allowPositionals: true, strict: true, tokens: true });
70
+ const names = parsed.tokens.flatMap((t) => (t.kind === 'option' ? [t.name] : []));
71
+ const twice = names.find((n, i) => names.indexOf(n) !== i);
72
+ if (twice !== undefined)
73
+ throw new GapiError(3, `--${twice} given twice`);
74
+ return parsed;
53
75
  }
54
76
  catch (e) {
55
- throw new GapiError(3, messageOf(e));
77
+ throw e instanceof GapiError ? e : new GapiError(3, messageOf(e));
56
78
  }
57
79
  }
58
- async function runAuth(sub, values, { io, creds, catalog }) {
80
+ async function runAuth(sub, values, { io, creds, catalog, responses, entities, }) {
59
81
  // --help never starts a side effect: no server, no fetch, no file write.
60
82
  if (values.help) {
61
83
  io.stdout.write(`${AUTH_USAGE}\n`);
@@ -63,6 +85,8 @@ async function runAuth(sub, values, { io, creds, catalog }) {
63
85
  }
64
86
  if (sub === 'login') {
65
87
  const consent = await consentScopes(catalog);
88
+ // A new account starts with no stored responses and no entities.
89
+ await Promise.all([responses.clear(), entities.clear()]);
66
90
  const tokens = await creds.login({ consent, manual: values.manual === true });
67
91
  io.stderr.write(`gapi: authorized (${tokens.refresh_token ? 'refresh token stored' : 'no refresh token'})\n`);
68
92
  return;
@@ -77,15 +101,162 @@ async function runAuth(sub, values, { io, creds, catalog }) {
77
101
  }
78
102
  if (sub === 'status') {
79
103
  const consent = await consentScopes(catalog);
80
- io.stdout.write(`${JSON.stringify(await creds.status(consent), null, 2)}\n`);
104
+ printJson(io, `${JSON.stringify(await creds.status(consent))}\n`);
81
105
  return;
82
106
  }
83
107
  if (sub === 'logout') {
84
- await creds.logout();
108
+ try {
109
+ await creds.logout();
110
+ }
111
+ finally {
112
+ // Also when the revoke fails: no account data stays on disk.
113
+ await Promise.all([responses.clear(), entities.clear()]);
114
+ }
85
115
  io.stderr.write('gapi: credentials removed\n');
86
116
  return;
87
117
  }
88
- throw new GapiError(3, `unknown auth command: ${sub}`);
118
+ throw new GapiError(3, sub === undefined ? `usage: ${AUTH_USAGE}` : `unknown auth command: ${sub}`);
119
+ }
120
+ // #200 D6: `sync` stores each API's entity sources; `find` searches them offline.
121
+ async function runEntities(args, { io, creds, catalog, entities }) {
122
+ const parsed = parse(args, {
123
+ help: { type: 'boolean' },
124
+ api: { type: 'string' },
125
+ 'page-limit': { type: 'string' },
126
+ });
127
+ const { values } = parsed;
128
+ const [sub, ...positionals] = parsed.positionals;
129
+ if (values.help) {
130
+ io.stdout.write(`${ENTITIES_USAGE}\n`);
131
+ return;
132
+ }
133
+ if (sub !== 'find' && sub !== 'sync')
134
+ throw new GapiError(3, `usage: ${ENTITIES_USAGE}`);
135
+ const other = sub === 'find' ? 'page-limit' : 'api';
136
+ if (values[other] !== undefined)
137
+ throw new GapiError(3, `--${other}: not an option of ${sub}`);
138
+ const identity = await creds.identity({ kind: 'oauth', scopes: [] });
139
+ const sourcesOf = (api) => {
140
+ if (!Object.hasOwn(catalog.entries, api))
141
+ throw new GapiError(3, `unknown api: ${api}`);
142
+ const sources = catalog.entries[api].entities ?? [];
143
+ if (sources.length === 0)
144
+ throw new GapiError(3, `${api} has no entity sources`);
145
+ return sources;
146
+ };
147
+ if (sub === 'find') {
148
+ const api = typeof values.api === 'string' ? values.api : undefined;
149
+ if (api !== undefined)
150
+ sourcesOf(api);
151
+ const { hits, empty } = await entities.find(positionals.join(' '), { api, identity });
152
+ if (empty)
153
+ io.stderr.write('gapi: entities index is empty or older than 7 days; run gapi entities sync\n');
154
+ if (hits.length === 0)
155
+ io.stderr.write('gapi: no match\n');
156
+ io.stdout.write(entitiesText(hits));
157
+ return;
158
+ }
159
+ const limit = typeof values['page-limit'] === 'string' ? values['page-limit'] : '10';
160
+ if (!/^[1-9]\d*$/.test(limit))
161
+ throw new GapiError(3, '--page-limit must be a positive integer');
162
+ const apis = positionals.length > 0
163
+ ? [...new Set(positionals)]
164
+ : Object.keys(catalog.entries)
165
+ .filter((a) => catalog.entries[a].entities?.length)
166
+ .toSorted();
167
+ const sources = apis.map((a) => ({ api: a, sources: sourcesOf(a) }));
168
+ if (identity === undefined)
169
+ throw new GapiError(2, 'no refresh token; run gapi auth login');
170
+ // Cost first: one call per top-level source, then one per parent entity of a child source.
171
+ const children = new Set();
172
+ const each = (api, list) => {
173
+ for (const s of list) {
174
+ for (const [kind, child] of Object.entries(s.children ?? {})) {
175
+ children.add(`${api} ${kind}`);
176
+ each(api, [child]);
177
+ }
178
+ }
179
+ };
180
+ for (const s of sources)
181
+ each(s.api, s.sources);
182
+ const top = sources.reduce((n, s) => n + s.sources.length, 0);
183
+ const then = [...children].map((c) => `, then 1 per ${c}`).join('');
184
+ io.stderr.write(`gapi: entities sync: ${top} list calls${then}; max ${limit} pages per list\n`);
185
+ let failed = 0;
186
+ for (const { api, sources: list } of sources) {
187
+ try {
188
+ // A source may call another entry's method (`api`); its settings and headers apply.
189
+ const request = async function* ({ api: owner = api, method, params, body, headers, }) {
190
+ const model = await catalog.load(owner);
191
+ const settings = await creds.settings({}, model.headers);
192
+ const paged = model.byId.get(method)?.paging !== undefined;
193
+ const call = plan(model, method, { params, body, pageAll: paged, pageLimit: paged ? limit : undefined }, { ...settings, headers: { ...settings.headers, ...headers } });
194
+ for (const w of call.warnings)
195
+ io.stderr.write(`gapi: warning: ${w}\n`);
196
+ yield* receive(call, { fetch: io.fetch, authorize: creds.authorize, sleep: io.sleep });
197
+ };
198
+ const n = await entities.sync(api, list, identity, request);
199
+ io.stdout.write(`${api}: ${n} entities\n`);
200
+ }
201
+ catch (e) {
202
+ failed++;
203
+ io.stdout.write(`${api}: failed, old rows kept: ${messageOf(e)}\n`);
204
+ }
205
+ }
206
+ if (failed > 0)
207
+ throw new GapiError(1, `${failed} of ${sources.length} entity sources failed`);
208
+ }
209
+ // #200 D7: GAQL names offline from the discovery document, GA4 names from one getMetadata call.
210
+ async function runFields(args, { io, creds, catalog }) {
211
+ const { values, positionals } = parse(args, {
212
+ property: { type: 'string' },
213
+ cache: { type: 'string' },
214
+ help: { type: 'boolean' },
215
+ });
216
+ if (values.help) {
217
+ io.stdout.write(`${FIELDS_HELP}\n`);
218
+ return;
219
+ }
220
+ // ADR 0001 keys a cache entry by the request, so two queries would share one entry.
221
+ if (values.cache !== undefined)
222
+ throw new GapiError(3, '--cache: fields is not cacheable');
223
+ const [name, ...words] = positionals;
224
+ if (name === undefined)
225
+ throw new GapiError(3, `usage: ${FIELDS_USAGE}`);
226
+ if (!FIELD_APIS.includes(name)) {
227
+ throw new GapiError(3, `fields: ${name} has no field source; supported: ${FIELD_APIS.join(', ')}`);
228
+ }
229
+ const query = words.join(' ');
230
+ matchQuery(query, FIELDS_USAGE); // no word → exit 3 before any document loads
231
+ const { property } = values;
232
+ if (name !== 'ga4-data' && property !== undefined) {
233
+ throw new GapiError(3, `--property: not an option of fields ${name}`);
234
+ }
235
+ if (name === 'ga4-data' && property === undefined) {
236
+ throw new GapiError(3, '--property is required');
237
+ }
238
+ const id = property?.replace(/^properties\//, '');
239
+ if (id !== undefined && !/^\d+$/.test(id)) {
240
+ throw new GapiError(3, `--property: ${property} is not a GA4 property id`);
241
+ }
242
+ const model = await catalog.load(name);
243
+ let fields = [];
244
+ if (id === undefined) {
245
+ fields = gaqlFields(model);
246
+ }
247
+ else {
248
+ const settings = await creds.settings({}, model.headers);
249
+ const call = plan(model, 'analyticsdata.properties.getMetadata', { params: { name: `properties/${id}/metadata` } }, settings);
250
+ for (const w of call.warnings)
251
+ io.stderr.write(`gapi: warning: ${w}\n`);
252
+ const opts = { fetch: io.fetch, authorize: creds.authorize, sleep: io.sleep };
253
+ for await (const data of receive(call, opts))
254
+ fields = ga4Fields(data);
255
+ }
256
+ const hits = rankFields(fields, query);
257
+ if (hits.length === 0)
258
+ io.stderr.write('gapi: no match\n');
259
+ io.stdout.write(fieldsText(hits));
89
260
  }
90
261
  // D8 consent set: every entry's per-method scope plus its entry scopes (identity is added by login).
91
262
  async function consentScopes(catalog) {
@@ -105,10 +276,19 @@ async function consentScopes(catalog) {
105
276
  function schemaTarget(model, path) {
106
277
  if (!model.methods.has(path) && model.schemas.has(path))
107
278
  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 };
279
+ try {
280
+ const found = resolve(model, path.split('.'));
281
+ if (!found.method)
282
+ throw new GapiError(3, `${path} is a resource, not a method or schema`);
283
+ return { method: found.method };
284
+ }
285
+ catch (e) {
286
+ // resolve knows only the tree; a one-word path can also name a schema.
287
+ if (e instanceof GapiError && /^unknown /.test(e.message) && !path.includes('.')) {
288
+ throw new GapiError(3, `${e.message}, or a schema name`);
289
+ }
290
+ throw e;
291
+ }
112
292
  }
113
293
  async function dispatch(argv, io) {
114
294
  const dir = configDir(io.env);
@@ -121,6 +301,29 @@ async function dispatch(argv, io) {
121
301
  stdin: io.stdin,
122
302
  stderr: io.stderr,
123
303
  });
304
+ const responses = openResponses(join(dir, 'responses'));
305
+ await responses.sweep();
306
+ const entities = openEntities(join(dir, 'entities.db'));
307
+ // shortcut: a lock or disk error skips this run's sweep with a warning, the next run retries.
308
+ await entities.sweep().catch((e) => warn(`entities sweep skipped: ${messageOf(e)}`));
309
+ // ADR 0001 eligibility, before any network call: the entry allows caching, the call is
310
+ // one request or pages, the method is a GET or a `reads` POST.
311
+ const cacheFor = async (call, model, values) => {
312
+ if (values.cache === undefined)
313
+ return undefined;
314
+ const maxAgeMs = parseAge(values.cache);
315
+ const { nocache } = catalog.entries[model.api];
316
+ if (nocache) {
317
+ throw new GapiError(3, `--cache: ${model.api} responses must not be cached: ${nocache.reason} (${nocache.source})`);
318
+ }
319
+ const method = model.byId.get(call.methodId);
320
+ const once = call.send === 'once' || call.send === 'pages';
321
+ if (!once || (method?.httpMethod !== 'GET' && !method?.read)) {
322
+ const command = method ? `${model.api} ${method.words.join(' ')}` : call.methodId;
323
+ throw new GapiError(3, `--cache: ${command} is not a cacheable read`);
324
+ }
325
+ return { key: responses.key(call, await creds.identity(call.auth)), maxAgeMs };
326
+ };
124
327
  if (argv.length === 0 || (argv[0] === '--help' && argv.length === 1)) {
125
328
  io.stdout.write(rootHelp(Object.keys(catalog.entries)));
126
329
  return;
@@ -131,11 +334,31 @@ async function dispatch(argv, io) {
131
334
  manual: { type: 'boolean' },
132
335
  help: { type: 'boolean' },
133
336
  });
134
- return runAuth(positionals[0], values, { io, creds, catalog });
337
+ return runAuth(positionals[0], values, { io, creds, catalog, responses, entities });
338
+ }
339
+ if (api === 'entities')
340
+ return runEntities(rest, { io, creds, catalog, entities });
341
+ if (api === 'search') {
342
+ // No option parse: a word like `-list` is query text (story 11).
343
+ if (rest.includes('--help')) {
344
+ io.stdout.write(`${SEARCH_USAGE}\n`);
345
+ return;
346
+ }
347
+ const query = rest.join(' ');
348
+ matchQuery(query); // a query with no token exits 3 before any document loads
349
+ const models = await Promise.all(Object.keys(catalog.entries).map((a) => catalog.load(a)));
350
+ const hits = search(models, query);
351
+ if (hits.length === 0)
352
+ io.stderr.write('gapi: no match\n');
353
+ io.stdout.write(searchText(hits));
354
+ return;
135
355
  }
356
+ if (api === 'fields')
357
+ return runFields(rest, { io, creds, catalog });
136
358
  if (api === 'schema') {
137
359
  const { values, positionals } = parse(rest, {
138
360
  'resolve-refs': { type: 'boolean' },
361
+ find: { type: 'string' },
139
362
  help: { type: 'boolean' },
140
363
  });
141
364
  if (values.help) {
@@ -145,9 +368,19 @@ async function dispatch(argv, io) {
145
368
  if (positionals.length !== 2)
146
369
  throw new GapiError(3, `usage: ${SCHEMA_USAGE}`);
147
370
  const [name, path] = positionals;
371
+ if (values.find === '')
372
+ throw new GapiError(3, '--find: needs a word');
148
373
  const model = await catalog.load(name);
374
+ const target = schemaTarget(model, path);
375
+ if (values.find !== undefined) {
376
+ const found = schemaFind(model, target, values.find);
377
+ if (found === '')
378
+ io.stderr.write('gapi: no match\n');
379
+ io.stdout.write(found);
380
+ return;
381
+ }
149
382
  const resolveRefs = values['resolve-refs'] === true;
150
- io.stdout.write(schemaView(model, schemaTarget(model, path), { resolveRefs }));
383
+ io.stdout.write(schemaView(model, target, { resolveRefs }));
151
384
  return;
152
385
  }
153
386
  if (api === 'discovery') {
@@ -205,13 +438,18 @@ async function dispatch(argv, io) {
205
438
  throw new GapiError(3, `cannot read ${path}: ${reasonOf(e)}`);
206
439
  }),
207
440
  });
208
- return perform(call, values['dry-run'] === true, io, creds.authorize);
441
+ const cache = await cacheFor(call, model, values);
442
+ return perform(call, values['dry-run'] === true, io, creds.authorize, { responses, cache });
209
443
  }
210
444
  const entry = Object.hasOwn(catalog.entries, api) ? catalog.entries[api] : undefined;
211
445
  const { values, positionals } = parse(rest, { ...GLOBAL_OPTIONS, ...headerOptions(entry) });
212
446
  const model = await catalog.load(api);
213
447
  const target = resolve(model, positionals);
214
448
  if (!target.method) {
449
+ // A listing sends nothing: a request flag here means the method word is missing.
450
+ const flag = Object.keys(values).find((k) => k !== 'help');
451
+ if (flag)
452
+ throw new GapiError(3, `--${flag}: ${[api, ...positionals].join(' ')} is not a method`);
215
453
  const helpers = positionals.length === 0 ? catalog.entries[api].helpers : undefined;
216
454
  io.stdout.write(levelText(model, positionals, target.node, helpers));
217
455
  return;
@@ -232,12 +470,35 @@ async function dispatch(argv, io) {
232
470
  uploadProtocol: values['upload-protocol'],
233
471
  output: values.output,
234
472
  }, await creds.settings(values, model.headers));
235
- return perform(call, dryRun, io, creds.authorize);
473
+ const cache = await cacheFor(call, model, values);
474
+ return perform(call, dryRun, io, creds.authorize, { responses, cache });
475
+ }
476
+ // The one JSON writer (epic #200 D2): compact lines stay as they are off a terminal (agents), each
477
+ // line gets a 2-space indent on a terminal (operators).
478
+ function printJson(io, lines) {
479
+ if (!io.stdout.isTTY)
480
+ return io.stdout.write(lines);
481
+ let text;
482
+ try {
483
+ const values = lines
484
+ .split('\n')
485
+ .filter(Boolean)
486
+ .map((l) => JSON.parse(l));
487
+ text = values.map((v) => `${JSON.stringify(v, null, 2)}\n`).join('');
488
+ }
489
+ catch {
490
+ text = lines; // a cache entry stored before #205 is already indented
491
+ }
492
+ return io.stdout.write(text);
236
493
  }
237
- // Prints the plan's warnings, then the redacted request (--dry-run) or the response.
238
- async function perform(call, dryRun, io, authorize) {
494
+ // Prints the plan's warnings, then the redacted request (--dry-run), the stored response
495
+ // (a --cache hit) or the live response, which a --cache miss stores after a full success.
496
+ async function perform(call, dryRun, io, authorize, { responses, cache }) {
239
497
  for (const w of call.warnings)
240
498
  io.stderr.write(`gapi: warning: ${w}\n`);
499
+ const mtime = cache && (await responses.mtime(cache.key));
500
+ const fresh = cache && mtime !== undefined && Date.now() - mtime < cache.maxAgeMs;
501
+ const stored = fresh ? await responses.get(cache.key) : undefined;
241
502
  if (dryRun) {
242
503
  const headers = { ...call.headers, ...(await authorize(call.auth, { redact: true })) };
243
504
  const request = {
@@ -255,9 +516,28 @@ async function perform(call, dryRun, io, authorize) {
255
516
  body: p.body,
256
517
  }))
257
518
  : undefined,
519
+ cache: cache && (stored === undefined ? 'miss' : 'hit'),
258
520
  };
259
- io.stdout.write(`${JSON.stringify(request, null, 2)}\n`);
521
+ printJson(io, `${JSON.stringify(request)}\n`);
522
+ return;
523
+ }
524
+ if (stored !== undefined && mtime !== undefined) {
525
+ printJson(io, stored);
526
+ io.stderr.write(`gapi: cached response from ${new Date(mtime).toISOString()}\n`);
260
527
  return;
261
528
  }
262
- await send(call, { fetch: io.fetch, authorize, out: io.stdout });
529
+ const chunks = [];
530
+ const out = {
531
+ write: (chunk) => {
532
+ chunks.push(chunk);
533
+ return printJson(io, chunk);
534
+ },
535
+ };
536
+ await send(call, { fetch: io.fetch, authorize, out, sleep: io.sleep });
537
+ if (!cache)
538
+ return;
539
+ // Best effort: the response is already printed, so a failed write only warns.
540
+ await responses.put(cache.key, chunks.join('')).catch((e) => {
541
+ io.stderr.write(`gapi: warning: cache write failed: ${reasonOf(e)}\n`);
542
+ });
263
543
  }