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.
@@ -1,27 +1,81 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
1
53
  // Search: finds methods by intent across API entries with an in-memory SQLite FTS5 index (#182 §1).
2
54
  // shortcut: DatabaseSync is a documentation-only deprecated alias of Database since Node v26.11.0
3
55
  // (DEP0210); switch when @types/node types Database.
4
56
  import { DatabaseSync } from 'node:sqlite';
5
57
  import { GapiError } from './errors.js';
6
- import { SEARCH_USAGE } from './render.js';
58
+ import { isRecord } from './json.js';
59
+ import { brief, FIELDS_USAGE, SEARCH_USAGE } from './render.js';
7
60
  // `conversionActions` → `conversion Actions`, `URLInspection` → `URL Inspection`, `a-b_c.d` → `a b c d`.
8
61
  const split = (text) => text
9
62
  .replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, '$1 $2')
10
63
  .replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, '$1 $2')
11
64
  .replace(/[-_.]/g, ' ');
12
65
  // Tokens hold only letters and digits, so no FTS5 syntax reaches the match expression.
13
- export function matchQuery(query) {
66
+ export function matchQuery(query, usage = SEARCH_USAGE) {
14
67
  const tokens = split(query)
15
68
  .toLowerCase()
16
69
  .match(/[\p{L}\p{N}]+/gu);
17
70
  if (!tokens)
18
- throw new GapiError(3, `usage: ${SEARCH_USAGE}`);
71
+ throw new GapiError(3, `usage: ${usage}`);
19
72
  return tokens.map((t) => `"${t}"`).join(' OR ');
20
73
  }
21
74
  export function search(models, query) {
22
- const match = matchQuery(query);
23
- const db = new DatabaseSync(':memory:');
75
+ const env_1 = { stack: [], error: void 0, hasError: false };
24
76
  try {
77
+ const match = matchQuery(query);
78
+ const db = __addDisposableResource(env_1, new DatabaseSync(':memory:'), false);
25
79
  db.exec("create virtual table m using fts5(api unindexed, id unindexed, words, extra, descr, tokenize = 'porter unicode61')");
26
80
  const insert = db.prepare('insert into m values (?, ?, ?, ?, ?)');
27
81
  db.exec('begin');
@@ -47,7 +101,108 @@ export function search(models, query) {
47
101
  return model && method ? [{ model, method }] : [];
48
102
  });
49
103
  }
104
+ catch (e_1) {
105
+ env_1.error = e_1;
106
+ env_1.hasError = true;
107
+ }
108
+ finally {
109
+ __disposeResources(env_1);
110
+ }
111
+ }
112
+ export const FIELD_APIS = ['ads', 'ga4-data', 'sa360'];
113
+ const snake = (camel) => split(camel).replaceAll(' ', '_').toLowerCase();
114
+ // Proto field behavior marks open most Ads descriptions; the sentence after them says more.
115
+ const FIELD_MARKS = /^(?:(?:Output only|Immutable|Required|Optional)\.\s*)+/;
116
+ const GAQL_ROOT = /(?:Resources__(\w+)|Common__(Metrics|Segments))$/;
117
+ const isResource = (ref) => typeof ref === 'string' && /Resources__/.test(ref);
118
+ // Singular message fields expand to their leaves (`campaign.network_settings.target_google_search`);
119
+ // arrays stay one field. A message that only holds other resources stays one field, as
120
+ // `change_event.old_resource` in Google's GAQL examples.
121
+ // shortcut: names follow the discovery shape, not Google's selectable list; selectability
122
+ // (`selectable_with`) needs googleAdsFields.search.
123
+ export function gaqlFields(model) {
124
+ const fields = [];
125
+ const walk = (schema, path, kind, seen) => {
126
+ const props = isRecord(schema.properties) ? Object.entries(schema.properties) : [];
127
+ for (const [key, prop] of props) {
128
+ if (!isRecord(prop))
129
+ continue;
130
+ const name = `${path}.${snake(key)}`;
131
+ const ref = typeof prop.$ref === 'string' ? prop.$ref : undefined;
132
+ const target = ref && !seen.includes(ref) ? model.schemas.get(ref) : undefined;
133
+ const inner = isRecord(target?.properties) ? Object.values(target.properties) : [];
134
+ if (target && ref && !inner.every((p) => isRecord(p) && isResource(p.$ref))) {
135
+ walk(target, name, kind, [...seen, ref]);
136
+ }
137
+ else {
138
+ const text = typeof prop.description === 'string' ? prop.description : '';
139
+ fields.push({ name, kind, description: text.replace(FIELD_MARKS, '') });
140
+ }
141
+ }
142
+ };
143
+ for (const [id, schema] of model.schemas) {
144
+ const m = GAQL_ROOT.exec(id);
145
+ if (!m)
146
+ continue;
147
+ const root = m[1] ? snake(m[1]) : m[2].toLowerCase();
148
+ const kind = m[1] ? 'attribute' : m[2] === 'Metrics' ? 'metric' : 'segment';
149
+ walk(schema, root, kind, [id]);
150
+ }
151
+ return fields;
152
+ }
153
+ // GA4 Metadata: dimensions and metrics; the UI name and deprecated names rank too.
154
+ export function ga4Fields(metadata) {
155
+ const list = (key, kind) => {
156
+ const items = isRecord(metadata) && Array.isArray(metadata[key]) ? metadata[key] : [];
157
+ return items.filter(isRecord).flatMap((d) => {
158
+ if (typeof d.apiName !== 'string')
159
+ return [];
160
+ const old = Array.isArray(d.deprecatedApiNames) ? d.deprecatedApiNames : [];
161
+ const words = [d.uiName, ...old].filter((w) => typeof w === 'string').join(' ');
162
+ const description = typeof d.description === 'string' ? d.description : undefined;
163
+ return [{ name: d.apiName, kind, words, description }];
164
+ });
165
+ };
166
+ return [...list('dimensions', 'dimension'), ...list('metrics', 'metric')];
167
+ }
168
+ // FTS5 bm25 normalizes by the whole row's length, so names and descriptions sit in two tables:
169
+ // a long description does not bury a short name. A description match counts a third.
170
+ export function rankFields(fields, query) {
171
+ const env_2 = { stack: [], error: void 0, hasError: false };
172
+ try {
173
+ const match = matchQuery(query, FIELDS_USAGE);
174
+ const db = __addDisposableResource(env_2, new DatabaseSync(':memory:'), false);
175
+ db.exec(`create virtual table n using fts5(i unindexed, name, words, tokenize = 'porter unicode61');
176
+ create virtual table d using fts5(i unindexed, descr, tokenize = 'porter unicode61')`);
177
+ const names = db.prepare('insert into n values (?, ?, ?)');
178
+ const descrs = db.prepare('insert into d values (?, ?)');
179
+ db.exec('begin');
180
+ fields.forEach((f, i) => {
181
+ // Each name word once: a nested path repeats words (`budget…budget`).
182
+ names.run(i, [...new Set(split(f.name).toLowerCase().split(' '))].join(' '), split(f.words ?? ''));
183
+ descrs.run(i, brief(f.description));
184
+ });
185
+ db.exec('commit');
186
+ const ranked = db
187
+ .prepare(`select i, sum(s) as score from (
188
+ select i, bm25(n, 0, 2, 1) as s from n where n match ?
189
+ union all select i, bm25(d) / 3 from d where d match ?)
190
+ group by i order by score, i`)
191
+ .all(match, match)
192
+ .map(({ i }) => fields[Number(i)]);
193
+ // Fields whose last name part is exactly the query come first, in rank order.
194
+ const key = (text) => split(text)
195
+ .toLowerCase()
196
+ .match(/[\p{L}\p{N}]+/gu)
197
+ ?.join(' ');
198
+ const exact = (f) => key(f.name.slice(f.name.lastIndexOf('.') + 1)) === key(query);
199
+ return [...ranked.filter(exact), ...ranked.filter((f) => !exact(f))].slice(0, 20);
200
+ }
201
+ catch (e_2) {
202
+ env_2.error = e_2;
203
+ env_2.hasError = true;
204
+ }
50
205
  finally {
51
- db.close();
206
+ __disposeResources(env_2);
52
207
  }
53
208
  }
