google-apis-cli 0.3.0 → 0.4.1
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.
- package/README.md +14 -12
- package/dist/src/apis/ads.js +34 -0
- package/dist/src/apis/bigquery.js +191 -0
- package/dist/src/apis/business-information.js +32 -0
- package/dist/src/apis/cloudquotas.js +4 -0
- package/dist/src/apis/ga4-admin.js +47 -0
- package/dist/src/apis/gsc.js +10 -0
- package/dist/src/apis/gtm.js +35 -0
- package/dist/src/apis/merchant-accounts.js +13 -0
- package/dist/src/apis/youtube.js +15 -0
- package/dist/src/catalog.js +28 -1
- package/dist/src/credentials.js +67 -31
- package/dist/src/entities.js +180 -0
- package/dist/src/plan.js +19 -5
- package/dist/src/render.js +28 -2
- package/dist/src/run.js +180 -11
- package/dist/src/search.js +161 -6
- package/dist/src/transport.js +36 -29
- package/package.json +5 -5
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
521
|
+
printJson(io, `${JSON.stringify(request)}\n`);
|
|
353
522
|
return;
|
|
354
523
|
}
|
|
355
524
|
if (stored !== undefined && mtime !== undefined) {
|
|
356
|
-
io
|
|
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
|
|
533
|
+
return printJson(io, chunk);
|
|
365
534
|
},
|
|
366
535
|
};
|
|
367
536
|
await send(call, { fetch: io.fetch, authorize, out, sleep: io.sleep });
|
package/dist/src/search.js
CHANGED
|
@@ -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 {
|
|
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: ${
|
|
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
|
|
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
|
-
|
|
206
|
+
__disposeResources(env_2);
|
|
52
207
|
}
|
|
53
208
|
}
|
package/dist/src/transport.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Transport: sends a CallPlan and prints
|
|
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
|
|
20
|
+
const decode = (data, step) => (plan.decode ? plan.decode(data, step) : data);
|
|
13
21
|
if (plan.send === 'upload') {
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
method:
|
|
49
|
+
const fetchStep = async (step) => json(await call(step.url, {
|
|
50
|
+
method: step.httpMethod,
|
|
44
51
|
headers: plan.headers,
|
|
45
|
-
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
|
-
|
|
49
|
-
print(plan.decode ? plan.decode(data) : data);
|
|
56
|
+
yield* some(decode(await fetchStep(step), step));
|
|
50
57
|
return;
|
|
51
58
|
}
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
4
|
-
"description": "One discovery-driven CLI (gapi) for
|
|
3
|
+
"version": "0.4.1",
|
|
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.
|
|
58
|
+
"google-auth-library": "^11.3.4"
|
|
59
59
|
},
|
|
60
60
|
"license": "MIT",
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@biomejs/biome": "^2.6.
|
|
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.
|
|
66
|
+
"packageManager": "pnpm@12.18.0+sha512.9d7882d5dc82147ecfc4e062aa34834e0ad0296710a1837a1115ae012127d36b7109e321ac1598a1dd0a65597681b1e99917e12ed0d749eae91187943363beb3"
|
|
67
67
|
}
|