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/credentials.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
//
|
|
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:
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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' &&
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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) {
|
package/dist/src/render.js
CHANGED
|
@@ -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.
|