@@ -1,4 +1,4 @@
1
- // Transport: sends a CallPlan and prints the response (epic #30 D13).
1
+ // Transport: sends a CallPlan and yields or prints its responses (epic #30 D13, #200 D3).
2
2
  // `authorize` adds the credential before every request; a non-2xx exits 1, a missing scope 2.
3
3
  import { setTimeout } from 'node:timers/promises';
4
4
  import { GapiError, reasonOf } from './errors.js';
@@ -7,15 +7,23 @@ import { batch } from './transport/batch.js';
7
7
  import { download } from './transport/download.js';
8
8
  import { backoff, NetworkError, RETRIES, retryAfter, retryable, } from './transport/retry.js';
9
9
  import { upload } from './transport/upload.js';
10
+ // Prints each response as one compact JSON line; `run` picks the final form (epic #200 D2).
10
11
  export async function send(plan, deps) {
12
+ for await (const data of receive(plan, deps)) {
13
+ deps.out.write(`${JSON.stringify(data)}\n`);
14
+ }
15
+ }
16
+ // Yields each decoded response of a plan; a decode result of `undefined` is not yielded.
17
+ // A batch yields its results before it throws a failure: the parts sent were acted on.
18
+ export async function* receive(plan, deps) {
11
19
  const call = caller(plan, deps);
12
- const print = (data) => deps.out.write(`${JSON.stringify(data, null, 2)}\n`);
20
+ const decode = (data, step) => (plan.decode ? plan.decode(data, step) : data);
13
21
  if (plan.send === 'upload') {
14
- print(await json(await upload(plan, call, deps.sleep ?? setTimeout)));
22
+ yield* some(decode(await json(await upload(plan, call, deps.sleep ?? setTimeout))));
15
23
  return;
16
24
  }
17
25
  if (plan.send === 'download') {
18
- print(await download(plan, call));
26
+ yield* some(decode(await download(plan, call)));
19
27
  return;
20
28
  }
21
29
  if (plan.send === 'batch') {
@@ -24,9 +32,8 @@ export async function send(plan, deps) {
24
32
  status,
25
33
  body: text ? parseOrText(text) : undefined,
26
34
  }));
