google-apis-cli 0.3.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.
@@ -0,0 +1,180 @@
1
+ // Entities (#200 D6, ADR 0002): the offline index of the operator's own accounts, properties
2
+ // and sites in <config dir>/entities.db, mode 600. Rows hold the Credentials identity and live
3
+ // at most 7 days; login and logout delete the file. Nothing here ever picks an ID for a call.
4
+ // shortcut: DatabaseSync is a deprecated alias of Database since Node v26.11.0 (DEP0210), switch
5
+ // when @types/node types Database.
6
+ import { existsSync } from 'node:fs';
7
+ import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
8
+ import { dirname } from 'node:path';
9
+ import { DatabaseSync } from 'node:sqlite';
10
+ import { GapiError } from './errors.js';
11
+ import { isRecord } from './json.js';
12
+ import { MAX_AGE_MS } from './responses.js';
13
+ const VERSION = 1; // PRAGMA user_version; another value drops the file, sync rebuilds it
14
+ const NOT_A_DATABASE = 26; // SQLITE_NOTADB: the file is dropped like another version
15
+ const BUSY_MS = 5000; // another gapi process may hold the write lock
16
+ const SCHEMA = `create virtual table if not exists entity using fts5(
17
+ api unindexed, kind unindexed, id, name, parent, domain, identity unindexed, synced unindexed,
18
+ tokenize = 'trigram')`;
19
+ // Domain key: URL host, lower case (IDN as punycode), no leading `www.`.
20
+ // shortcut: no public-suffix parsing, so `shop.acme.com` does not link to `acme.com`; upgrade
21
+ // when a source needs subdomain linking.
22
+ export function domainOf(uri) {
23
+ if (typeof uri !== 'string' || uri === '')
24
+ return undefined;
25
+ const bare = uri.replace(/^sc-domain:/, '');
26
+ const url = URL.parse(/^[a-z][a-z\d+.-]*:\/\//i.test(bare) ? bare : `https://${bare}`);
27
+ return url?.hostname.replace(/^www\./, '') || undefined;
28
+ }
29
+ // The record items of `data[key]`, for a source's `entities` mapping.
30
+ export const items = (data, key) => isRecord(data) && Array.isArray(data[key]) ? data[key].filter(isRecord) : [];
31
+ export const text = (v) => typeof v === 'string' && v ? v : undefined;
32
+ // Schema version; undefined for a file that is not a database. Any other error (a lock) throws,
33
+ // so a busy index is never dropped.
34
+ function versionOf(db) {
35
+ try {
36
+ return Number(db.prepare('pragma user_version').get()?.user_version);
37
+ }
38
+ catch (e) {
39
+ if (e instanceof Error && 'errcode' in e && e.errcode === NOT_A_DATABASE)
40
+ return undefined;
41
+ throw e;
42
+ }
43
+ }
44
+ export function openEntities(file) {
45
+ const clear = async () => {
46
+ await Promise.all(['', '-journal'].map((s) => rm(`${file}${s}`, { force: true })));
47
+ };
48
+ // Created mode 600 before SQLite opens it (SQLite gives its journal the same mode). `fresh`:
49
+ // the file is empty, so it is new (or a racing process has not written it yet).
50
+ const create = async () => {
51
+ await mkdir(dirname(file), { recursive: true, mode: 0o700 });
52
+ await writeFile(file, '', { mode: 0o600, flag: 'a' });
53
+ const fresh = (await stat(file)).size === 0;
54
+ return { db: new DatabaseSync(file, { timeout: BUSY_MS }), fresh };
55
+ };
56
+ // A new file gets the schema in place. A file of another schema version, or not a database
57
+ // at all, is dropped and rebuilt empty.
58
+ async function open() {
59
+ let { db, fresh } = await create();
60
+ try {
61
+ if (!fresh && versionOf(db) === VERSION)
62
+ return db;
63
+ if (!fresh) {
64
+ db.close();
65
+ await clear();
66
+ ({ db } = await create());
67
+ }
68
+ db.exec(SCHEMA);
69
+ db.exec(`pragma user_version = ${VERSION}`);
70
+ return db;
71
+ }
72
+ catch (e) {
73
+ if (db.isOpen)
74
+ db.close();
75
+ throw e;
76
+ }
77
+ }
78
+ async function use(fn) {
79
+ const db = await open();
80
+ try {
81
+ return fn(db);
82
+ }
83
+ finally {
84
+ db.close();
85
+ }
86
+ }
87
+ // Every run: rows older than the response cache max age go (ADR 0001 terms, ADR 0002).
88
+ async function sweep() {
89
+ if (!existsSync(file))
90
+ return;
91
+ await use((db) => db.prepare('delete from entity where synced < ?').run(Date.now() - MAX_AGE_MS));
92
+ }
93
+ // Reads every source of one API, then replaces that API's rows in one transaction; a
94
+ // failed call throws before the write, so the old rows stay. Rows of one kind and ID merge
95
+ // (later defined fields win), so a child can add a domain or name. Returns the row count.
96
+ async function sync(api, sources, identity, request) {
97
+ const merged = new Map();
98
+ const walk = async (source, parent) => {
99
+ const found = [];
100
+ const call = {
101
+ api: source.api,
102
+ method: source.method,
103
+ params: source.params?.(parent) ?? {},
104
+ body: source.body?.(parent),
105
+ headers: source.headers?.(parent),
106
+ };
107
+ for await (const page of request(call))
108
+ found.push(...source.entities(page, parent));
109
+ for (const e of found) {
110
+ const key = `${e.kind}\0${e.id}`;
111
+ const was = merged.get(key);
112
+ merged.set(key, {
113
+ ...e,
114
+ name: e.name ?? was?.name,
115
+ parent: e.parent ?? was?.parent,
116
+ domain: e.domain ?? was?.domain,
117
+ });
118
+ }
119
+ for (const e of found) {
120
+ const { children } = source;
121
+ const child = children && Object.hasOwn(children, e.kind) ? children[e.kind] : undefined;
122
+ if (child)
123
+ await walk(child, e);
124
+ }
125
+ };
126
+ for (const source of sources)
127
+ await walk(source);
128
+ const rows = [...merged.values()];
129
+ const now = Date.now();
130
+ return use((db) => {
131
+ db.exec('begin');
132
+ try {
133
+ db.prepare('delete from entity where api = ? and identity = ?').run(api, identity);
134
+ const insert = db.prepare('insert into entity values (?, ?, ?, ?, ?, ?, ?, ?)');
135
+ for (const r of rows) {
136
+ insert.run(api, r.kind, r.id, r.name ?? '', r.parent ?? '', r.domain ?? '', identity, now);
137
+ }
138
+ db.exec('commit');
139
+ }
140
+ catch (e) {
141
+ db.exec('rollback');
142
+ throw e;
143
+ }
144
+ return rows.length;
145
+ });
146
+ }
147
+ // Trigram match of each whitespace-separated term (any substring of 3+ characters), joined
148
+ // by OR, best 20 by bm25. Each term is one quoted FTS5 string, so no FTS5 syntax reaches the
149
+ // match. `empty`: the identity has no row (for `api`) at all.
150
+ async function find(query, { api, identity }) {
151
+ const terms = query.split(/\s+/).filter(Boolean);
152
+ const short = terms.find((t) => [...t].length < 3);
153
+ if (terms.length === 0 || short !== undefined) {
154
+ throw new GapiError(3, `find: each term needs 3 or more characters${short ? `: '${short}'` : ''}`);
155
+ }
156
+ if (identity === undefined || !existsSync(file))
157
+ return { hits: [], empty: true };
158
+ const match = terms.map((t) => `"${t.replaceAll('"', '""')}"`).join(' OR ');
159
+ return use((db) => {
160
+ const scope = 'identity = ? and (? is null or api = ?)';
161
+ const count = db.prepare(`select count(*) as n from entity where ${scope}`);
162
+ const empty = count.get(identity, api ?? null, api ?? null)?.n === 0;
163
+ const hits = db
164
+ .prepare(`select api, kind, id, name, parent, domain, synced from entity
165
+ where entity match ? and ${scope} order by rank limit 20`)
166
+ .all(match, identity, api ?? null, api ?? null)
167
+ .map((r) => ({
168
+ api: String(r.api),
169
+ kind: String(r.kind),
170
+ id: String(r.id),
171
+ name: text(r.name),
172
+ parent: text(r.parent),
173
+ domain: text(r.domain),
174
+ synced: Number(r.synced),
175
+ }));
176
+ return { hits, empty };
177
+ });
178
+ }
179
+ return { sync, find, sweep, clear };
180
+ }
package/dist/src/plan.js CHANGED
@@ -80,7 +80,7 @@ export function plan(model, target, input, settings) {
80
80
  if (input.body !== undefined && !method.request)
81
81
  throw invalid(`--body: ${command} takes no request body`);
82
82
  const mode = sendMode(method, command, input);
83
- const body = mode.send === 'pages' && mode.paging === 'body' ? mode.body : input.body;
83
+ const body = mode.send === 'pages' && method.paging === 'body' ? pagingBody(input) : input.body;
84
84
  const url = buildUrl(model, method, values, mode);
85
85
  const headers = contentHeaders(mode, body !== undefined);
86
86
  if (model.auth === 'oauth' && settings.project)
@@ -163,13 +163,27 @@ function sendMode(method, command, input) {
163
163
  if (!/^[1-9]\d*$/.test(limit))
164
164
  throw invalid('--page-limit must be a positive integer');
165
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).
166
+ return { send: 'pages', pageLimit, follow: nextPage(method.paging) };
167
+ }
168
+ // Body paging: every page sends this object, from page 2 on with pageToken set (D6).
169
+ function pagingBody(input) {
169
170
  const body = input.body ?? {};
170
171
  if (!isRecord(body))
171
172
  throw invalid('--page-all: --body must be a JSON object');
172
- return { send: 'pages', pageLimit, paging: 'body', body };
173
+ return body;
174
+ }
175
+ // AIP-158 follow rule: repeat the request with only pageToken changed, until no nextPageToken.
176
+ function nextPage(paging) {
177
+ return (data, last) => {
178
+ const token = isRecord(data) ? data.nextPageToken : undefined;
179
+ if (typeof token !== 'string' || !token)
180
+ return undefined;
181
+ if (paging === 'body')
182
+ return { ...last, body: { ...(isRecord(last.body) && last.body), pageToken: token } };
183
+ const url = new URL(last.url);
184
+ url.searchParams.set('pageToken', token);
185
+ return { ...last, url: url.href };
186
+ };
173
187
  }
174
188
  // D7: protocol defaults to multipart with --body, else media; the type must fit `accept`.
175
189
  function upload(method, command, path, input, hasBody) {
@@ -1,9 +1,17 @@
1
- // Renderers: plain text views over an ApiModel (epic #30 D4, story 8).
2
1
  import { isRecord } from './json.js';
3
2
  export const SCHEMA_USAGE = 'gapi schema <api> <resource>[.<sub-resource>…].<method> | <SchemaName> [--resolve-refs | --find <word>]';
4
3
  export const DISCOVERY_USAGE = 'gapi discovery refresh [<api>…] | path';
5
4
  export const AUTH_USAGE = 'gapi auth setup | login [--manual] | status | logout';
6
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>]';
7
15
  const USAGE = `gapi <api> <resource> [<sub-resource>…] <method> [--params '<json>'] [--body '<json>']
8
16
  [--upload <file> [--upload-content-type <type>] [--upload-protocol media|multipart|resumable]]
9
17
  [--output <file>] [--page-all [--page-limit <n>]] [--cache <age>] [--dry-run]
@@ -12,6 +20,8 @@ gapi <api> [<resource>…] list sub-resources and methods
12
20
  gapi <api> <resource>… <method> --help method help
13
21
  gapi <api> +<helper> … [--dry-run] [--cache <age>] [--project <id>] [--api-key <key>] [<entry header flags>]
14
22
  ${SEARCH_USAGE} find methods by intent, max 20 hits
23
+ ${FIELDS_USAGE}
24
+ ${ENTITIES_USAGE}
15
25
  ${SCHEMA_USAGE}
16
26
  ${DISCOVERY_USAGE}
17
27
  ${AUTH_USAGE}
@@ -100,7 +110,7 @@ const oneLine = (description = '') => description
100
110
  .split('\n')[0]
101
111
  .split(/(?<=\.)\s+(?=[A-Z*])/)[0];
102
112
  // The first sentence, max 100 chars: one line of search hits and of `schema --find`.
103
- const brief = (description) => {
113
+ export const brief = (description) => {
104
114
  const sentence = oneLine(description);
105
115
  const chars = [...sentence]; // code points: a cut never splits a surrogate pair
106
116
  return chars.length > 100 ? `${chars.slice(0, 99).join('')}…` : sentence;
@@ -161,6 +171,22 @@ export function searchText(hits) {
161
171
  })
162
172
  .join('');
163
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
+ }
164
190
  // D9: a method's shape (request/response as schema names), or one document schema.
165
191
  export function schemaView(model, target, { resolveRefs }) {
166
192
  // A method shape holds schema names, never a $ref, so --resolve-refs leaves it as is.
package/dist/src/run.js CHANGED
@@ -4,13 +4,14 @@ import { join } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
5
  import { openCatalog } from './catalog.js';
6
6
  import { configDir, openCredentials } from './credentials.js';
7
+ import { openEntities } from './entities.js';
7
8
  import { GapiError, messageOf, reasonOf } from './errors.js';
8
9
  import { isRecord } from './json.js';
9
10
  import { parseJson, plan, resolve } from './plan.js';
10
- import { AUTH_USAGE, DISCOVERY_USAGE, levelText, methodHelp, rootHelp, SCHEMA_USAGE, SEARCH_USAGE, schemaFind, schemaView, searchText, } from './render.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';
11
12
  import { MAX_AGE_MS, openResponses } from './responses.js';
12
- import { matchQuery, search } from './search.js';
13
- import { send } from './transport.js';
13
+ import { FIELD_APIS, ga4Fields, gaqlFields, matchQuery, rankFields, search, } from './search.js';
14
+ import { receive, send } from './transport.js';
14
15
  const GLOBAL_OPTIONS = {
15
16
  params: { type: 'string' },
16
17
  body: { type: 'string' },
@@ -76,7 +77,7 @@ function parse(args, options) {
76
77
  throw e instanceof GapiError ? e : new GapiError(3, messageOf(e));
77
78
  }
78
79
  }
79
- async function runAuth(sub, values, { io, creds, catalog, responses, }) {
80
+ async function runAuth(sub, values, { io, creds, catalog, responses, entities, }) {
80
81
  // --help never starts a side effect: no server, no fetch, no file write.
81
82
  if (values.help) {
82
83
  io.stdout.write(`${AUTH_USAGE}\n`);
@@ -84,7 +85,8 @@ async function runAuth(sub, values, { io, creds, catalog, responses, }) {
84
85
  }
85
86
  if (sub === 'login') {
86
87
  const consent = await consentScopes(catalog);
87
- await responses.clear(); // a new account starts with no stored responses
88
+ // A new account starts with no stored responses and no entities.
89
+ await Promise.all([responses.clear(), entities.clear()]);
88
90
  const tokens = await creds.login({ consent, manual: values.manual === true });
89
91
  io.stderr.write(`gapi: authorized (${tokens.refresh_token ? 'refresh token stored' : 'no refresh token'})\n`);
90
92
  return;
@@ -99,7 +101,7 @@ async function runAuth(sub, values, { io, creds, catalog, responses, }) {
99
101
  }
100
102
  if (sub === 'status') {
101
103
  const consent = await consentScopes(catalog);
102
- io.stdout.write(`${JSON.stringify(await creds.status(consent), null, 2)}\n`);
104
+ printJson(io, `${JSON.stringify(await creds.status(consent))}\n`);
103
105
  return;
104
106
  }
105
107
  if (sub === 'logout') {
@@ -107,13 +109,155 @@ async function runAuth(sub, values, { io, creds, catalog, responses, }) {
107
109
  await creds.logout();
108
110
  }
109
111
  finally {
110
- await responses.clear(); // also when the revoke fails: no account data stays on disk
112
+ // Also when the revoke fails: no account data stays on disk.
113
+ await Promise.all([responses.clear(), entities.clear()]);
111
114
  }
112
115
  io.stderr.write('gapi: credentials removed\n');
113
116
  return;
114
117
  }
115
118
  throw new GapiError(3, sub === undefined ? `usage: ${AUTH_USAGE}` : `unknown auth command: ${sub}`);
116
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));
260
+ }
117
261
  // D8 consent set: every entry's per-method scope plus its entry scopes (identity is added by login).
118
262
  async function consentScopes(catalog) {
119
263
  const models = await Promise.all(Object.keys(catalog.entries).map((api) => catalog.load(api)));
@@ -159,6 +303,9 @@ async function dispatch(argv, io) {
159
303
  });
160
304
  const responses = openResponses(join(dir, 'responses'));
161
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)}`));
162
309
  // ADR 0001 eligibility, before any network call: the entry allows caching, the call is
