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.
@@ -38,7 +38,18 @@ export function openCredentials({ dir, env, oauth = (opts) => new OAuth2Client(o
38
38
  let apiKey;
39
39
  let tokenClient;
40
40
  let saving; // the refreshed-token write the 'tokens' event starts
41
- const ask = (q, opts) => defaultPrompt(q, { ...opts, input: stdin, output: stderr });
41
+ // Each prompting command reads stdin through one session, closed when it ends (TTY raw mode).
42
+ const withPrompt = async (given, fn) => {
43
+ if (given)
44
+ return fn(given);
45
+ const p = openPrompt({ input: stdin, output: stderr });
46
+ try {
47
+ return await fn(p.ask);
48
+ }
49
+ finally {
50
+ p.close();
51
+ }
52
+ };
42
53
  // Only a missing file reads as {}: anything else stops before `setup` can overwrite it.
43
54
  // The parse error is not echoed, since V8 quotes the file text (secrets) in it.
44
55
  const readConfig = async () => {
@@ -171,7 +182,7 @@ export function openCredentials({ dir, env, oauth = (opts) => new OAuth2Client(o
171
182
  return token ? hash('sha256', token) : undefined;
172
183
  }
173
184
  // Both flows send `state` and a PKCE S256 challenge, and redeem one redirect URL.
174
- async function login({ consent, manual = false, prompt = ask, }) {
185
+ async function login({ consent, manual = false, prompt, }) {
175
186
  const c = await client(REDIRECT_URI);
176
187
  const state = randomBytes(32).toString('base64url');
177
188
  const { codeVerifier, codeChallenge } = await c.generateCodeVerifierAsync();
@@ -206,7 +217,8 @@ export function openCredentials({ dir, env, oauth = (opts) => new OAuth2Client(o
206
217
  stderr.write(`Open this URL and grant access:\n${url}\n` +
207
218
  `After consent the browser cannot load ${LOOPBACK_HOST}:${LOOPBACK_PORT}; ` +
208
219
  'paste the full URL from its address bar.\n');
209
- const redirect = URL.parse((await prompt('redirected URL: ')).trim());
220
+ const answer = await withPrompt(prompt, (ask) => ask('redirected URL: '));
221
+ const redirect = URL.parse(answer.trim());
210
222
  if (!redirect)
211
223
  throw new GapiError(2, 'not a URL; paste the full URL from the address bar');
212
224
  return redeem(redirect);
@@ -279,7 +291,7 @@ export function openCredentials({ dir, env, oauth = (opts) => new OAuth2Client(o
279
291
  await rm(credentialsFile, { force: true });
280
292
  }
281
293
  // `headers`: every entry's header specs, tagged with the entry's api.
282
- async function setup(headers, prompt = ask) {
294
+ async function setup(headers, prompt) {
283
295
  const cfg = await readConfig();
284
296
  const fields = [
285
297
  ...CLIENT_FIELDS,
@@ -291,15 +303,17 @@ export function openCredentials({ dir, env, oauth = (opts) => new OAuth2Client(o
291
303
  })),
292
304
  API_KEY_FIELD,
293
305
  ];
294
- for (const { key, label, required, secret } of fields) {
295
- const cur = cfg[key];
296
- const hint = cur != null ? ' [keep current]' : required ? '' : ' (blank to skip)';
297
- const v = (await prompt(`${label}${hint}: `, { secret })).trim();
298
- if (v)
299
- cfg[key] = v;
300
- else if (required && cur == null)
301
- throw new GapiError(3, `${label} is required`);
302
- }
306
+ await withPrompt(prompt, async (ask) => {
307
+ for (const { key, label, required, secret } of fields) {
308
+ const cur = cfg[key];
309
+ const hint = cur != null ? ' [keep current]' : required ? '' : ' (blank to skip)';
310
+ const v = (await ask(`${label}${hint}: `, { secret })).trim();
311
+ if (v)
312
+ cfg[key] = v;
313
+ else if (required && cur == null)
314
+ throw new GapiError(3, `${label} is required`);
315
+ }
316
+ });
303
317
  await writeSecure(configFile, cfg);
304
318
  return configFile;
305
319
  }
@@ -318,25 +332,47 @@ function accountFromIdToken(idToken) {
318
332
  return null;
319
333
  }
320
334
  }
321
- // `secret: true` keeps the typed value off the screen. Node has no masking
322
- // option, so point readline's echo at a sink and print the question ourselves.
323
- // `terminal: true` is what makes this safe: it puts the tty in raw mode, so the
324
- // driver stops echoing too and the sink is the only writer left. Streams are
325
- // injected (Credentials passes the ones `run` got), so the masking has a test.
326
- export async function defaultPrompt(q, { secret = false, input, output, terminal, }) {
327
- if (secret)
328
- output.write(q);
335
+ // `secret: true` keeps the typed value off the screen. Node has no masking option.
336
+ // So readline echoes through a gate that drops writes during a secret.
337
+ // TTY raw mode stops the driver echo; a pipe echoes nothing.
338
+ export function openPrompt({ input, output, terminal, }) {
339
+ let muted = false; // a secret: drop the terminal echo of what the user types
329
340
  const rl = createInterface({
330
341
  input,
331
- output: secret ? new Writable({ write: (_c, _e, cb) => cb() }) : output,
332
- terminal: secret ? true : terminal,
342
+ output: new Writable({
343
+ write: (c, _e, cb) => {
344
+ if (!muted)
345
+ output.write(c);
346
+ cb();
347
+ },
348
+ }),
349
+ terminal: terminal ?? ('isTTY' in input && input.isTTY === true),
333
350
  });
334
- try {
335
- return await rl.question(secret ? '' : q);
336
- }
337
- finally {
338
- rl.close();
339
- if (secret)
340
- output.write('\n'); // the swallowed Enter never printed one
341
- }
351
+ // One reader per session, made before any await: a pipe sends all answers in one chunk (#233).
352
+ const lines = rl[Symbol.asyncIterator]();
353
+ let ended = false; // input closed: buffered lines remain, but `rl.prompt` throws
354
+ rl.on('close', () => {
355
+ ended = true;
356
+ });
357
+ const ask = async (q, { secret = false } = {}) => {
358
+ muted = secret;
359
+ if (secret || ended)
360
+ output.write(q);
361
+ else {
362
+ rl.setPrompt(q); // terminal mode redraws the prompt on line edits
363
+ rl.prompt();
364
+ }
365
+ try {
366
+ const { value, done } = await lines.next();
367
+ if (done)
368
+ throw new GapiError(3, `input ended before an answer to: ${q.trim()}`);
369
+ return value;
370
+ }
371
+ finally {
372
+ muted = false;
373
+ if (secret)
374
+ output.write('\n'); // the swallowed Enter (TTY) or the pipe never printed one
375
+ }
376
+ };
377
+ return { ask, close: () => rl.close() };
342
378
  }
@@ -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.