27
- // Results print before any failure: the parts already sent were acted on.
28
35
  if (results.length > 0 || !sent.error)
29
- print(plan.decode ? plan.decode(results) : results);
36
+ yield* some(decode(results));
30
37
  if (sent.error)
31
38
  throw sent.error;
32
39
  const failed = sent.results.filter((r) => r.status < 200 || r.status > 299);
@@ -39,41 +46,41 @@ export async function send(plan, deps) {
39
46
  }
40
47
  return;
41
48
  }
42
- const init = {
43
- method: plan.httpMethod,
49
+ const fetchStep = async (step) => json(await call(step.url, {
50
+ method: step.httpMethod,
44
51
  headers: plan.headers,
45
- body: plan.body === undefined ? undefined : JSON.stringify(plan.body),
46
- };
52
+ body: step.body === undefined ? undefined : JSON.stringify(step.body),
53
+ }));
54
+ let step = { httpMethod: plan.httpMethod, url: plan.url, body: plan.body };
47
55
  if (plan.send === 'once') {
48
- const data = await json(await call(plan.url, init));
49
- print(plan.decode ? plan.decode(data) : data);
56
+ yield* some(decode(await fetchStep(step), step));
50
57
  return;
51
58
  }
52
- // D6: one compact JSON line per page, until no nextPageToken or pageLimit pages. The next
53
- // page repeats the call with only pageToken changed (AIP-158), in the query or the body.
54
- const url = new URL(plan.url);
55
- let pageInit = init;
56
- for (let page = 1; page <= plan.pageLimit; page++) {
57
- const data = await json(await call(url.href, pageInit));
58
- deps.out.write(`${JSON.stringify(data)}\n`);
59
- const token = isRecord(data) ? data.nextPageToken : undefined;
60
- if (typeof token !== 'string' || !token)
61
- return;
62
- if (plan.paging === 'query') {
63
- url.searchParams.set('pageToken', token);
64
- }
65
- else {
66
- pageInit = { ...init, body: JSON.stringify({ ...plan.body, pageToken: token }) };
59
+ // D3: follow the rule until it stops or pageLimit responses were yielded.
60
+ for (let yielded = 0; step && yielded < plan.pageLimit;) {
61
+ const data = await fetchStep(step);
62
+ const decoded = decode(data, step);
63
+ if (decoded !== undefined) {
64
+ yielded++;
65
+ yield decoded;
67
66
  }
67
+ step = plan.follow(data, step);
68
68
  }
69
69
  }
70
+ function* some(data) {
71
+ if (data !== undefined)
72
+ yield data;
73
+ }
70
74
  // Uploads and batches never retry here; a resumable upload resumes on its own.
71
- function caller(plan, { fetch, authorize, sleep = setTimeout }) {
75
+ function caller(plan, { fetch, authorize, signal, sleep = (ms) => setTimeout(ms, undefined, { signal }) }) {
72
76
  const retry = plan.retry && plan.send !== 'upload' && plan.send !== 'batch';
73
77
  return async (url, init, allowed) => {
74
78
  for (let attempt = 0;; attempt++) {
79
+ signal?.throwIfAborted(); // a deadline that passed during a backoff stops the retries
75
80
  const headers = { ...init.headers, ...(await authorize(plan.auth)) };
76
- const res = await fetch(url, { ...init, headers }).catch((e) => {
81
+ const res = await fetch(url, { ...init, headers, ...(signal && { signal }) }).catch((e) => {
82
+ if (signal?.aborted)
83
+ throw signal.reason; // a deadline is never retried
77
84
  const cause = e instanceof Error && e.cause !== undefined ? e.cause : e;
78
85
  return new NetworkError(1, `network: ${reasonOf(cause)}`);
79
86
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "google-apis-cli",
3
- "version": "0.3.0",
4
- "description": "One discovery-driven CLI (gapi) for 47 Google marketing and data APIs: Ads, GA4, Search Console, Tag Manager, Indexing, Merchant, Business Profile, SA360, CM360, DV360, YouTube, PageSpeed, CrUX, Sheets, Places and more. One login, read+write.",
3
+ "version": "0.4.0",
4
+ "description": "One discovery-driven CLI (gapi) for 49 Google marketing and data APIs: Ads, GA4, Search Console, Tag Manager, Indexing, Merchant, Business Profile, SA360, CM360, DV360, YouTube, PageSpeed, CrUX, BigQuery, Sheets, Places and more. One login, read+write.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "gapi": "dist/bin/gapi.js"
@@ -55,13 +55,13 @@
55
55
  "url": "https://github.com/realroboto/google-apis-cli/issues"
56
56
  },
57
57
  "dependencies": {
58
- "google-auth-library": "^11.3.3"
58
+ "google-auth-library": "^11.3.4"
59
59
  },
60
60
  "license": "MIT",
61
61
  "devDependencies": {
62
- "@biomejs/biome": "^2.6.5",
62
+ "@biomejs/biome": "^2.6.7",
63
63
  "@types/node": "^26.6.6",
64
64
  "typescript": "^7.0.2"
65
65
  },
66
- "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1"
66
+ "packageManager": "pnpm@12.18.0+sha512.9d7882d5dc82147ecfc4e062aa34834e0ad0296710a1837a1115ae012127d36b7109e321ac1598a1dd0a65597681b1e99917e12ed0d749eae91187943363beb3"
67
67
  }