google-apis-cli 0.1.0 → 0.3.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.
Files changed (70) hide show
  1. package/README.md +42 -26
  2. package/dist/bin/gapi.js +11 -135
  3. package/dist/src/apis/ads.js +59 -49
  4. package/dist/src/apis/bid-manager.js +4 -0
  5. package/dist/src/apis/bigquery-dts.js +4 -0
  6. package/dist/src/apis/business-account-management.js +5 -0
  7. package/dist/src/apis/business-information.js +5 -0
  8. package/dist/src/apis/business-lodging.js +5 -0
  9. package/dist/src/apis/business-notifications.js +5 -0
  10. package/dist/src/apis/business-performance.js +5 -0
  11. package/dist/src/apis/business-place-actions.js +5 -0
  12. package/dist/src/apis/business-v4.js +52 -0
  13. package/dist/src/apis/business-verifications.js +5 -0
  14. package/dist/src/apis/cm360.js +4 -0
  15. package/dist/src/apis/crux.js +8 -0
  16. package/dist/src/apis/data-manager.js +4 -0
  17. package/dist/src/apis/dv360.js +4 -0
  18. package/dist/src/apis/ga4-admin-alpha.js +4 -0
  19. package/dist/src/apis/ga4-admin.js +1 -288
  20. package/dist/src/apis/ga4-data-alpha.js +5 -0
  21. package/dist/src/apis/ga4-data.js +9 -69
  22. package/dist/src/apis/gsc.js +6 -58
  23. package/dist/src/apis/gtm.js +1 -189
  24. package/dist/src/apis/indexing.js +37 -15
  25. package/dist/src/apis/merchant-accounts.js +4 -0
  26. package/dist/src/apis/merchant-conversions.js +4 -0
  27. package/dist/src/apis/merchant-datasources.js +4 -0
  28. package/dist/src/apis/merchant-inventories.js +4 -0
  29. package/dist/src/apis/merchant-issueresolution.js +4 -0
  30. package/dist/src/apis/merchant-lfp.js +4 -0
  31. package/dist/src/apis/merchant-loyaltycustomers.js +4 -0
  32. package/dist/src/apis/merchant-notifications.js +4 -0
  33. package/dist/src/apis/merchant-ordertracking.js +4 -0
  34. package/dist/src/apis/merchant-products.js +4 -0
  35. package/dist/src/apis/merchant-promotions.js +4 -0
  36. package/dist/src/apis/merchant-quota.js +4 -0
  37. package/dist/src/apis/merchant-reports.js +5 -0
  38. package/dist/src/apis/merchant-reviews.js +4 -0
  39. package/dist/src/apis/pagespeed.js +8 -0
  40. package/dist/src/apis/places.js +19 -0
  41. package/dist/src/apis/postmaster.js +4 -0
  42. package/dist/src/apis/sa360-legacy.js +4 -0
  43. package/dist/src/apis/sa360.js +13 -0
  44. package/dist/src/apis/sheets.js +4 -0
  45. package/dist/src/apis/site-verification.js +4 -0
  46. package/dist/src/apis/web-risk.js +18 -0
  47. package/dist/src/apis/youtube-analytics.js +4 -0
  48. package/dist/src/apis/youtube-reporting.js +4 -0
  49. package/dist/src/apis/youtube.js +4 -0
  50. package/dist/src/catalog.js +218 -0
  51. package/dist/src/credentials.js +342 -0
  52. package/dist/src/errors.js +16 -0
  53. package/dist/src/json.js +11 -0
  54. package/dist/src/plan.js +348 -0
  55. package/dist/src/render.js +248 -0
  56. package/dist/src/responses.js +37 -0
  57. package/dist/src/run.js +374 -0
  58. package/dist/src/search.js +53 -0
  59. package/dist/src/transport/batch.js +75 -0
  60. package/dist/src/transport/download.js +21 -0
  61. package/dist/src/transport/retry.js +21 -0
  62. package/dist/src/transport/upload.js +106 -0
  63. package/dist/src/transport.js +125 -0
  64. package/dist/src/types.js +1 -13
  65. package/package.json +23 -8
  66. package/dist/src/apis/ga4-admin-v1alpha.js +0 -280
  67. package/dist/src/auth.js +0 -172
  68. package/dist/src/config.js +0 -89
  69. package/dist/src/manifest.js +0 -69
  70. package/dist/src/rest.js +0 -90
