ytstats 0.1.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.
- package/CHANGELOG.md +40 -0
- package/LICENSE +28 -0
- package/README.md +256 -0
- package/bin/ytstats.js +9 -0
- package/docs/architecture.md +128 -0
- package/docs/auth.md +186 -0
- package/docs/cli.md +285 -0
- package/docs/configuration.md +127 -0
- package/docs/contributing.md +155 -0
- package/docs/gotchas/auth.md +121 -0
- package/docs/gotchas/cli-output.md +155 -0
- package/docs/gotchas/config-storage.md +98 -0
- package/docs/gotchas/youtube-api.md +132 -0
- package/docs/gotchas.md +10 -0
- package/docs/output-contract.md +214 -0
- package/docs/testing.md +93 -0
- package/docs/youtube-apis.md +180 -0
- package/package.json +66 -0
- package/src/api/analytics.js +259 -0
- package/src/api/client.js +40 -0
- package/src/api/data.js +65 -0
- package/src/api/reporting.js +100 -0
- package/src/api/transforms.js +170 -0
- package/src/auth/credentials.js +172 -0
- package/src/auth/oauth.js +167 -0
- package/src/auth/session.js +257 -0
- package/src/auth/tokens.js +140 -0
- package/src/cli.js +600 -0
- package/src/config/paths.js +47 -0
- package/src/config/store.js +123 -0
- package/src/dates.js +73 -0
- package/src/diagnostics.js +857 -0
- package/src/errors.js +233 -0
- package/src/fetch-all.js +181 -0
- package/src/index.js +44 -0
- package/src/output.js +168 -0
|
@@ -0,0 +1,857 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diagnostic catalog.
|
|
3
|
+
*
|
|
4
|
+
* Designed for an LLM in a retry loop, not a human reading a terminal. Every
|
|
5
|
+
* anticipated failure gets its own code and its own recovery path, because a
|
|
6
|
+
* single generic "not authenticated" forces the caller to guess which of six
|
|
7
|
+
* different problems it has.
|
|
8
|
+
*
|
|
9
|
+
* A diagnostic answers four questions:
|
|
10
|
+
* what happened title + detail
|
|
11
|
+
* why cause
|
|
12
|
+
* can I fix it recoverable / retryable
|
|
13
|
+
* what do I run now remediation.commands
|
|
14
|
+
*
|
|
15
|
+
* `code` values are public API. Add new ones freely; never repurpose an existing
|
|
16
|
+
* one, and never delete one without a major version bump.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const SEVERITY = { ERROR: 'error', WARNING: 'warning' };
|
|
20
|
+
|
|
21
|
+
export const EXIT = { OK: 0, GENERAL: 1, AUTH: 2, INPUT: 3, API: 4, PARTIAL: 5 };
|
|
22
|
+
|
|
23
|
+
const CONSOLE = 'https://console.cloud.google.com';
|
|
24
|
+
const SETUP_DOC = 'https://www.npmjs.com/package/ytstats#setup';
|
|
25
|
+
|
|
26
|
+
/** Shorthand for the credential-setup walkthrough reused by several diagnostics. */
|
|
27
|
+
const SETUP_STEPS = [
|
|
28
|
+
`Create or select a Google Cloud project: ${CONSOLE}/projectcreate`,
|
|
29
|
+
`Enable YouTube Data API v3: ${CONSOLE}/apis/library/youtube.googleapis.com`,
|
|
30
|
+
`Enable YouTube Analytics API: ${CONSOLE}/apis/library/youtubeanalytics.googleapis.com`,
|
|
31
|
+
`Enable YouTube Reporting API: ${CONSOLE}/apis/library/youtubereporting.googleapis.com`,
|
|
32
|
+
`Configure the OAuth consent screen (External), add yourself as a test user, then publish it to Production: ${CONSOLE}/apis/credentials/consent`,
|
|
33
|
+
`Create credentials > OAuth client ID > Application type "Desktop app", then download the JSON: ${CONSOLE}/apis/credentials`,
|
|
34
|
+
'Run: ytstats login --client-secret /path/to/client_secret_XXX.json',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const LOGIN_CMD = {
|
|
38
|
+
run: 'ytstats login --client-secret <path-to-client_secret.json>',
|
|
39
|
+
description: 'Authenticate with your own Google Cloud OAuth client',
|
|
40
|
+
};
|
|
41
|
+
const STATUS_CMD = {
|
|
42
|
+
run: 'ytstats status',
|
|
43
|
+
description: 'Show which channels are signed in and where credentials are stored',
|
|
44
|
+
};
|
|
45
|
+
const DOCTOR_CMD = {
|
|
46
|
+
run: 'ytstats doctor',
|
|
47
|
+
description: 'Run every readiness check and report exactly what is missing',
|
|
48
|
+
};
|
|
49
|
+
const HELP_CMD = { run: 'ytstats --help', description: 'List all commands and their options' };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @typedef {object} DiagnosticDef
|
|
53
|
+
* @property {string} code
|
|
54
|
+
* @property {string} severity
|
|
55
|
+
* @property {number} exitCode
|
|
56
|
+
* @property {boolean} recoverable the caller can fix this and retry
|
|
57
|
+
* @property {boolean} retryable retrying the same call unchanged may succeed
|
|
58
|
+
*/
|
|
59
|
+
const def = d => Object.freeze({ severity: SEVERITY.ERROR, exitCode: EXIT.GENERAL, ...d });
|
|
60
|
+
|
|
61
|
+
export const DIAGNOSTICS = {
|
|
62
|
+
// ─────────────────────────────────────────────────────────── authentication
|
|
63
|
+
|
|
64
|
+
AUTH_NO_CREDENTIALS: def({
|
|
65
|
+
code: 'AUTH_NO_CREDENTIALS',
|
|
66
|
+
exitCode: EXIT.AUTH,
|
|
67
|
+
recoverable: true,
|
|
68
|
+
retryable: false,
|
|
69
|
+
title: 'No Google OAuth client credentials found',
|
|
70
|
+
detail:
|
|
71
|
+
'ytstats ships no client ID — each user brings their own Google Cloud OAuth client. ' +
|
|
72
|
+
'None was found via --client-secret, the YTSTATS_CLIENT_ID/YTSTATS_CLIENT_SECRET environment ' +
|
|
73
|
+
'variables, previously stored credentials, or a client_secret*.json in the working directory.',
|
|
74
|
+
cause: 'The one-time Google Cloud setup has not been completed on this machine.',
|
|
75
|
+
remediation: {
|
|
76
|
+
summary: 'Create a Google Cloud OAuth client (Desktop app) and pass it to ytstats login.',
|
|
77
|
+
steps: SETUP_STEPS,
|
|
78
|
+
commands: [LOGIN_CMD, DOCTOR_CMD],
|
|
79
|
+
docs: [SETUP_DOC, `${CONSOLE}/apis/credentials`],
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
|
|
83
|
+
AUTH_NO_TOKENS: def({
|
|
84
|
+
code: 'AUTH_NO_TOKENS',
|
|
85
|
+
exitCode: EXIT.AUTH,
|
|
86
|
+
recoverable: true,
|
|
87
|
+
retryable: false,
|
|
88
|
+
title: 'Not signed in',
|
|
89
|
+
detail:
|
|
90
|
+
'OAuth client credentials are available, but no YouTube account has been authorized on this ' +
|
|
91
|
+
'machine yet. No access or refresh token is stored.',
|
|
92
|
+
cause: 'ytstats login has not been run, or the stored tokens were removed by ytstats logout.',
|
|
93
|
+
remediation: {
|
|
94
|
+
summary: 'Run the login flow once; the browser opens and the callback is captured automatically.',
|
|
95
|
+
steps: [
|
|
96
|
+
'Run: ytstats login',
|
|
97
|
+
'Approve the requested read-only scopes in the browser that opens.',
|
|
98
|
+
'On a headless or SSH machine use: ytstats login --no-browser',
|
|
99
|
+
],
|
|
100
|
+
commands: [
|
|
101
|
+
{ run: 'ytstats login', description: 'Open the browser and authorize this machine' },
|
|
102
|
+
{ run: 'ytstats login --no-browser', description: 'Headless variant: prints a URL, reads the pasted redirect' },
|
|
103
|
+
STATUS_CMD,
|
|
104
|
+
],
|
|
105
|
+
docs: [SETUP_DOC],
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
|
|
109
|
+
AUTH_TOKEN_EXPIRED: def({
|
|
110
|
+
code: 'AUTH_TOKEN_EXPIRED',
|
|
111
|
+
exitCode: EXIT.AUTH,
|
|
112
|
+
recoverable: true,
|
|
113
|
+
retryable: false,
|
|
114
|
+
title: 'Stored refresh token is no longer valid',
|
|
115
|
+
detail:
|
|
116
|
+
'Google rejected the stored refresh token (invalid_grant). The token was not merely stale — ' +
|
|
117
|
+
'automatic refresh already failed, so re-authorization is required.',
|
|
118
|
+
cause:
|
|
119
|
+
'Most commonly the OAuth consent screen is still in "Testing" mode, where Google expires refresh ' +
|
|
120
|
+
'tokens after 7 days. Also caused by a password change or 6 months of inactivity.',
|
|
121
|
+
remediation: {
|
|
122
|
+
summary: 'Sign in again, then publish your consent screen to Production so it stops recurring.',
|
|
123
|
+
steps: [
|
|
124
|
+
'Run: ytstats login',
|
|
125
|
+
`Then fix the root cause: open ${CONSOLE}/apis/credentials/consent`,
|
|
126
|
+
'If Publishing status is "Testing", click "PUBLISH APP" and confirm.',
|
|
127
|
+
'Publishing removes the 7-day refresh token expiry. Verification is not required for personal use — you click past a one-time "unverified app" warning.',
|
|
128
|
+
],
|
|
129
|
+
commands: [
|
|
130
|
+
{ run: 'ytstats login', description: 'Re-authorize this machine' },
|
|
131
|
+
DOCTOR_CMD,
|
|
132
|
+
],
|
|
133
|
+
docs: [`${CONSOLE}/apis/credentials/consent`],
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
|
|
137
|
+
AUTH_TOKEN_REVOKED: def({
|
|
138
|
+
code: 'AUTH_TOKEN_REVOKED',
|
|
139
|
+
exitCode: EXIT.AUTH,
|
|
140
|
+
recoverable: true,
|
|
141
|
+
retryable: false,
|
|
142
|
+
title: 'Access was revoked for this application',
|
|
143
|
+
detail:
|
|
144
|
+
'The stored token was explicitly revoked — either by ytstats logout elsewhere, or by removing ' +
|
|
145
|
+
'this app from the Google account permissions page.',
|
|
146
|
+
cause: 'Access removed at https://myaccount.google.com/permissions or by a prior logout.',
|
|
147
|
+
remediation: {
|
|
148
|
+
summary: 'Authorize again.',
|
|
149
|
+
steps: ['Run: ytstats login', 'Approve the requested scopes.'],
|
|
150
|
+
commands: [{ run: 'ytstats login', description: 'Re-authorize this machine' }],
|
|
151
|
+
docs: ['https://myaccount.google.com/permissions'],
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
|
|
155
|
+
AUTH_ACCOUNT_UNKNOWN: def({
|
|
156
|
+
code: 'AUTH_ACCOUNT_UNKNOWN',
|
|
157
|
+
exitCode: EXIT.AUTH,
|
|
158
|
+
recoverable: true,
|
|
159
|
+
retryable: false,
|
|
160
|
+
title: 'Requested account is not signed in',
|
|
161
|
+
detail: 'The --account selector did not match any signed-in channel on this machine.',
|
|
162
|
+
cause: 'A channel id or @handle was given that has never been authorized here, or a typo.',
|
|
163
|
+
remediation: {
|
|
164
|
+
summary: 'List the signed-in channels and use one of them, or log in to the one you want.',
|
|
165
|
+
steps: [
|
|
166
|
+
'Run: ytstats status — the accounts array lists every signed-in channel id and handle.',
|
|
167
|
+
'Re-run your command with --account <channelId> using a value from that list.',
|
|
168
|
+
'Or run ytstats login to add the missing channel.',
|
|
169
|
+
],
|
|
170
|
+
commands: [STATUS_CMD, { run: 'ytstats login', description: 'Add another channel' }],
|
|
171
|
+
docs: [SETUP_DOC],
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
|
|
175
|
+
AUTH_CONSENT_DECLINED: def({
|
|
176
|
+
code: 'AUTH_CONSENT_DECLINED',
|
|
177
|
+
exitCode: EXIT.AUTH,
|
|
178
|
+
recoverable: true,
|
|
179
|
+
retryable: true,
|
|
180
|
+
title: 'Authorization was declined in the browser',
|
|
181
|
+
detail: 'Google returned access_denied — the consent screen was dismissed or a scope was refused.',
|
|
182
|
+
cause: 'The user clicked Cancel, or the Google account is not listed as a test user on the consent screen.',
|
|
183
|
+
remediation: {
|
|
184
|
+
summary: 'Retry the login and approve all scopes; if it fails again, add yourself as a test user.',
|
|
185
|
+
steps: [
|
|
186
|
+
'Run: ytstats login',
|
|
187
|
+
'Approve every requested scope — all three are read-only.',
|
|
188
|
+
`If the consent screen refuses your account, add it as a test user: ${CONSOLE}/apis/credentials/consent`,
|
|
189
|
+
],
|
|
190
|
+
commands: [{ run: 'ytstats login', description: 'Retry authorization' }],
|
|
191
|
+
docs: [`${CONSOLE}/apis/credentials/consent`],
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
|
|
195
|
+
AUTH_TIMEOUT: def({
|
|
196
|
+
code: 'AUTH_TIMEOUT',
|
|
197
|
+
exitCode: EXIT.AUTH,
|
|
198
|
+
recoverable: true,
|
|
199
|
+
retryable: false,
|
|
200
|
+
title: 'Browser sign-in did not complete in time',
|
|
201
|
+
detail:
|
|
202
|
+
'The local callback listener timed out waiting for Google to redirect back. Note that a plain ' +
|
|
203
|
+
'retry usually does NOT help: the most common cause is that Google refused the request in the ' +
|
|
204
|
+
'browser and never redirected at all.',
|
|
205
|
+
cause:
|
|
206
|
+
'Most often Google showed "Access blocked: Authorization Error" because the OAuth client is not ' +
|
|
207
|
+
'usable — wrong/typo\'d client ID, the client was deleted, it belongs to a different project than ' +
|
|
208
|
+
'the enabled APIs, or the consent screen is unconfigured. Less often: the flow was abandoned, or ' +
|
|
209
|
+
'the machine has no browser.',
|
|
210
|
+
remediation: {
|
|
211
|
+
summary:
|
|
212
|
+
'Check what the browser actually showed. If it said "Access blocked", fix the OAuth client — ' +
|
|
213
|
+
'retrying the same command will keep timing out.',
|
|
214
|
+
steps: [
|
|
215
|
+
'If the browser showed "Access blocked: Authorization Error": the OAuth client is the problem, not the timeout.',
|
|
216
|
+
` - Confirm the client ID still exists and is of type "Desktop app": ${CONSOLE}/apis/credentials`,
|
|
217
|
+
` - Confirm the OAuth consent screen is configured and you are a test user (or it is published): ${CONSOLE}/apis/credentials/consent`,
|
|
218
|
+
' - Re-download the client JSON and pass it again with --client-secret.',
|
|
219
|
+
'If the browser never opened, run: ytstats login --no-browser',
|
|
220
|
+
'If you simply walked away, re-run: ytstats login',
|
|
221
|
+
],
|
|
222
|
+
commands: [
|
|
223
|
+
{ run: 'ytstats login --client-secret <path>', description: 'Retry with a freshly downloaded OAuth client' },
|
|
224
|
+
{ run: 'ytstats login --no-browser', description: 'Paste-the-URL flow for headless machines' },
|
|
225
|
+
DOCTOR_CMD,
|
|
226
|
+
],
|
|
227
|
+
docs: [`${CONSOLE}/apis/credentials`, `${CONSOLE}/apis/credentials/consent`],
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
|
|
231
|
+
AUTH_CLIENT_ID_INVALID: def({
|
|
232
|
+
code: 'AUTH_CLIENT_ID_INVALID',
|
|
233
|
+
exitCode: EXIT.AUTH,
|
|
234
|
+
recoverable: true,
|
|
235
|
+
retryable: false,
|
|
236
|
+
title: 'OAuth client ID is not a valid Google client ID',
|
|
237
|
+
detail:
|
|
238
|
+
'Google client IDs always end in .apps.googleusercontent.com. Sending a malformed one produces ' +
|
|
239
|
+
'"Access blocked: Authorization Error" in the browser and no redirect ever arrives, so the login ' +
|
|
240
|
+
'would hang until it timed out. Rejected up front instead.',
|
|
241
|
+
cause: 'A hand-edited, truncated, placeholder, or non-Google client ID.',
|
|
242
|
+
remediation: {
|
|
243
|
+
summary: 'Download the real OAuth client JSON from Google Cloud and pass that file unmodified.',
|
|
244
|
+
steps: [
|
|
245
|
+
`Open ${CONSOLE}/apis/credentials`,
|
|
246
|
+
'Under "OAuth 2.0 Client IDs", pick the Desktop app client (create one if there is none).',
|
|
247
|
+
'Click the download icon — do not hand-edit the file.',
|
|
248
|
+
'Run: ytstats login --client-secret /path/to/that/file.json',
|
|
249
|
+
],
|
|
250
|
+
commands: [LOGIN_CMD, DOCTOR_CMD],
|
|
251
|
+
docs: [`${CONSOLE}/apis/credentials`],
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
|
|
255
|
+
AUTH_CLIENT_ID_SUSPICIOUS: def({
|
|
256
|
+
code: 'AUTH_CLIENT_ID_SUSPICIOUS',
|
|
257
|
+
severity: SEVERITY.WARNING,
|
|
258
|
+
exitCode: EXIT.OK,
|
|
259
|
+
recoverable: true,
|
|
260
|
+
retryable: false,
|
|
261
|
+
title: 'OAuth client ID does not look like one Google issues',
|
|
262
|
+
detail:
|
|
263
|
+
'Real client IDs look like 123456789012-abc123def456.apps.googleusercontent.com. This one has the ' +
|
|
264
|
+
'right suffix but not the usual <project-number>-<hash> form. If Google shows "Access blocked" in ' +
|
|
265
|
+
'the browser, this is why.',
|
|
266
|
+
cause: 'A trimmed, placeholder, or legacy client ID.',
|
|
267
|
+
remediation: {
|
|
268
|
+
summary: 'Proceeding anyway — but if the browser shows "Access blocked", re-download the client JSON.',
|
|
269
|
+
steps: [
|
|
270
|
+
`If sign-in fails, re-download the OAuth client from ${CONSOLE}/apis/credentials`,
|
|
271
|
+
'Pass the downloaded file unmodified to --client-secret.',
|
|
272
|
+
],
|
|
273
|
+
commands: [LOGIN_CMD],
|
|
274
|
+
docs: [`${CONSOLE}/apis/credentials`],
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
|
|
278
|
+
AUTH_STATE_MISMATCH: def({
|
|
279
|
+
code: 'AUTH_STATE_MISMATCH',
|
|
280
|
+
exitCode: EXIT.AUTH,
|
|
281
|
+
recoverable: true,
|
|
282
|
+
retryable: true,
|
|
283
|
+
title: 'Sign-in aborted by a security check',
|
|
284
|
+
detail:
|
|
285
|
+
'The OAuth state parameter returned by the browser did not match the one this process generated, ' +
|
|
286
|
+
'so the authorization code was discarded.',
|
|
287
|
+
cause: 'A stale browser tab from an earlier login attempt, or another process answering on the callback port.',
|
|
288
|
+
remediation: {
|
|
289
|
+
summary: 'Close old sign-in tabs and run login again.',
|
|
290
|
+
steps: ['Close any leftover Google sign-in tabs.', 'Run: ytstats login'],
|
|
291
|
+
commands: [{ run: 'ytstats login', description: 'Start a fresh authorization' }],
|
|
292
|
+
docs: [SETUP_DOC],
|
|
293
|
+
},
|
|
294
|
+
}),
|
|
295
|
+
|
|
296
|
+
AUTH_SERVICE_ACCOUNT: def({
|
|
297
|
+
code: 'AUTH_SERVICE_ACCOUNT',
|
|
298
|
+
exitCode: EXIT.AUTH,
|
|
299
|
+
recoverable: false,
|
|
300
|
+
retryable: false,
|
|
301
|
+
title: 'Service account keys cannot be used with YouTube APIs',
|
|
302
|
+
detail:
|
|
303
|
+
'The supplied file is a service account key. A service account owns no YouTube channel and there ' +
|
|
304
|
+
'is no way to link one, so Google rejects it with NoLinkedYouTubeAccount. This is a platform ' +
|
|
305
|
+
'limitation with no workaround — domain-wide delegation does not help either.',
|
|
306
|
+
cause: 'A service account JSON was passed where an OAuth client ID was expected.',
|
|
307
|
+
remediation: {
|
|
308
|
+
summary: 'Create an OAuth client ID of type "Desktop app" instead. Do not retry with a service account.',
|
|
309
|
+
steps: [
|
|
310
|
+
`Open ${CONSOLE}/apis/credentials`,
|
|
311
|
+
'Create credentials > OAuth client ID > Application type: Desktop app.',
|
|
312
|
+
'Download that JSON and pass it to ytstats login --client-secret.',
|
|
313
|
+
],
|
|
314
|
+
commands: [LOGIN_CMD],
|
|
315
|
+
docs: ['https://developers.google.com/youtube/v3/guides/authentication'],
|
|
316
|
+
},
|
|
317
|
+
}),
|
|
318
|
+
|
|
319
|
+
AUTH_NO_CHANNEL: def({
|
|
320
|
+
code: 'AUTH_NO_CHANNEL',
|
|
321
|
+
exitCode: EXIT.AUTH,
|
|
322
|
+
recoverable: true,
|
|
323
|
+
retryable: false,
|
|
324
|
+
title: 'Signed-in Google account has no YouTube channel',
|
|
325
|
+
detail:
|
|
326
|
+
'Authorization succeeded but channels.list returned nothing, so this Google account owns no channel ' +
|
|
327
|
+
'and there is no analytics data to read.',
|
|
328
|
+
cause: 'Signed in with the wrong Google account, or a Brand Account channel not owned by this login.',
|
|
329
|
+
remediation: {
|
|
330
|
+
summary: 'Log out and sign in with the Google account that owns the channel.',
|
|
331
|
+
steps: [
|
|
332
|
+
'Run: ytstats logout',
|
|
333
|
+
'Run: ytstats login — and pick the Google account that owns the channel.',
|
|
334
|
+
'For a Brand Account, choose the brand profile on the account chooser screen.',
|
|
335
|
+
],
|
|
336
|
+
commands: [
|
|
337
|
+
{ run: 'ytstats logout', description: 'Forget the current account' },
|
|
338
|
+
{ run: 'ytstats login', description: 'Sign in with the channel owner account' },
|
|
339
|
+
],
|
|
340
|
+
docs: [SETUP_DOC],
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
|
|
344
|
+
AUTH_CREDENTIALS_MALFORMED: def({
|
|
345
|
+
code: 'AUTH_CREDENTIALS_MALFORMED',
|
|
346
|
+
exitCode: EXIT.AUTH,
|
|
347
|
+
recoverable: true,
|
|
348
|
+
retryable: false,
|
|
349
|
+
title: 'Credential file could not be read as an OAuth client',
|
|
350
|
+
detail:
|
|
351
|
+
'The file exists but is not the JSON Google produces for an OAuth client ID — it lacks a usable ' +
|
|
352
|
+
'client_id/client_secret pair under "installed" or "web".',
|
|
353
|
+
cause: 'Wrong file passed, a truncated download, or an API key instead of an OAuth client.',
|
|
354
|
+
remediation: {
|
|
355
|
+
summary: 'Re-download the OAuth client JSON and pass that exact file.',
|
|
356
|
+
steps: [
|
|
357
|
+
`Open ${CONSOLE}/apis/credentials`,
|
|
358
|
+
'Find your OAuth 2.0 Client ID (Desktop app) and click the download icon.',
|
|
359
|
+
'Pass that file: ytstats login --client-secret /path/to/downloaded.json',
|
|
360
|
+
],
|
|
361
|
+
commands: [LOGIN_CMD],
|
|
362
|
+
docs: [`${CONSOLE}/apis/credentials`],
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
|
|
366
|
+
AUTH_CREDENTIALS_NOT_FOUND: def({
|
|
367
|
+
code: 'AUTH_CREDENTIALS_NOT_FOUND',
|
|
368
|
+
exitCode: EXIT.AUTH,
|
|
369
|
+
recoverable: true,
|
|
370
|
+
retryable: false,
|
|
371
|
+
title: 'Credential file does not exist at the given path',
|
|
372
|
+
detail: 'The path passed to --client-secret could not be opened.',
|
|
373
|
+
cause: 'Typo in the path, a relative path resolved from an unexpected working directory, or the file was moved.',
|
|
374
|
+
remediation: {
|
|
375
|
+
summary: 'Check the path and pass an absolute one.',
|
|
376
|
+
steps: [
|
|
377
|
+
'Confirm the file exists at that exact path.',
|
|
378
|
+
'Prefer an absolute path — relative paths resolve from the current working directory.',
|
|
379
|
+
'Alternatively place it as client_secret.json in the working directory and run: ytstats login',
|
|
380
|
+
],
|
|
381
|
+
commands: [LOGIN_CMD, DOCTOR_CMD],
|
|
382
|
+
docs: [SETUP_DOC],
|
|
383
|
+
},
|
|
384
|
+
}),
|
|
385
|
+
|
|
386
|
+
// ──────────────────────────────────────────────────────────── Google APIs
|
|
387
|
+
|
|
388
|
+
API_NOT_ENABLED: def({
|
|
389
|
+
code: 'API_NOT_ENABLED',
|
|
390
|
+
exitCode: EXIT.API,
|
|
391
|
+
recoverable: true,
|
|
392
|
+
retryable: true,
|
|
393
|
+
title: 'A required YouTube API is not enabled in your Google Cloud project',
|
|
394
|
+
detail:
|
|
395
|
+
'Google rejected the request with accessNotConfigured. ytstats needs all three YouTube APIs enabled ' +
|
|
396
|
+
'in the same project that issued your OAuth client.',
|
|
397
|
+
cause: 'One or more of the three APIs was never enabled, or was enabled in a different project.',
|
|
398
|
+
remediation: {
|
|
399
|
+
summary: 'Enable all three APIs in the project that owns your OAuth client, then retry.',
|
|
400
|
+
steps: [
|
|
401
|
+
`Enable YouTube Data API v3: ${CONSOLE}/apis/library/youtube.googleapis.com`,
|
|
402
|
+
`Enable YouTube Analytics API: ${CONSOLE}/apis/library/youtubeanalytics.googleapis.com`,
|
|
403
|
+
`Enable YouTube Reporting API: ${CONSOLE}/apis/library/youtubereporting.googleapis.com`,
|
|
404
|
+
'Enabling can take up to a minute to propagate. Then retry the same command.',
|
|
405
|
+
],
|
|
406
|
+
commands: [DOCTOR_CMD],
|
|
407
|
+
docs: [`${CONSOLE}/apis/library`],
|
|
408
|
+
},
|
|
409
|
+
}),
|
|
410
|
+
|
|
411
|
+
API_QUOTA_EXCEEDED: def({
|
|
412
|
+
code: 'API_QUOTA_EXCEEDED',
|
|
413
|
+
exitCode: EXIT.API,
|
|
414
|
+
recoverable: true,
|
|
415
|
+
retryable: true,
|
|
416
|
+
title: 'YouTube API quota exhausted for your Google Cloud project',
|
|
417
|
+
detail:
|
|
418
|
+
'The Data API allows 10,000 units per day per project. That budget is spent. The quota resets at ' +
|
|
419
|
+
'midnight Pacific Time.',
|
|
420
|
+
cause: 'Too many calls today — commonly a large fetch with retention enabled, which costs one call per video.',
|
|
421
|
+
remediation: {
|
|
422
|
+
summary: 'Wait for the reset, or reduce the cost of the call.',
|
|
423
|
+
steps: [
|
|
424
|
+
'Wait until midnight Pacific Time for the daily reset.',
|
|
425
|
+
'Reduce cost meanwhile: ytstats fetch --no-retention (retention is one API call per video)',
|
|
426
|
+
'Or narrow the window: ytstats fetch --days 7',
|
|
427
|
+
`Inspect usage: ${CONSOLE}/apis/dashboard`,
|
|
428
|
+
],
|
|
429
|
+
commands: [
|
|
430
|
+
{ run: 'ytstats fetch --no-retention', description: 'Cheapest full fetch — skips per-video retention' },
|
|
431
|
+
{ run: 'ytstats fetch --days 7 --no-retention', description: 'Cheaper still: narrow window, no retention' },
|
|
432
|
+
],
|
|
433
|
+
docs: [`${CONSOLE}/apis/dashboard`],
|
|
434
|
+
},
|
|
435
|
+
}),
|
|
436
|
+
|
|
437
|
+
API_RATE_LIMITED: def({
|
|
438
|
+
code: 'API_RATE_LIMITED',
|
|
439
|
+
exitCode: EXIT.API,
|
|
440
|
+
recoverable: true,
|
|
441
|
+
retryable: true,
|
|
442
|
+
title: 'Temporarily rate limited by YouTube',
|
|
443
|
+
detail: 'Google returned a rate limit response. This is transient, unlike a daily quota exhaustion.',
|
|
444
|
+
cause: 'Too many requests in a short window.',
|
|
445
|
+
remediation: {
|
|
446
|
+
summary: 'Wait a few seconds and retry the same command.',
|
|
447
|
+
steps: ['Wait 5-30 seconds.', 'Retry the identical command — no changes needed.'],
|
|
448
|
+
commands: [DOCTOR_CMD],
|
|
449
|
+
docs: [`${CONSOLE}/apis/dashboard`],
|
|
450
|
+
},
|
|
451
|
+
}),
|
|
452
|
+
|
|
453
|
+
API_QUERY_NOT_SUPPORTED: def({
|
|
454
|
+
code: 'API_QUERY_NOT_SUPPORTED',
|
|
455
|
+
exitCode: EXIT.API,
|
|
456
|
+
recoverable: true,
|
|
457
|
+
retryable: false,
|
|
458
|
+
title: 'YouTube rejected this metric/dimension combination',
|
|
459
|
+
detail:
|
|
460
|
+
'The Analytics API refuses many otherwise-documented combinations, and which ones are refused varies ' +
|
|
461
|
+
'by channel. Notably videoThumbnailImpressions never works on channel reports — use ytstats reach instead.',
|
|
462
|
+
cause: 'An unsupported metrics/dimensions/filters combination for this channel.',
|
|
463
|
+
remediation: {
|
|
464
|
+
summary: 'Simplify the query, or use a purpose-built command instead of a raw query.',
|
|
465
|
+
steps: [
|
|
466
|
+
'Drop dimensions one at a time until the query is accepted.',
|
|
467
|
+
'For thumbnail impressions or CTR use: ytstats reach (the Analytics API cannot serve these).',
|
|
468
|
+
'Check the supported combinations in the channel reports reference.',
|
|
469
|
+
],
|
|
470
|
+
commands: [
|
|
471
|
+
{ run: 'ytstats reach', description: 'The only working source of impressions and CTR' },
|
|
472
|
+
{ run: 'ytstats query -m views --dimensions day', description: 'A minimal query known to work' },
|
|
473
|
+
],
|
|
474
|
+
docs: ['https://developers.google.com/youtube/analytics/channel_reports'],
|
|
475
|
+
},
|
|
476
|
+
}),
|
|
477
|
+
|
|
478
|
+
API_FORBIDDEN: def({
|
|
479
|
+
code: 'API_FORBIDDEN',
|
|
480
|
+
exitCode: EXIT.API,
|
|
481
|
+
recoverable: true,
|
|
482
|
+
retryable: false,
|
|
483
|
+
title: 'YouTube denied access to this resource',
|
|
484
|
+
detail: 'Authentication succeeded but the account is not permitted to read the requested data.',
|
|
485
|
+
cause: 'The signed-in account does not own the channel or video, or a required scope was not granted.',
|
|
486
|
+
remediation: {
|
|
487
|
+
summary: 'Confirm the account owns the resource and re-authorize to grant all scopes.',
|
|
488
|
+
steps: [
|
|
489
|
+
'Run: ytstats status — confirm the signed-in channel is the one you expect.',
|
|
490
|
+
'If it is wrong: ytstats logout, then ytstats login with the owning account.',
|
|
491
|
+
'If it is right, re-run login and approve every requested scope.',
|
|
492
|
+
],
|
|
493
|
+
commands: [STATUS_CMD, { run: 'ytstats login', description: 'Re-authorize with all scopes' }],
|
|
494
|
+
docs: [SETUP_DOC],
|
|
495
|
+
},
|
|
496
|
+
}),
|
|
497
|
+
|
|
498
|
+
API_NOT_FOUND: def({
|
|
499
|
+
code: 'API_NOT_FOUND',
|
|
500
|
+
exitCode: EXIT.API,
|
|
501
|
+
recoverable: true,
|
|
502
|
+
retryable: false,
|
|
503
|
+
title: 'Requested resource does not exist',
|
|
504
|
+
detail: 'YouTube returned 404 for the requested id.',
|
|
505
|
+
cause: 'A video id that is wrong, deleted, or belongs to another channel.',
|
|
506
|
+
remediation: {
|
|
507
|
+
summary: 'Verify the id against your own video list.',
|
|
508
|
+
steps: [
|
|
509
|
+
'Run: ytstats videos — the data array contains every valid video id for this channel.',
|
|
510
|
+
'Re-run with an id from that list.',
|
|
511
|
+
],
|
|
512
|
+
commands: [{ run: 'ytstats videos', description: 'List all valid video ids for this channel' }],
|
|
513
|
+
docs: [SETUP_DOC],
|
|
514
|
+
},
|
|
515
|
+
}),
|
|
516
|
+
|
|
517
|
+
API_UNAVAILABLE: def({
|
|
518
|
+
code: 'API_UNAVAILABLE',
|
|
519
|
+
exitCode: EXIT.API,
|
|
520
|
+
recoverable: true,
|
|
521
|
+
retryable: true,
|
|
522
|
+
title: 'YouTube API is temporarily unavailable',
|
|
523
|
+
detail: 'Google returned a 5xx server error. Nothing is wrong with the request.',
|
|
524
|
+
cause: 'Transient Google-side outage or overload.',
|
|
525
|
+
remediation: {
|
|
526
|
+
summary: 'Retry the identical command after a short wait.',
|
|
527
|
+
steps: ['Wait 10-60 seconds.', 'Retry the same command unchanged.', 'If it persists, check the Google Cloud status dashboard.'],
|
|
528
|
+
commands: [DOCTOR_CMD],
|
|
529
|
+
docs: ['https://status.cloud.google.com/'],
|
|
530
|
+
},
|
|
531
|
+
}),
|
|
532
|
+
|
|
533
|
+
NETWORK_UNREACHABLE: def({
|
|
534
|
+
code: 'NETWORK_UNREACHABLE',
|
|
535
|
+
exitCode: EXIT.API,
|
|
536
|
+
recoverable: true,
|
|
537
|
+
retryable: true,
|
|
538
|
+
title: 'Could not reach Google',
|
|
539
|
+
detail: 'The HTTP request failed before a response was received — DNS, TLS, proxy, or no connectivity.',
|
|
540
|
+
cause: 'No internet access, a firewall, or a proxy intercepting the connection.',
|
|
541
|
+
remediation: {
|
|
542
|
+
summary: 'Restore connectivity to googleapis.com and retry.',
|
|
543
|
+
steps: [
|
|
544
|
+
'Confirm this machine has internet access.',
|
|
545
|
+
'Confirm oauth2.googleapis.com and www.googleapis.com are reachable and not blocked.',
|
|
546
|
+
'If behind a proxy, set HTTPS_PROXY before retrying.',
|
|
547
|
+
],
|
|
548
|
+
commands: [DOCTOR_CMD],
|
|
549
|
+
docs: ['https://status.cloud.google.com/'],
|
|
550
|
+
},
|
|
551
|
+
}),
|
|
552
|
+
|
|
553
|
+
// ──────────────────────────────────────────────────────────────── input
|
|
554
|
+
|
|
555
|
+
INPUT_UNKNOWN_COMMAND: def({
|
|
556
|
+
code: 'INPUT_UNKNOWN_COMMAND',
|
|
557
|
+
exitCode: EXIT.INPUT,
|
|
558
|
+
recoverable: true,
|
|
559
|
+
retryable: false,
|
|
560
|
+
title: 'Unknown command',
|
|
561
|
+
detail: 'That command does not exist.',
|
|
562
|
+
cause: 'A typo, or a command from a different version of the CLI.',
|
|
563
|
+
remediation: {
|
|
564
|
+
summary: 'Use one of the supported commands.',
|
|
565
|
+
steps: [
|
|
566
|
+
'Run: ytstats --help — lists every command.',
|
|
567
|
+
'The allowed set is included in this diagnostic under context.allowed.',
|
|
568
|
+
],
|
|
569
|
+
commands: [HELP_CMD],
|
|
570
|
+
docs: [SETUP_DOC],
|
|
571
|
+
},
|
|
572
|
+
}),
|
|
573
|
+
|
|
574
|
+
INPUT_UNKNOWN_OPTION: def({
|
|
575
|
+
code: 'INPUT_UNKNOWN_OPTION',
|
|
576
|
+
exitCode: EXIT.INPUT,
|
|
577
|
+
recoverable: true,
|
|
578
|
+
retryable: false,
|
|
579
|
+
title: 'Unknown option',
|
|
580
|
+
detail: 'That flag is not recognised by this command.',
|
|
581
|
+
cause: 'A typo, or a flag that belongs to a different subcommand.',
|
|
582
|
+
remediation: {
|
|
583
|
+
summary: 'Check the flags this specific command accepts.',
|
|
584
|
+
steps: ['Run: ytstats <command> --help — lists the flags valid for that command.'],
|
|
585
|
+
commands: [HELP_CMD],
|
|
586
|
+
docs: [SETUP_DOC],
|
|
587
|
+
},
|
|
588
|
+
}),
|
|
589
|
+
|
|
590
|
+
INPUT_MISSING_REQUIRED: def({
|
|
591
|
+
code: 'INPUT_MISSING_REQUIRED',
|
|
592
|
+
exitCode: EXIT.INPUT,
|
|
593
|
+
recoverable: true,
|
|
594
|
+
retryable: false,
|
|
595
|
+
title: 'Required option missing',
|
|
596
|
+
detail: 'A mandatory option was not supplied.',
|
|
597
|
+
cause: 'The command cannot run without this value.',
|
|
598
|
+
remediation: {
|
|
599
|
+
summary: 'Supply the missing option and re-run.',
|
|
600
|
+
steps: ['Add the flag named in context.flag.', 'Run: ytstats <command> --help to see its expected value.'],
|
|
601
|
+
commands: [HELP_CMD],
|
|
602
|
+
docs: [SETUP_DOC],
|
|
603
|
+
},
|
|
604
|
+
}),
|
|
605
|
+
|
|
606
|
+
INPUT_INVALID_CHOICE: def({
|
|
607
|
+
code: 'INPUT_INVALID_CHOICE',
|
|
608
|
+
exitCode: EXIT.INPUT,
|
|
609
|
+
recoverable: true,
|
|
610
|
+
retryable: false,
|
|
611
|
+
title: 'Option value is not one of the allowed choices',
|
|
612
|
+
detail: 'The value supplied is outside the permitted set.',
|
|
613
|
+
cause: 'A value not in the enumerated list for this flag.',
|
|
614
|
+
remediation: {
|
|
615
|
+
summary: 'Re-run using one of the allowed values listed in context.allowed.',
|
|
616
|
+
steps: ['Pick a value from context.allowed.', 'Re-run the command with that value.'],
|
|
617
|
+
commands: [HELP_CMD],
|
|
618
|
+
docs: [SETUP_DOC],
|
|
619
|
+
},
|
|
620
|
+
}),
|
|
621
|
+
|
|
622
|
+
INPUT_INVALID_DATE: def({
|
|
623
|
+
code: 'INPUT_INVALID_DATE',
|
|
624
|
+
exitCode: EXIT.INPUT,
|
|
625
|
+
recoverable: true,
|
|
626
|
+
retryable: false,
|
|
627
|
+
title: 'Date is not in the expected format',
|
|
628
|
+
detail: 'Dates must be ISO calendar dates formatted YYYY-MM-DD.',
|
|
629
|
+
cause: 'A locale-style date (01/01/2026), a natural-language date, or a calendar-invalid day.',
|
|
630
|
+
// Guaranteed on the diagnostic even when a call site forgets to pass it.
|
|
631
|
+
defaults: { expected: 'YYYY-MM-DD (e.g. 2026-01-01)' },
|
|
632
|
+
remediation: {
|
|
633
|
+
summary: 'Re-run using YYYY-MM-DD.',
|
|
634
|
+
steps: [
|
|
635
|
+
'Format the date as YYYY-MM-DD, e.g. 2026-01-01.',
|
|
636
|
+
'Or drop --start/--end and use --days <n> for a relative window.',
|
|
637
|
+
],
|
|
638
|
+
commands: [
|
|
639
|
+
{ run: 'ytstats daily --days 30', description: 'Relative window, no date parsing needed' },
|
|
640
|
+
HELP_CMD,
|
|
641
|
+
],
|
|
642
|
+
docs: [SETUP_DOC],
|
|
643
|
+
},
|
|
644
|
+
}),
|
|
645
|
+
|
|
646
|
+
INPUT_INVALID_RANGE: def({
|
|
647
|
+
code: 'INPUT_INVALID_RANGE',
|
|
648
|
+
exitCode: EXIT.INPUT,
|
|
649
|
+
recoverable: true,
|
|
650
|
+
retryable: false,
|
|
651
|
+
title: 'Date range is inverted or out of bounds',
|
|
652
|
+
detail: 'The start date must be on or before the end date, and --days must be a positive number.',
|
|
653
|
+
cause: 'Start and end were swapped, or a non-positive/absurd day count was supplied.',
|
|
654
|
+
defaults: { expected: '--start on or before --end; --days a positive integer' },
|
|
655
|
+
remediation: {
|
|
656
|
+
summary: 'Correct the range and re-run.',
|
|
657
|
+
steps: ['Ensure --start is on or before --end.', 'Ensure --days is a positive integer.'],
|
|
658
|
+
commands: [{ run: 'ytstats daily --days 30', description: 'A known-good relative window' }, HELP_CMD],
|
|
659
|
+
docs: [SETUP_DOC],
|
|
660
|
+
},
|
|
661
|
+
}),
|
|
662
|
+
|
|
663
|
+
INPUT_INVALID_VALUE: def({
|
|
664
|
+
code: 'INPUT_INVALID_VALUE',
|
|
665
|
+
exitCode: EXIT.INPUT,
|
|
666
|
+
recoverable: true,
|
|
667
|
+
retryable: false,
|
|
668
|
+
title: 'Option value is not valid',
|
|
669
|
+
detail: 'The supplied value could not be interpreted for this flag.',
|
|
670
|
+
cause: 'Wrong type or an out-of-range value.',
|
|
671
|
+
remediation: {
|
|
672
|
+
summary: 'Correct the value and re-run.',
|
|
673
|
+
steps: ['Check context.expected for what this flag accepts.', 'Run: ytstats <command> --help'],
|
|
674
|
+
commands: [HELP_CMD],
|
|
675
|
+
docs: [SETUP_DOC],
|
|
676
|
+
},
|
|
677
|
+
}),
|
|
678
|
+
|
|
679
|
+
// ──────────────────────────────────────────────────────────────── data
|
|
680
|
+
|
|
681
|
+
DATA_PARTIAL: def({
|
|
682
|
+
code: 'DATA_PARTIAL',
|
|
683
|
+
severity: SEVERITY.WARNING,
|
|
684
|
+
exitCode: EXIT.OK,
|
|
685
|
+
recoverable: true,
|
|
686
|
+
retryable: true,
|
|
687
|
+
title: 'Some datasets could not be fetched',
|
|
688
|
+
detail:
|
|
689
|
+
'One or more analytics steps failed while the rest succeeded. The affected datasets are empty in ' +
|
|
690
|
+
'data — empty here means "not fetched", not "no activity".',
|
|
691
|
+
cause: 'YouTube rejects certain metric/dimension combinations for certain channels.',
|
|
692
|
+
remediation: {
|
|
693
|
+
summary: 'Treat the named datasets as unknown rather than zero. Others are complete and usable.',
|
|
694
|
+
steps: [
|
|
695
|
+
'Read warnings[].context.step for which datasets are missing.',
|
|
696
|
+
'Do not interpret an empty dataset listed here as zero activity.',
|
|
697
|
+
'Retrying may succeed if the cause was transient.',
|
|
698
|
+
],
|
|
699
|
+
commands: [DOCTOR_CMD],
|
|
700
|
+
docs: ['https://developers.google.com/youtube/analytics/channel_reports'],
|
|
701
|
+
},
|
|
702
|
+
}),
|
|
703
|
+
|
|
704
|
+
DATA_EMPTY: def({
|
|
705
|
+
code: 'DATA_EMPTY',
|
|
706
|
+
severity: SEVERITY.WARNING,
|
|
707
|
+
exitCode: EXIT.OK,
|
|
708
|
+
recoverable: true,
|
|
709
|
+
retryable: false,
|
|
710
|
+
title: 'Query succeeded but returned no rows',
|
|
711
|
+
detail: 'YouTube accepted the query and returned zero rows. This is genuinely no data, not a failure.',
|
|
712
|
+
cause: 'The window predates the channel, the channel had no activity, or the metric does not apply.',
|
|
713
|
+
remediation: {
|
|
714
|
+
summary: 'Widen the window or confirm the channel had activity in this period.',
|
|
715
|
+
steps: ['Try a wider window, e.g. --days 90.', 'Run: ytstats channel to confirm the channel has views at all.'],
|
|
716
|
+
commands: [
|
|
717
|
+
{ run: 'ytstats channel', description: 'Confirm the channel has lifetime activity' },
|
|
718
|
+
{ run: 'ytstats daily --days 90', description: 'Retry over a wider window' },
|
|
719
|
+
],
|
|
720
|
+
docs: [SETUP_DOC],
|
|
721
|
+
},
|
|
722
|
+
}),
|
|
723
|
+
|
|
724
|
+
REACH_PENDING: def({
|
|
725
|
+
code: 'REACH_PENDING',
|
|
726
|
+
severity: SEVERITY.WARNING,
|
|
727
|
+
exitCode: EXIT.OK,
|
|
728
|
+
recoverable: true,
|
|
729
|
+
retryable: true,
|
|
730
|
+
title: 'Reach reporting job created — data not generated yet',
|
|
731
|
+
detail:
|
|
732
|
+
'Impressions and CTR come from the asynchronous Reporting API. The job now exists, but YouTube has ' +
|
|
733
|
+
'not produced any report files yet. This is expected on first use and is not an error.',
|
|
734
|
+
cause: 'First run of ytstats reach. YouTube generates the first reports within 24-48 hours, including a 30-day backfill.',
|
|
735
|
+
remediation: {
|
|
736
|
+
summary: 'Wait 24-48 hours, then run ytstats reach again. Nothing else is required.',
|
|
737
|
+
steps: [
|
|
738
|
+
'Do not re-create the job — it already exists and re-running is harmless.',
|
|
739
|
+
'Re-run ytstats reach after 24-48 hours.',
|
|
740
|
+
'Note this data is permanently 1-2 days behind; today and yesterday will never be present.',
|
|
741
|
+
],
|
|
742
|
+
commands: [
|
|
743
|
+
{ run: 'ytstats reach', description: 'Re-run after 24-48 hours to download the generated reports' },
|
|
744
|
+
{ run: 'ytstats reach-jobs', description: 'Confirm the reporting job exists' },
|
|
745
|
+
],
|
|
746
|
+
docs: ['https://developers.google.com/youtube/reporting/v1/reports'],
|
|
747
|
+
},
|
|
748
|
+
}),
|
|
749
|
+
|
|
750
|
+
CONFIG_UNWRITABLE: def({
|
|
751
|
+
code: 'CONFIG_UNWRITABLE',
|
|
752
|
+
exitCode: EXIT.GENERAL,
|
|
753
|
+
recoverable: true,
|
|
754
|
+
retryable: false,
|
|
755
|
+
title: 'Configuration directory is not writable',
|
|
756
|
+
detail: 'Credentials and tokens could not be saved, so authentication cannot persist between runs.',
|
|
757
|
+
cause: 'Directory permissions, a read-only filesystem, or a container without a writable home.',
|
|
758
|
+
remediation: {
|
|
759
|
+
summary: 'Point YTSTATS_CONFIG_DIR at a writable directory.',
|
|
760
|
+
steps: [
|
|
761
|
+
'Set YTSTATS_CONFIG_DIR to a writable path, e.g. export YTSTATS_CONFIG_DIR=$PWD/.ytstats',
|
|
762
|
+
'Re-run: ytstats login',
|
|
763
|
+
'For CI, supply YTSTATS_CLIENT_ID and YTSTATS_CLIENT_SECRET as environment variables instead.',
|
|
764
|
+
],
|
|
765
|
+
commands: [DOCTOR_CMD, LOGIN_CMD],
|
|
766
|
+
docs: [SETUP_DOC],
|
|
767
|
+
},
|
|
768
|
+
}),
|
|
769
|
+
|
|
770
|
+
UNEXPECTED: def({
|
|
771
|
+
code: 'UNEXPECTED',
|
|
772
|
+
exitCode: EXIT.GENERAL,
|
|
773
|
+
recoverable: false,
|
|
774
|
+
retryable: true,
|
|
775
|
+
title: 'Unexpected internal error',
|
|
776
|
+
detail: 'ytstats hit a condition it does not recognise. This is a bug worth reporting.',
|
|
777
|
+
cause: 'Unanticipated failure — the underlying message is in context.detail.',
|
|
778
|
+
remediation: {
|
|
779
|
+
summary: 'Retry once; if it persists, run doctor and report the diagnostic.',
|
|
780
|
+
steps: [
|
|
781
|
+
'Retry the command once — some transient failures present this way.',
|
|
782
|
+
'Run: ytstats doctor to check whether the environment is healthy.',
|
|
783
|
+
'If reproducible, report the issue including this diagnostic (it contains no secrets).',
|
|
784
|
+
],
|
|
785
|
+
commands: [DOCTOR_CMD],
|
|
786
|
+
docs: [SETUP_DOC],
|
|
787
|
+
},
|
|
788
|
+
}),
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
/** Strip anything resembling a stack frame; diagnostics are prose, never traces. */
|
|
792
|
+
function clean(text) {
|
|
793
|
+
return String(text ?? '')
|
|
794
|
+
.split('\n')
|
|
795
|
+
.filter(line => !/^\s*at\s.+:\d+:\d+\)?$/.test(line.trimEnd()))
|
|
796
|
+
.join('\n')
|
|
797
|
+
.trim();
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Instantiate a diagnostic from its definition, interpolating call-site context.
|
|
802
|
+
*
|
|
803
|
+
* @param {DiagnosticDef} definition an entry from DIAGNOSTICS
|
|
804
|
+
* @param {object} [context] flag, value, allowed, expected, step, account, detail…
|
|
805
|
+
*/
|
|
806
|
+
export function diagnose(definition, context = {}) {
|
|
807
|
+
if (!definition?.code) {
|
|
808
|
+
return diagnose(DIAGNOSTICS.UNEXPECTED, { detail: 'diagnose() called without a definition' });
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const ctx = {};
|
|
812
|
+
for (const [k, v] of Object.entries({ ...definition.defaults, ...context })) {
|
|
813
|
+
if (v !== undefined && v !== null) ctx[k] = typeof v === 'string' ? clean(v) : v;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Fold the specifics into detail so a caller reading only `detail` still learns
|
|
817
|
+
// exactly what was wrong with their input.
|
|
818
|
+
let detail = definition.detail;
|
|
819
|
+
const bits = [];
|
|
820
|
+
if (ctx.flag) bits.push(`Option: ${ctx.flag}`);
|
|
821
|
+
if (ctx.value !== undefined) bits.push(`Received: ${JSON.stringify(ctx.value)}`);
|
|
822
|
+
if (ctx.expected) bits.push(`Expected: ${ctx.expected}`);
|
|
823
|
+
if (Array.isArray(ctx.allowed) && ctx.allowed.length) bits.push(`Allowed: ${ctx.allowed.join(', ')}`);
|
|
824
|
+
if (ctx.step) bits.push(`Step: ${ctx.step}`);
|
|
825
|
+
if (ctx.account) bits.push(`Account: ${ctx.account}`);
|
|
826
|
+
if (ctx.detail) bits.push(`Underlying: ${ctx.detail}`);
|
|
827
|
+
if (bits.length) detail = `${detail} ${bits.join('. ')}.`;
|
|
828
|
+
|
|
829
|
+
return {
|
|
830
|
+
code: definition.code,
|
|
831
|
+
severity: definition.severity,
|
|
832
|
+
title: definition.title,
|
|
833
|
+
detail,
|
|
834
|
+
cause: definition.cause,
|
|
835
|
+
recoverable: definition.recoverable,
|
|
836
|
+
retryable: definition.retryable,
|
|
837
|
+
remediation: {
|
|
838
|
+
summary: definition.remediation.summary,
|
|
839
|
+
steps: [...definition.remediation.steps],
|
|
840
|
+
commands: (definition.remediation.commands ?? []).map(c => ({ ...c })),
|
|
841
|
+
docs: [...(definition.remediation.docs ?? [])],
|
|
842
|
+
},
|
|
843
|
+
context: ctx,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export function isDiagnostic(value) {
|
|
848
|
+
return Boolean(value && typeof value === 'object' && value.code && value.remediation && value.severity);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Worst exit code across a set of diagnostics; OK when there are no errors. */
|
|
852
|
+
export function exitCodeFor(diagnostics = []) {
|
|
853
|
+
const errors = diagnostics.filter(d => d.severity === SEVERITY.ERROR);
|
|
854
|
+
if (errors.length === 0) return EXIT.OK;
|
|
855
|
+
const byCode = new Map(Object.values(DIAGNOSTICS).map(d => [d.code, d.exitCode]));
|
|
856
|
+
return errors.reduce((worst, d) => Math.max(worst, byCode.get(d.code) ?? EXIT.GENERAL), EXIT.OK);
|
|
857
|
+
}
|