google-apis-cli 0.4.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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-apis-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
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": {