@@ -0,0 +1,125 @@
1
+ // Transport: sends a CallPlan and prints the response (epic #30 D13).
2
+ // `authorize` adds the credential before every request; a non-2xx exits 1, a missing scope 2.
3
+ import { setTimeout } from 'node:timers/promises';
4
+ import { GapiError, reasonOf } from './errors.js';
5
+ import { isRecord, parseOrText } from './json.js';
6
+ import { batch } from './transport/batch.js';
7
+ import { download } from './transport/download.js';
8
+ import { backoff, NetworkError, RETRIES, retryAfter, retryable, } from './transport/retry.js';
9
+ import { upload } from './transport/upload.js';
10
+ export async function send(plan, deps) {
11
+ const call = caller(plan, deps);
12
+ const print = (data) => deps.out.write(`${JSON.stringify(data, null, 2)}\n`);
13
+ if (plan.send === 'upload') {
14
+ print(await json(await upload(plan, call, deps.sleep ?? setTimeout)));
15
+ return;
16
+ }
17
+ if (plan.send === 'download') {
18
+ print(await download(plan, call));
19
+ return;
20
+ }
21
+ if (plan.send === 'batch') {
22
+ const sent = await batch(plan, call);
23
+ const results = sent.results.map(({ status, text }) => ({
24
+ status,
25
+ body: text ? parseOrText(text) : undefined,
26
+ }));
27
+ // Results print before any failure: the parts already sent were acted on.
28
+ if (results.length > 0 || !sent.error)
29
+ print(plan.decode ? plan.decode(results) : results);
30
+ if (sent.error)
31
+ throw sent.error;
32
+ const failed = sent.results.filter((r) => r.status < 200 || r.status > 299);
33
+ // A part that lacks a scope gets the same exit 2 and hint as a single call.
34
+ const scope = failed.map((r) => failure(plan, r.status, r.text)).find((e) => e.exit === 2);
35
+ if (scope)
36
+ throw scope;
37
+ if (failed.length > 0) {
38
+ throw new GapiError(1, `${failed.length} of ${results.length} batch parts failed`);
39
+ }
40
+ return;
41
+ }
42
+ const init = {
43
+ method: plan.httpMethod,
44
+ headers: plan.headers,
45
+ body: plan.body === undefined ? undefined : JSON.stringify(plan.body),
46
+ };
47
+ if (plan.send === 'once') {
48
+ const data = await json(await call(plan.url, init));
49
+ print(plan.decode ? plan.decode(data) : data);
50
+ return;
51
+ }
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 }) };
67
+ }
68
+ }
69
+ }
70
+ // Uploads and batches never retry here; a resumable upload resumes on its own.
71
+ function caller(plan, { fetch, authorize, sleep = setTimeout }) {
72
+ const retry = plan.retry && plan.send !== 'upload' && plan.send !== 'batch';
73
+ return async (url, init, allowed) => {
74
+ for (let attempt = 0;; attempt++) {
75
+ const headers = { ...init.headers, ...(await authorize(plan.auth)) };
76
+ const res = await fetch(url, { ...init, headers }).catch((e) => {
77
+ const cause = e instanceof Error && e.cause !== undefined ? e.cause : e;
78
+ return new NetworkError(1, `network: ${reasonOf(cause)}`);
79
+ });
80
+ const last = !retry || attempt === RETRIES;
81
+ if (res instanceof NetworkError) {
82
+ if (last)
83
+ throw res;
84
+ await sleep(backoff(attempt));
85
+ continue;
86
+ }
87
+ if (res.ok || allowed?.(res.status))
88
+ return res;
89
+ const text = await res.text();
90
+ if (last || !retryable(res.status, text))
91
+ throw failure(plan, res.status, text);
92
+ await sleep(Math.max(backoff(attempt), retryAfter(res)));
93
+ }
94
+ };
95
+ }
96
+ // A 2xx body that is not JSON is Google's surprise, not gapi's: exit 1 with the body.
97
+ async function json(res) {
98
+ const text = await res.text();
99
+ if (!text)
100
+ return {};
101
+ try {
102
+ return JSON.parse(text);
103
+ }
104
+ catch {
105
+ throw new GapiError(1, `HTTP ${res.status}: the response body is not JSON`, text);
106
+ }
107
+ }
108
+ export function failure(plan, status, text) {
109
+ // A front-end error page can be HTML; keep it as text rather than fail on parse.
110
+ // A streaming method wraps the error in an array (Go googleapi errorReplyFromBody).
111
+ const parsed = parseOrText(text);
112
+ const body = Array.isArray(parsed) ? parsed[0] : parsed;
113
+ const error = isRecord(body) && isRecord(body.error) ? body.error : {};
114
+ // D8: the stored grant lacks this method's scope; a new consent fixes it. AIP-193: the
115
+ // (reason, domain) pair of the ErrorInfo detail identifies the error.
116
+ const info = (Array.isArray(error.details) ? error.details : []).find((d) => isRecord(d) && typeof d['@type'] === 'string' && d['@type'].endsWith('google.rpc.ErrorInfo'));
117
+ if (isRecord(info) &&
118
+ info.reason === 'ACCESS_TOKEN_SCOPE_INSUFFICIENT' &&
119
+ info.domain === 'googleapis.com') {
120
+ const scopes = plan.auth.scopes.join(', ');
121
+ return new GapiError(2, `missing scope (${scopes}); run gapi auth login`, body);
122
+ }
123
+ const message = typeof error.message === 'string' ? error.message : `HTTP ${status}`;
124
+ return new GapiError(1, message, body || undefined);
125
+ }
package/dist/src/types.js CHANGED
@@ -1,13 +1 @@
1
- // Shared types for the two seams: the declarative manifest row and the
2
- // executor's injected dependencies. Kept in one file so an API slice only
3
- // imports types, never redefines the contract.
4
- // Carries the Google REST error status + body up to the CLI's catch.
5
- export class RestError extends Error {
6
- status;
7
- body;
8
- constructor(message, status, body) {
9
- super(message);
10
- this.status = status;
11
- this.body = body;
12
- }
13
- }
1
+ export {};
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "google-apis-cli",
3
- "version": "0.1.0",
4
- "description": "One CLI (gapi) for Google Ads, GTM, GA4 (Admin+Data), Search Console, and Indexing. One login, all scopes, read+write.",
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.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "gapi": "dist/bin/gapi.js"
8
8
  },