163
310
  // one request or pages, the method is a GET or a `reads` POST.
164
311
  const cacheFor = async (call, model, values) => {
@@ -187,8 +334,10 @@ async function dispatch(argv, io) {
187
334
  manual: { type: 'boolean' },
188
335
  help: { type: 'boolean' },
189
336
  });
190
- return runAuth(positionals[0], values, { io, creds, catalog, responses });
337
+ return runAuth(positionals[0], values, { io, creds, catalog, responses, entities });
191
338
  }
339
+ if (api === 'entities')
340
+ return runEntities(rest, { io, creds, catalog, entities });
192
341
  if (api === 'search') {
193
342
  // No option parse: a word like `-list` is query text (story 11).
194
343
  if (rest.includes('--help')) {
@@ -204,6 +353,8 @@ async function dispatch(argv, io) {
204
353
  io.stdout.write(searchText(hits));
205
354
  return;
206
355
  }
356
+ if (api === 'fields')
357
+ return runFields(rest, { io, creds, catalog });
207
358
  if (api === 'schema') {
208
359
  const { values, positionals } = parse(rest, {
209
360
  'resolve-refs': { type: 'boolean' },
@@ -322,6 +473,24 @@ async function dispatch(argv, io) {
322
473
  const cache = await cacheFor(call, model, values);
323
474
  return perform(call, dryRun, io, creds.authorize, { responses, cache });
324
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);
493
+ }
325
494
  // Prints the plan's warnings, then the redacted request (--dry-run), the stored response
326
495
  // (a --cache hit) or the live response, which a --cache miss stores after a full success.
327
496
  async function perform(call, dryRun, io, authorize, { responses, cache }) {
@@ -349,11 +518,11 @@ async function perform(call, dryRun, io, authorize, { responses, cache }) {
349
518
  : undefined,
350
519
  cache: cache && (stored === undefined ? 'miss' : 'hit'),
351
520
  };
352
- io.stdout.write(`${JSON.stringify(request, null, 2)}\n`);
521
+ printJson(io, `${JSON.stringify(request)}\n`);
353
522
  return;
354
523
  }
355
524
  if (stored !== undefined && mtime !== undefined) {
356
- io.stdout.write(stored);
525
+ printJson(io, stored);
357
526
  io.stderr.write(`gapi: cached response from ${new Date(mtime).toISOString()}\n`);
358
527
  return;
359
528
  }
@@ -361,7 +530,7 @@ async function perform(call, dryRun, io, authorize, { responses, cache }) {
361
530
  const out = {
362
531
  write: (chunk) => {
363
532
  chunks.push(chunk);
364
- return io.stdout.write(chunk);
533
+ return printJson(io, chunk);
365
534
  },
366
535
  };
367
536
  await send(call, { fetch: io.fetch, authorize, out, sleep: io.sleep });