9
9
  "engines": {
10
- "node": ">=24"
10
+ "node": ">=26"
11
11
  },
12
12
  "scripts": {
13
13
  "test": "node --test",
14
14
  "typecheck": "tsc --noEmit",
15
15
  "lint": "biome check",
16
16
  "format": "biome check --write",
17
+ "discovery:check": "node scripts/discovery-check.ts",
18
+ "gen:skill": "node scripts/gen-skill.ts",
17
19
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
18
- "prepublishOnly": "pnpm typecheck && pnpm lint && pnpm test && pnpm build"
20
+ "prepublishOnly": "pnpm typecheck && pnpm lint && pnpm test && pnpm discovery:check && pnpm build"
19
21
  },
20
22
  "files": [
21
23
  "dist/"
@@ -29,6 +31,19 @@
29
31
  "analytics",
30
32
  "search-console",
31
33
  "indexing",
34
+ "merchant-api",
35
+ "business-profile",
36
+ "search-ads-360",
37
+ "campaign-manager-360",
38
+ "display-video-360",
39
+ "youtube",
40
+ "pagespeed",
41
+ "crux",
42
+ "bigquery-data-transfer",
43
+ "sheets",
44
+ "places",
45
+ "web-risk",
46
+ "discovery",
32
47
  "cli"
33
48
  ],
34
49
  "homepage": "https://github.com/realroboto/google-apis-cli#readme",
@@ -40,13 +55,13 @@
40
55
  "url": "https://github.com/realroboto/google-apis-cli/issues"
41
56
  },
42
57
  "dependencies": {
43
- "google-auth-library": "^11.0.2"
58
+ "google-auth-library": "^11.3.3"
44
59
  },
45
60
  "license": "MIT",
46
61
  "devDependencies": {
47
- "@biomejs/biome": "^2.5.13",
48
- "@types/node": "^24.13.4",
62
+ "@biomejs/biome": "^2.6.5",
63
+ "@types/node": "^26.6.6",
49
64
  "typescript": "^7.0.2"
50
65
  },
51
- "packageManager": "pnpm@12.3.4+sha512.961aa41fb077da3a04a441d9f8e15ebc0c96da8ef710b2eb67bf9ee7cb0610eabd48f1fd85f51cffe73846785fa0f87c56a3a872a1d893f8446741b5cce45457"
66
+ "packageManager": "pnpm@12.16.1+sha512.091d1dee7927575661b25dab3c2ae31cafc1c54d6b71ed6a6600cf0c1cb10434a447eae14015f1b23e2e6bedeed79df94a932e2164d2832280dce3fb32f656d1"
52
67
  }
@@ -1,280 +0,0 @@
1
- const RO = ['analytics.readonly'];
2
- const RW = ['analytics.edit'];
3
- const USERS = ['analytics.manage.users'];
4
- export default {
5
- api: 'ga4-admin-alpha',
6
- baseUrl: 'https://analyticsadmin.googleapis.com',
7
- resources: {
8
- // --- access bindings (users/permissions), gated by analytics.manage.users ---
9
- 'access-bindings': {
10
- list: {
11
- httpMethod: 'GET',
12
- pathTemplate: '/v1alpha/properties/{property}/accessBindings',
13
- scopes: USERS,
14
- listKey: 'accessBindings',
15
- },
16
- get: {
17
- httpMethod: 'GET',
18
- pathTemplate: '/v1alpha/properties/{property}/accessBindings/{accessBinding}',
19
- scopes: USERS,
20
- },
21
- // The names to fetch are a trailing positional → ?names= (query seam).
22
- 'batch-get': {
23
- httpMethod: 'GET',
24
- pathTemplate: '/v1alpha/properties/{property}/accessBindings:batchGet',
25
- scopes: USERS,
26
- query: ['names'],
27
- },
28
- create: {
29
- httpMethod: 'POST',
30
- pathTemplate: '/v1alpha/properties/{property}/accessBindings',
31
- scopes: USERS,
32
- },
33
- patch: {
34
- httpMethod: 'PATCH',
35
- pathTemplate: '/v1alpha/properties/{property}/accessBindings/{accessBinding}',
36
- scopes: USERS,
37
- },
38
- delete: {
39
- httpMethod: 'DELETE',
40
- pathTemplate: '/v1alpha/properties/{property}/accessBindings/{accessBinding}',
41
- scopes: USERS,
42
- },
43
- 'batch-create': {
44
- httpMethod: 'POST',
45
- pathTemplate: '/v1alpha/properties/{property}/accessBindings:batchCreate',
46
- scopes: USERS,
47
- },
48
- 'batch-update': {
49
- httpMethod: 'POST',
50
- pathTemplate: '/v1alpha/properties/{property}/accessBindings:batchUpdate',
51
- scopes: USERS,
52
- },
53
- 'batch-delete': {
54
- httpMethod: 'POST',
55
- pathTemplate: '/v1alpha/properties/{property}/accessBindings:batchDelete',
56
- scopes: USERS,
57
- },
58
- },
59
- 'account-access-bindings': {
60
- list: {
61
- httpMethod: 'GET',
62
- pathTemplate: '/v1alpha/accounts/{account}/accessBindings',
63
- scopes: USERS,
64
- listKey: 'accessBindings',
65
- },
66
- get: {
67
- httpMethod: 'GET',
68
- pathTemplate: '/v1alpha/accounts/{account}/accessBindings/{accessBinding}',
69
- scopes: USERS,
70
- },
71
- // The names to fetch are a trailing positional → ?names= (query seam).
72
- 'batch-get': {
73
- httpMethod: 'GET',
74
- pathTemplate: '/v1alpha/accounts/{account}/accessBindings:batchGet',
75
- scopes: USERS,
76
- query: ['names'],
77
- },
78
- create: {
79
- httpMethod: 'POST',
80
- pathTemplate: '/v1alpha/accounts/{account}/accessBindings',
81
- scopes: USERS,
82
- },
83
- patch: {
84
- httpMethod: 'PATCH',
85
- pathTemplate: '/v1alpha/accounts/{account}/accessBindings/{accessBinding}',
86
- scopes: USERS,
87
- },
88
- delete: {
89
- httpMethod: 'DELETE',
90
- pathTemplate: '/v1alpha/accounts/{account}/accessBindings/{accessBinding}',
91
- scopes: USERS,
92
- },
93
- 'batch-create': {
94
- httpMethod: 'POST',
95
- pathTemplate: '/v1alpha/accounts/{account}/accessBindings:batchCreate',
96
- scopes: USERS,
97
- },
98
- 'batch-update': {
99
- httpMethod: 'POST',
100
- pathTemplate: '/v1alpha/accounts/{account}/accessBindings:batchUpdate',
101
- scopes: USERS,
102
- },
103
- 'batch-delete': {
104
- httpMethod: 'POST',
105
- pathTemplate: '/v1alpha/accounts/{account}/accessBindings:batchDelete',
106
- scopes: USERS,
107
- },
108
- },
109
- // --- audiences (no delete: archived via :archive) ---
110
- audiences: {
111
- list: {
112
- httpMethod: 'GET',
113
- pathTemplate: '/v1alpha/properties/{property}/audiences',
114
- scopes: RO,
115
- listKey: 'audiences',
116
- },
117
- get: {
118
- httpMethod: 'GET',
119
- pathTemplate: '/v1alpha/properties/{property}/audiences/{audience}',
120
- scopes: RO,
121
- },
122
- create: {
123
- httpMethod: 'POST',
124
- pathTemplate: '/v1alpha/properties/{property}/audiences',
125
- scopes: RW,
126
- },
127
- patch: {
128
- httpMethod: 'PATCH',
129
- pathTemplate: '/v1alpha/properties/{property}/audiences/{audience}',
130
- scopes: RW,
131
- },
132
- archive: {
133
- httpMethod: 'POST',
134
- pathTemplate: '/v1alpha/properties/{property}/audiences/{audience}:archive',
135
- scopes: RW,
136
- },
137
- },
138
- 'calculated-metrics': {
139
- list: {
140
- httpMethod: 'GET',
141
- pathTemplate: '/v1alpha/properties/{property}/calculatedMetrics',
142
- scopes: RO,
143
- listKey: 'calculatedMetrics',
144
- },
145
- get: {
146
- httpMethod: 'GET',
147
- pathTemplate: '/v1alpha/properties/{property}/calculatedMetrics/{calculatedMetric}',
148
- scopes: RO,
149
- },
150
- create: {
151
- httpMethod: 'POST',
152
- pathTemplate: '/v1alpha/properties/{property}/calculatedMetrics',
153
- scopes: RW,
154
- },
155
- patch: {
156
- httpMethod: 'PATCH',
157
- pathTemplate: '/v1alpha/properties/{property}/calculatedMetrics/{calculatedMetric}',
158
- scopes: RW,
159
- },
160
- delete: {
161
- httpMethod: 'DELETE',
162
- pathTemplate: '/v1alpha/properties/{property}/calculatedMetrics/{calculatedMetric}',
163
- scopes: RW,
164
- },
165
- },
166
- 'channel-groups': {
167
- list: {
168
- httpMethod: 'GET',
169
- pathTemplate: '/v1alpha/properties/{property}/channelGroups',
170
- scopes: RO,
171
- listKey: 'channelGroups',
172
- },
173
- get: {
174
- httpMethod: 'GET',
175
- pathTemplate: '/v1alpha/properties/{property}/channelGroups/{channelGroup}',
176
- scopes: RO,
177
- },
178
- create: {
179
- httpMethod: 'POST',
180
- pathTemplate: '/v1alpha/properties/{property}/channelGroups',
181
- scopes: RW,
182
- },
183
- patch: {
184
- httpMethod: 'PATCH',
185
- pathTemplate: '/v1alpha/properties/{property}/channelGroups/{channelGroup}',
186
- scopes: RW,
187
- },
188
- delete: {
189
- httpMethod: 'DELETE',
190
- pathTemplate: '/v1alpha/properties/{property}/channelGroups/{channelGroup}',
191
- scopes: RW,
192
- },
193
- },
194
- // --- subproperties & rollups ---
195
- 'rollup-properties': {
196
- create: {
197
- httpMethod: 'POST',
198
- pathTemplate: '/v1alpha/properties:createRollupProperty',
199
- scopes: RW,
200
- },
201
- },
202
- subproperties: {
203
- provision: {
204
- httpMethod: 'POST',
205
- pathTemplate: '/v1alpha/properties:provisionSubproperty',
206
- scopes: RW,
207
- },
208
- },
209
- 'rollup-property-source-links': {
210
- list: {
211
- httpMethod: 'GET',
212
- pathTemplate: '/v1alpha/properties/{property}/rollupPropertySourceLinks',
213
- scopes: RO,
214
- listKey: 'rollupPropertySourceLinks',
215
- },
216
- get: {
217
- httpMethod: 'GET',
218
- pathTemplate: '/v1alpha/properties/{property}/rollupPropertySourceLinks/{rollupPropertySourceLink}',
219
- scopes: RO,
220
- },
221
- create: {
222
- httpMethod: 'POST',
223
- pathTemplate: '/v1alpha/properties/{property}/rollupPropertySourceLinks',
224
- scopes: RW,
225
- },
226
- delete: {
227
- httpMethod: 'DELETE',
228
- pathTemplate: '/v1alpha/properties/{property}/rollupPropertySourceLinks/{rollupPropertySourceLink}',
229
- scopes: RW,
230
- },
231
- },
232
- 'subproperty-event-filters': {
233
- list: {
234
- httpMethod: 'GET',
235
- pathTemplate: '/v1alpha/properties/{property}/subpropertyEventFilters',
236
- scopes: RO,
237
- listKey: 'subpropertyEventFilters',
238
- },
239
- get: {
240
- httpMethod: 'GET',
241
- pathTemplate: '/v1alpha/properties/{property}/subpropertyEventFilters/{subpropertyEventFilter}',
242
- scopes: RO,
243
- },
244
- create: {
245
- httpMethod: 'POST',
246
- pathTemplate: '/v1alpha/properties/{property}/subpropertyEventFilters',
247
- scopes: RW,
248
- },
249
- patch: {
250
- httpMethod: 'PATCH',
251
- pathTemplate: '/v1alpha/properties/{property}/subpropertyEventFilters/{subpropertyEventFilter}',
252
- scopes: RW,
253
- },
254
- delete: {
255
- httpMethod: 'DELETE',
256
- pathTemplate: '/v1alpha/properties/{property}/subpropertyEventFilters/{subpropertyEventFilter}',
257
- scopes: RW,
258
- },
259
- },
260
- // subproperty sync configs are auto-created → list/get/patch only.
261
- 'subproperty-sync-configs': {
262
- list: {
263
- httpMethod: 'GET',
264
- pathTemplate: '/v1alpha/properties/{property}/subpropertySyncConfigs',
265
- scopes: RO,
266
- listKey: 'subpropertySyncConfigs',
267
- },
268
- get: {
269
- httpMethod: 'GET',
270
- pathTemplate: '/v1alpha/properties/{property}/subpropertySyncConfigs/{subpropertySyncConfig}',
271
- scopes: RO,
272
- },
273
- patch: {
274
- httpMethod: 'PATCH',
275
- pathTemplate: '/v1alpha/properties/{property}/subpropertySyncConfigs/{subpropertySyncConfig}',
276
- scopes: RW,
277
- },
278
- },
279
- },
280
- };
package/dist/src/auth.js DELETED
@@ -1,172 +0,0 @@
1
- // The only module with real handlers: OAuth login, status, logout, and the
2
- // tokenProvider the executor injects. One consent grants all 13 scopes; the
3
- // refresh token is persisted and getAccessToken() rehydrates transparently.
4
- import { readFileSync, rmSync } from 'node:fs';
5
- import { createServer } from 'node:http';
6
- import { createInterface } from 'node:readline/promises';
7
- import { Writable } from 'node:stream';
8
- import { OAuth2Client } from 'google-auth-library';
9
- import { CONFIG_PATH, CONSENT_SCOPES, CREDENTIALS_PATH, getOAuthClient, readConfigFile, SCOPES, writeSecureJson, } from './config.js';
10
- const LOOPBACK_PORT = 4600; // fixed port; vmCODE forwards it for headless use
11
- function newClient(redirectUri) {
12
- const { clientId, clientSecret } = getOAuthClient();
13
- if (!clientId || !clientSecret) {
14
- throw new Error('OAuth client not configured. Set GAPI_OAUTH_CLIENT_ID and GAPI_OAUTH_CLIENT_SECRET.');
15
- }
16
- return new OAuth2Client({ clientId, clientSecret, redirectUri });
17
- }
18
- function saveCredentials(tokens) {
19
- writeSecureJson(CREDENTIALS_PATH, tokens);
20
- }
21
- export function loadCredentials() {
22
- return JSON.parse(readFileSync(CREDENTIALS_PATH, 'utf8'));
23
- }
24
- const AUTH_OPTS = { access_type: 'offline', prompt: 'consent', scope: CONSENT_SCOPES };
25
- // Decode a JWT payload without verifying (display only; it is our own token).
26
- function accountFromIdToken(idToken) {
27
- try {
28
- const payload = JSON.parse(Buffer.from(idToken.split('.')[1], 'base64url').toString());
29
- return payload.email || payload.sub || null;
30
- }
31
- catch {
32
- return null;
33
- }
34
- }
35
- // The single-consent authorization URL for a client (offline + all scopes).
36
- export function authUrl(client) {
37
- return client.generateAuthUrl(AUTH_OPTS);
38
- }
39
- // --manual: print the consent URL, read the pasted code, exchange it.
40
- export async function loginManual({ prompt, } = {}) {
41
- const client = newClient('urn:ietf:wg:oauth:2.0:oob');
42
- const url = authUrl(client);
43
- console.error(`Open this URL, grant access, then paste the code:\n${url}\n`);
44
- const ask = prompt || defaultPrompt;
45
- const code = (await ask('code: ')).trim();
46
- const { tokens } = await client.getToken(code);
47
- saveCredentials(tokens);
48
- return tokens;
49
- }
50
- // Guided setup: prompt the OAuth Desktop client id/secret and the optional Ads
51
- // login-customer-id (MCC), then merge into ~/.config/gapi/config.json (mode
52
- // 600). A blank answer keeps the current stored value. `prompt` is injectable
53
- // for tests. The secret field is masked at the prompt and goes straight to the
54
- // file. config.ts reads these back with precedence env → config → embedded.
55
- const SETUP_FIELDS = [
56
- { key: 'oauth_client_id', label: 'OAuth client id', required: true, secret: false },
57
- { key: 'oauth_client_secret', label: 'OAuth client secret', required: true, secret: true },
58
- {
59
- key: 'login-customer-id',
60
- label: 'Ads login-customer-id (optional, MCC)',
61
- required: false,
62
- secret: false,
63
- },
64
- ];
65
- export async function configureAuth({ prompt, } = {}) {
66
- const ask = prompt || defaultPrompt;
67
- const cfg = readConfigFile();
68
- for (const { key, label, required, secret } of SETUP_FIELDS) {
69
- const cur = cfg[key];
70
- const hint = cur != null ? ' [keep current]' : required ? '' : ' (blank to skip)';
71
- const v = (await ask(`${label}${hint}: `, { secret })).trim();
72
- if (v)
73
- cfg[key] = v;
74
- else if (required && cur == null)
75
- throw new Error(`${label} is required`);
76
- }
77
- writeSecureJson(CONFIG_PATH, cfg);
78
- return cfg;
79
- }
80
- // `secret: true` keeps the typed value off the screen. Node has no masking
81
- // option, so point readline's echo at a sink and print the question ourselves.
82
- // `terminal: true` is what makes this safe: it puts the tty in raw mode, so the
83
- // driver stops echoing too and the sink is the only writer left. Streams are
84
- // injectable so the masking has a test.
85
- export async function defaultPrompt(q, { secret = false, input = process.stdin, output = process.stderr, terminal, } = {}) {
86
- if (secret)
87
- output.write(q);
88
- const rl = createInterface({
89
- input,
90
- output: secret ? new Writable({ write: (_c, _e, cb) => cb() }) : output,
91
- terminal: secret ? true : terminal,
92
- });
93
- try {
94
- return await rl.question(secret ? '' : q);
95
- }
96
- finally {
97
- rl.close();
98
- if (secret)
99
- output.write('\n'); // the swallowed Enter never printed one
100
- }
101
- }
102
- // Loopback: run a local server on LOOPBACK_PORT, open the consent URL, capture
103
- // the redirect's ?code=, exchange it, shut down. `handleRedirect` is exported
104
- // for tests (simulate GET /?code=... without a real browser).
105
- export async function loginLoopback({ openUrl, } = {}) {
106
- const redirectUri = `http://127.0.0.1:${LOOPBACK_PORT}`;
107
- const client = newClient(redirectUri);
108
- const url = authUrl(client);
109
- return new Promise((resolve, reject) => {
110
- const server = createServer(async (req, res) => {
111
- try {
112
- const tokens = await handleRedirect(client, req.url || '', redirectUri);
113
- res.end('gapi: authorized. You can close this tab.');
114
- server.close();
115
- resolve(tokens);
116
- }
117
- catch (e) {
118
- res.statusCode = 400;
119
- res.end(`gapi: ${e instanceof Error ? e.message : String(e)}`);
120
- server.close();
121
- reject(e);
122
- }
123
- });
124
- server.listen(LOOPBACK_PORT, () => {
125
- console.error(`Waiting for consent at ${redirectUri}\nOpen:\n${url}`);
126
- (openUrl || (() => { }))(url);
127
- });
128
- });
129
- }
130
- // Extract ?code=, exchange for tokens, persist. Shared by loopback + tests.
131
- export async function handleRedirect(client, reqUrl, base) {
132
- const code = new URL(reqUrl, base).searchParams.get('code');
133
- if (!code)
134
- throw new Error('no code in redirect');
135
- const { tokens } = await client.getToken(code);
136
- saveCredentials(tokens);
137
- return tokens;
138
- }
139
- // tokenProvider injected into the executor: rehydrate the refresh token and
140
- // let google-auth-library refresh the access token before each call.
141
- export function makeTokenProvider({ client: injected, } = {}) {
142
- let client = injected;
143
- return async () => {
144
- if (!client) {
145
- const c = newClient();
146
- c.setCredentials(loadCredentials());
147
- c.on('tokens', (t) => {
148
- const cur = loadCredentials();
149
- saveCredentials({ ...cur, ...t });
150
- });
151
- client = c;
152
- }
153
- const { token } = await client.getAccessToken();
154
- if (!token)
155
- throw new Error('no access token; run `gapi auth login`');
156
- return token;
157
- };
158
- }
159
- export function status() {
160
- const creds = loadCredentials();
161
- const granted = (creds.scope || '').split(' ').filter(Boolean);
162
- const byUrl = Object.fromEntries(Object.entries(SCOPES).map(([k, v]) => [v, k]));
163
- return {
164
- account: creds.id_token ? accountFromIdToken(creds.id_token) : null,
165
- hasRefreshToken: Boolean(creds.refresh_token),
166
- expiryDate: creds.expiry_date ? new Date(creds.expiry_date).toISOString() : null,
167
- scopes: granted.map((u) => byUrl[u] || u),
168
- };
169
- }
170
- export function logout() {
171
- rmSync(CREDENTIALS_PATH, { force: true });
172
- }