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/src/errors.js ADDED
@@ -0,0 +1,233 @@
1
+ import { diagnose, DIAGNOSTICS } from './diagnostics.js';
2
+
3
+ /**
4
+ * Stable machine-readable error codes. These are part of the public contract:
5
+ * scripts consuming ytstats JSON branch on `error.code`, so treat them as API.
6
+ */
7
+ export const ERROR_CODES = {
8
+ MISSING_CREDENTIALS: 'MISSING_CREDENTIALS',
9
+ INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
10
+ NOT_AUTHENTICATED: 'NOT_AUTHENTICATED',
11
+ AUTH_FAILED: 'AUTH_FAILED',
12
+ AUTH_TIMEOUT: 'AUTH_TIMEOUT',
13
+ ACCESS_DENIED: 'ACCESS_DENIED',
14
+ API_NOT_ENABLED: 'API_NOT_ENABLED',
15
+ NO_YOUTUBE_CHANNEL: 'NO_YOUTUBE_CHANNEL',
16
+ QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
17
+ QUERY_NOT_SUPPORTED: 'QUERY_NOT_SUPPORTED',
18
+ NOT_FOUND: 'NOT_FOUND',
19
+ INVALID_INPUT: 'INVALID_INPUT',
20
+ API_ERROR: 'API_ERROR',
21
+ UNKNOWN: 'UNKNOWN',
22
+ };
23
+
24
+ /** Process exit codes, one per broad failure class. */
25
+ export const EXIT_CODES = {
26
+ OK: 0,
27
+ GENERAL: 1,
28
+ AUTH: 2,
29
+ INPUT: 3,
30
+ API: 4,
31
+ };
32
+
33
+ export class YtStatsError extends Error {
34
+ constructor(message, { code = ERROR_CODES.UNKNOWN, exitCode, hint, cause, details, diagnostic } = {}) {
35
+ super(message);
36
+ this.name = 'YtStatsError';
37
+ this.code = code;
38
+ this.exitCode = exitCode ?? defaultExitCode(code);
39
+ if (hint) this.hint = hint;
40
+ if (cause) this.cause = cause;
41
+ if (details) this.details = details;
42
+ // The diagnostic is what reaches the caller; message/hint are for humans.
43
+ if (diagnostic) this.diagnostic = diagnostic;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Throw a fully-specified diagnostic.
49
+ * Preferred over `new YtStatsError(...)` everywhere a failure is anticipated.
50
+ */
51
+ export function fail(definition, context = {}) {
52
+ const d = diagnose(definition, context);
53
+ return new YtStatsError(`${d.title}: ${d.detail}`, {
54
+ code: d.code,
55
+ exitCode: d.remediation ? undefined : ERROR_CODES.UNKNOWN,
56
+ hint: d.remediation?.summary,
57
+ diagnostic: d,
58
+ });
59
+ }
60
+
61
+ function defaultExitCode(code) {
62
+ switch (code) {
63
+ case ERROR_CODES.MISSING_CREDENTIALS:
64
+ case ERROR_CODES.INVALID_CREDENTIALS:
65
+ case ERROR_CODES.NOT_AUTHENTICATED:
66
+ case ERROR_CODES.AUTH_FAILED:
67
+ case ERROR_CODES.AUTH_TIMEOUT:
68
+ case ERROR_CODES.ACCESS_DENIED:
69
+ case ERROR_CODES.NO_YOUTUBE_CHANNEL:
70
+ return EXIT_CODES.AUTH;
71
+ case ERROR_CODES.INVALID_INPUT:
72
+ return EXIT_CODES.INPUT;
73
+ case ERROR_CODES.API_NOT_ENABLED:
74
+ case ERROR_CODES.QUOTA_EXCEEDED:
75
+ case ERROR_CODES.QUERY_NOT_SUPPORTED:
76
+ case ERROR_CODES.API_ERROR:
77
+ return EXIT_CODES.API;
78
+ default:
79
+ return EXIT_CODES.GENERAL;
80
+ }
81
+ }
82
+
83
+ export const SETUP_GUIDE = [
84
+ 'Set up your own Google Cloud credentials (takes about 5 minutes):',
85
+ '',
86
+ ' 1. Create/select a project: https://console.cloud.google.com/projectcreate',
87
+ ' 2. Enable these three APIs in that project:',
88
+ ' YouTube Data API v3 https://console.cloud.google.com/apis/library/youtube.googleapis.com',
89
+ ' YouTube Analytics API https://console.cloud.google.com/apis/library/youtubeanalytics.googleapis.com',
90
+ ' YouTube Reporting API https://console.cloud.google.com/apis/library/youtubereporting.googleapis.com',
91
+ ' 3. Configure the OAuth consent screen (External) and add yourself as a test user:',
92
+ ' https://console.cloud.google.com/apis/credentials/consent',
93
+ ' 4. Create credentials > OAuth client ID > Application type: Desktop app, then download the JSON:',
94
+ ' https://console.cloud.google.com/apis/credentials',
95
+ ' 5. ytstats login --client-secret /path/to/client_secret_XXX.json',
96
+ '',
97
+ 'Your credentials stay on this machine. ytstats has no server and no shared client ID.',
98
+ ].join('\n');
99
+
100
+ /**
101
+ * Classify a Google API failure into a precise diagnostic.
102
+ *
103
+ * The ordering matters: the most specific signals (reason codes, message
104
+ * fingerprints) are checked before the generic HTTP status buckets, because a
105
+ * 403 can mean four unrelated things.
106
+ */
107
+ export function diagnoseGoogleError(err) {
108
+ if (err instanceof YtStatsError && err.diagnostic) return err.diagnostic;
109
+
110
+ const status = err?.response?.status ?? err?.status;
111
+ const apiErr = err?.response?.data?.error;
112
+ const reason = apiErr?.errors?.[0]?.reason || err?.errors?.[0]?.reason;
113
+ const message =
114
+ apiErr?.message ||
115
+ err?.response?.data?.error_description ||
116
+ (typeof apiErr === 'string' ? apiErr : '') ||
117
+ err?.message ||
118
+ '';
119
+ const detail = message || `HTTP ${status ?? 'unknown'}`;
120
+
121
+ // Network-level failures never reach an HTTP status.
122
+ const netCode = err?.code;
123
+ if (['ENOTFOUND', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'EPROTO', 'UND_ERR_CONNECT_TIMEOUT'].includes(netCode)
124
+ || /fetch failed|request to .* failed|getaddrinfo|socket hang up|network/i.test(message)) {
125
+ return diagnose(DIAGNOSTICS.NETWORK_UNREACHABLE, { detail });
126
+ }
127
+
128
+ if (reason === 'accessNotConfigured' || /has not been used in project|is disabled|API has not been used/i.test(message)) {
129
+ return diagnose(DIAGNOSTICS.API_NOT_ENABLED, { detail });
130
+ }
131
+
132
+ if (reason === 'NoLinkedYouTubeAccount' || /NoLinkedYouTubeAccount/i.test(message)) {
133
+ return diagnose(DIAGNOSTICS.AUTH_NO_CHANNEL, { detail });
134
+ }
135
+
136
+ if (reason === 'quotaExceeded' || /quota.*exceed/i.test(message)) {
137
+ return diagnose(DIAGNOSTICS.API_QUOTA_EXCEEDED, { detail });
138
+ }
139
+
140
+ if (reason === 'rateLimitExceeded' || reason === 'userRateLimitExceeded' || status === 429) {
141
+ return diagnose(DIAGNOSTICS.API_RATE_LIMITED, { detail });
142
+ }
143
+
144
+ if (/query is not supported/i.test(message)) {
145
+ return diagnose(DIAGNOSTICS.API_QUERY_NOT_SUPPORTED, { detail });
146
+ }
147
+
148
+ // invalid_grant is the single most common auth failure and has a specific fix.
149
+ if (/invalid_grant/i.test(message)) {
150
+ return diagnose(/revok/i.test(message) ? DIAGNOSTICS.AUTH_TOKEN_REVOKED : DIAGNOSTICS.AUTH_TOKEN_EXPIRED, { detail });
151
+ }
152
+
153
+ if (/token has been revoked|Token has been expired or revoked/i.test(message)) {
154
+ return diagnose(DIAGNOSTICS.AUTH_TOKEN_REVOKED, { detail });
155
+ }
156
+
157
+ if (status === 401 || /Invalid Credentials|unauthorized/i.test(message)) {
158
+ return diagnose(DIAGNOSTICS.AUTH_TOKEN_EXPIRED, { detail });
159
+ }
160
+
161
+ if (status === 403) return diagnose(DIAGNOSTICS.API_FORBIDDEN, { detail });
162
+ if (status === 404) return diagnose(DIAGNOSTICS.API_NOT_FOUND, { detail });
163
+ if (status >= 500) return diagnose(DIAGNOSTICS.API_UNAVAILABLE, { detail });
164
+ if (status === 400) return diagnose(DIAGNOSTICS.API_QUERY_NOT_SUPPORTED, { detail });
165
+
166
+ return diagnose(DIAGNOSTICS.UNEXPECTED, { detail });
167
+ }
168
+
169
+ /**
170
+ * Map a Google API error onto a typed YtStatsError carrying a diagnostic.
171
+ */
172
+ export function mapGoogleError(err) {
173
+ if (err instanceof YtStatsError) return err;
174
+
175
+ const d = diagnoseGoogleError(err);
176
+ return new YtStatsError(`${d.title}: ${d.detail}`, {
177
+ code: legacyCodeFor(d.code),
178
+ hint: d.remediation.summary,
179
+ diagnostic: d,
180
+ cause: err,
181
+ });
182
+ }
183
+
184
+ /** Bridge new diagnostic codes back to the original ERROR_CODES vocabulary. */
185
+ function legacyCodeFor(code) {
186
+ const map = {
187
+ AUTH_NO_CREDENTIALS: ERROR_CODES.MISSING_CREDENTIALS,
188
+ AUTH_CREDENTIALS_MALFORMED: ERROR_CODES.INVALID_CREDENTIALS,
189
+ AUTH_CREDENTIALS_NOT_FOUND: ERROR_CODES.INVALID_CREDENTIALS,
190
+ AUTH_SERVICE_ACCOUNT: ERROR_CODES.INVALID_CREDENTIALS,
191
+ AUTH_NO_TOKENS: ERROR_CODES.NOT_AUTHENTICATED,
192
+ AUTH_TOKEN_EXPIRED: ERROR_CODES.NOT_AUTHENTICATED,
193
+ AUTH_TOKEN_REVOKED: ERROR_CODES.NOT_AUTHENTICATED,
194
+ AUTH_ACCOUNT_UNKNOWN: ERROR_CODES.NOT_AUTHENTICATED,
195
+ AUTH_CONSENT_DECLINED: ERROR_CODES.ACCESS_DENIED,
196
+ AUTH_TIMEOUT: ERROR_CODES.AUTH_TIMEOUT,
197
+ AUTH_STATE_MISMATCH: ERROR_CODES.AUTH_FAILED,
198
+ AUTH_NO_CHANNEL: ERROR_CODES.NO_YOUTUBE_CHANNEL,
199
+ API_NOT_ENABLED: ERROR_CODES.API_NOT_ENABLED,
200
+ API_QUOTA_EXCEEDED: ERROR_CODES.QUOTA_EXCEEDED,
201
+ API_RATE_LIMITED: ERROR_CODES.QUOTA_EXCEEDED,
202
+ API_QUERY_NOT_SUPPORTED: ERROR_CODES.QUERY_NOT_SUPPORTED,
203
+ API_FORBIDDEN: ERROR_CODES.ACCESS_DENIED,
204
+ API_NOT_FOUND: ERROR_CODES.NOT_FOUND,
205
+ API_UNAVAILABLE: ERROR_CODES.API_ERROR,
206
+ NETWORK_UNREACHABLE: ERROR_CODES.API_ERROR,
207
+ };
208
+ return map[code] ?? ERROR_CODES.UNKNOWN;
209
+ }
210
+
211
+ const SECRET_PATTERNS = [
212
+ /GOCSPX-[A-Za-z0-9_-]+/g, // Google client secrets
213
+ /\bya29\.[A-Za-z0-9._-]+/g, // Google access tokens
214
+ /\b1\/\/[A-Za-z0-9._-]{20,}/g, // Google refresh tokens
215
+ /\b4\/[0-9A-Za-z_-]{20,}/g, // Google authorization codes
216
+ // Deliberately NOT matching a bare "code" field: every diagnostic carries
217
+ // `"code": "AUTH_NO_TOKENS"`, which is public API and must survive redaction.
218
+ /"(client_secret|clientSecret|refresh_token|access_token|code_verifier|codeVerifier|authorization_code)"\s*:\s*"[^"]*"/g,
219
+ /[?&](code|code_verifier|client_secret)=[^&\s"]+/g,
220
+ ];
221
+
222
+ /**
223
+ * Strip anything that looks like a secret out of text destined for logs or JSON
224
+ * output. Belt-and-braces: we also never deliberately log these values.
225
+ */
226
+ export function redact(text) {
227
+ if (typeof text !== 'string') return text;
228
+ let out = text;
229
+ for (const re of SECRET_PATTERNS) {
230
+ out = out.replace(re, m => (m.startsWith('"') ? m.replace(/:\s*"[^"]*"/, ': "[REDACTED]"') : '[REDACTED]'));
231
+ }
232
+ return out;
233
+ }
@@ -0,0 +1,181 @@
1
+ import * as data from './api/data.js';
2
+ import * as analytics from './api/analytics.js';
3
+ import * as reporting from './api/reporting.js';
4
+ import { daysBetween } from './dates.js';
5
+ import { YtStatsError, ERROR_CODES } from './errors.js';
6
+
7
+ /** Real implementations; tests inject fakes. */
8
+ function defaultFetchers() {
9
+ return {
10
+ fetchChannel: data.fetchChannel,
11
+ fetchAllVideoIds: data.fetchAllVideoIds,
12
+ fetchVideos: data.fetchVideos,
13
+ fetchDailyAnalytics: analytics.fetchDailyAnalytics,
14
+ fetchCardMetrics: analytics.fetchCardMetrics,
15
+ fetchVideoAnalytics: analytics.fetchVideoAnalytics,
16
+ fetchTrafficSources: analytics.fetchTrafficSources,
17
+ fetchDemographics: analytics.fetchDemographics,
18
+ fetchDeviceTypes: analytics.fetchDeviceTypes,
19
+ fetchContentTypes: analytics.fetchContentTypes,
20
+ fetchSearchTerms: analytics.fetchSearchTerms,
21
+ fetchGeography: analytics.fetchGeography,
22
+ fetchPlaybackLocations: analytics.fetchPlaybackLocations,
23
+ fetchTrafficSourceDetails: analytics.fetchTrafficSourceDetails,
24
+ fetchAudienceRetention: analytics.fetchAudienceRetention,
25
+ fetchReach: reporting.fetchReach,
26
+ };
27
+ }
28
+
29
+ const DEFAULT_RETENTION_LIMIT = 50;
30
+
31
+ /** Auth problems are fatal everywhere; degrading past them yields a useless document. */
32
+ const FATAL_CODES = new Set([
33
+ ERROR_CODES.NOT_AUTHENTICATED,
34
+ ERROR_CODES.MISSING_CREDENTIALS,
35
+ ERROR_CODES.INVALID_CREDENTIALS,
36
+ ERROR_CODES.NO_YOUTUBE_CHANNEL,
37
+ ERROR_CODES.QUOTA_EXCEEDED,
38
+ ]);
39
+
40
+ /**
41
+ * Pull every dimension in one pass.
42
+ *
43
+ * Individual analytics steps degrade rather than abort: YouTube rejects certain
44
+ * metric/dimension combinations for some channels, and losing demographics should
45
+ * not cost you the other twelve datasets. Whatever failed is reported in
46
+ * `warnings` so the caller can tell "no data" from "not fetched".
47
+ */
48
+ export async function fetchAll(apis, {
49
+ range,
50
+ fetchers: injected,
51
+ retention = true,
52
+ retentionLimit = DEFAULT_RETENTION_LIMIT,
53
+ reach = false,
54
+ onProgress,
55
+ } = {}) {
56
+ const f = { ...defaultFetchers(), ...injected };
57
+ const { startDate, endDate } = range;
58
+ const period = { startDate, endDate };
59
+
60
+ const warnings = [];
61
+ const notes = [];
62
+ const progress = msg => onProgress?.(msg);
63
+
64
+ async function step(name, fn, fallback) {
65
+ progress(`Fetching ${name}...`);
66
+ try {
67
+ return await fn();
68
+ } catch (err) {
69
+ if (FATAL_CODES.has(err?.code)) throw err;
70
+ warnings.push({
71
+ step: name,
72
+ code: err?.code ?? ERROR_CODES.UNKNOWN,
73
+ message: err?.message ?? String(err),
74
+ });
75
+ return fallback;
76
+ }
77
+ }
78
+
79
+ // Channel identity is the one hard requirement — everything else keys off it.
80
+ progress('Fetching channel...');
81
+ const channel = await f.fetchChannel(apis);
82
+ if (!channel) {
83
+ throw new YtStatsError('No YouTube channel found for the signed-in account.', {
84
+ code: ERROR_CODES.NO_YOUTUBE_CHANNEL,
85
+ hint: 'Sign in with the Google account that owns the channel: `ytstats logout` then `ytstats login`.',
86
+ });
87
+ }
88
+
89
+ progress('Listing videos...');
90
+ const videoIds = await step('videoIds', () => f.fetchAllVideoIds(apis, channel.uploadsPlaylistId), []);
91
+ const videos = await step('videos', () => f.fetchVideos(apis, videoIds), []);
92
+
93
+ const [daily, cards] = await Promise.all([
94
+ step('daily', () => f.fetchDailyAnalytics(apis, period), []),
95
+ step('cardMetrics', () => f.fetchCardMetrics(apis, period), []),
96
+ ]);
97
+
98
+ const [
99
+ videoAnalytics, trafficSources, demographics, deviceTypes,
100
+ contentTypes, searchTerms, geography, playbackLocations,
101
+ ] = await Promise.all([
102
+ step('videoAnalytics', () => f.fetchVideoAnalytics(apis, period), []),
103
+ step('trafficSources', () => f.fetchTrafficSources(apis, period), []),
104
+ step('demographics', () => f.fetchDemographics(apis, period), []),
105
+ step('deviceTypes', () => f.fetchDeviceTypes(apis, period), []),
106
+ step('contentTypes', () => f.fetchContentTypes(apis, period), []),
107
+ step('searchTerms', () => f.fetchSearchTerms(apis, period), []),
108
+ step('geography', () => f.fetchGeography(apis, period), []),
109
+ step('playbackLocations', () => f.fetchPlaybackLocations(apis, period), []),
110
+ ]);
111
+
112
+ // Only drill into traffic source types the channel actually has.
113
+ const detailTypes = trafficSources.map(t => t.sourceType).filter(Boolean);
114
+ const detailResults = await Promise.all(
115
+ detailTypes.map(sourceType =>
116
+ step(`trafficSourceDetails:${sourceType}`,
117
+ () => f.fetchTrafficSourceDetails(apis, { ...period, sourceType }),
118
+ []),
119
+ ),
120
+ );
121
+
122
+ const audienceRetention = {};
123
+ if (retention && videos.length) {
124
+ // One API call per video, so cap it — newest first, since that is what anyone
125
+ // analysing retention actually cares about.
126
+ const ordered = [...videos].sort(
127
+ (a, b) => String(b.publishedAt ?? '').localeCompare(String(a.publishedAt ?? '')),
128
+ );
129
+ const targets = ordered.slice(0, retentionLimit);
130
+ if (targets.length < videos.length) {
131
+ notes.push(`Audience retention fetched for ${targets.length} of ${videos.length} videos (most recent first). Use --retention-limit to change.`);
132
+ }
133
+
134
+ for (const [i, video] of targets.entries()) {
135
+ progress(`Fetching retention ${i + 1}/${targets.length}...`);
136
+ const curve = await step(`retention:${video.id}`,
137
+ () => f.fetchAudienceRetention(apis, { ...period, videoId: video.id }),
138
+ null);
139
+ if (curve?.length) audienceRetention[video.id] = curve;
140
+ }
141
+ }
142
+
143
+ const result = {
144
+ channel,
145
+ videos,
146
+ daily: mergeByDate(daily, cards),
147
+ videoAnalytics,
148
+ trafficSources,
149
+ trafficSourceDetails: detailResults.flat(),
150
+ demographics,
151
+ deviceTypes,
152
+ contentTypes,
153
+ searchTerms,
154
+ geography,
155
+ playbackLocations,
156
+ audienceRetention,
157
+ };
158
+
159
+ if (reach) {
160
+ result.reach = await step('reach', () => f.fetchReach(apis, { onProgress }), { pending: true, rows: [] });
161
+ }
162
+
163
+ return {
164
+ period: { ...period, days: daysBetween(startDate, endDate) },
165
+ warnings,
166
+ notes,
167
+ data: result,
168
+ };
169
+ }
170
+
171
+ /** Fold the card-metrics rows into the daily rows sharing their date. */
172
+ function mergeByDate(daily, extra) {
173
+ if (!extra?.length) return daily;
174
+ const index = new Map(extra.map(row => [row.date, row]));
175
+ return daily.map(row => {
176
+ const match = index.get(row.date);
177
+ if (!match) return row;
178
+ const { date, ...rest } = match;
179
+ return { ...row, ...rest };
180
+ });
181
+ }
package/src/index.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Programmatic API.
3
+ *
4
+ * The CLI is the public contract, but importing the library avoids a process
5
+ * spawn and a JSON round-trip when you are already in Node:
6
+ *
7
+ * import { getAuthenticatedClient, createApis, fetchAll, resolveDateRange } from 'ytstats';
8
+ *
9
+ * const { client } = getAuthenticatedClient();
10
+ * const result = await fetchAll(createApis(client), { range: resolveDateRange({ days: 90 }) });
11
+ */
12
+ export { getAuthenticatedClient, login, logout } from './auth/session.js';
13
+ export {
14
+ resolveCredentials,
15
+ saveCredentials,
16
+ clearCredentials,
17
+ loadStoredCredentials,
18
+ discoverClientSecretFile,
19
+ parseClientSecret,
20
+ } from './auth/credentials.js';
21
+ export {
22
+ loadAccount,
23
+ listAccounts,
24
+ saveAccount,
25
+ removeAccount,
26
+ setDefaultAccount,
27
+ clearAllAccounts,
28
+ migrateLegacyTokens,
29
+ } from './auth/tokens.js';
30
+ export { SCOPES } from './auth/oauth.js';
31
+
32
+ export { createApis } from './api/client.js';
33
+ export * as data from './api/data.js';
34
+ export * as analytics from './api/analytics.js';
35
+ export * as reporting from './api/reporting.js';
36
+ export * from './api/transforms.js';
37
+
38
+ export { fetchAll } from './fetch-all.js';
39
+ export { resolveDateRange, daysBetween, toIsoDate } from './dates.js';
40
+ export { configDir } from './config/store.js';
41
+ export { renderEnvelope, createReporter } from './output.js';
42
+ export { YtStatsError, ERROR_CODES, EXIT_CODES, mapGoogleError, diagnoseGoogleError, fail, redact } from './errors.js';
43
+ export { DIAGNOSTICS, diagnose, isDiagnostic, SEVERITY, EXIT } from './diagnostics.js';
44
+ export { buildProgram, main } from './cli.js';
package/src/output.js ADDED
@@ -0,0 +1,168 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { diagnose, isDiagnostic, exitCodeFor, DIAGNOSTICS, SEVERITY, EXIT } from './diagnostics.js';
5
+ export { SEVERITY, EXIT };
6
+ import { redact } from './errors.js';
7
+
8
+ const pkg = JSON.parse(
9
+ fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'),
10
+ );
11
+
12
+ /**
13
+ * The output contract.
14
+ *
15
+ * stdout exactly one JSON document, always, success or failure
16
+ * stderr human-readable progress; safe to discard entirely
17
+ *
18
+ * The envelope is shape-invariant: every key is present on every response, so a
19
+ * consumer never has to branch on whether a field exists. On failure `data` is
20
+ * null (never partial, never absent) and `errors` is non-empty.
21
+ *
22
+ * { ok, command, fetchedAt, data, errors[], warnings[], nextSteps[], meta }
23
+ */
24
+
25
+ const HELP_COMMAND = 'ytstats --help';
26
+
27
+ export function renderEnvelope({
28
+ command,
29
+ data = null,
30
+ errors = [],
31
+ warnings = [],
32
+ compact = false,
33
+ fetchedAt,
34
+ } = {}) {
35
+ const errorList = errors.map(toDiagnostic).filter(d => d.severity === SEVERITY.ERROR);
36
+ const warningList = [
37
+ ...warnings.map(toDiagnostic),
38
+ ...errors.map(toDiagnostic).filter(d => d.severity === SEVERITY.WARNING),
39
+ ];
40
+
41
+ const ok = errorList.length === 0;
42
+
43
+ const envelope = {
44
+ ok,
45
+ command: command ?? null,
46
+ fetchedAt: fetchedAt ?? new Date().toISOString(),
47
+ // Never hand back half a dataset alongside an error: a consumer that reads
48
+ // `data` without checking `ok` would silently act on partial results.
49
+ data: ok ? (data ?? null) : null,
50
+ errors: errorList,
51
+ warnings: warningList,
52
+ nextSteps: buildNextSteps([...errorList, ...warningList]),
53
+ meta: {
54
+ version: pkg.version,
55
+ exitCode: exitCodeFor([...errorList, ...warningList]),
56
+ helpCommand: HELP_COMMAND,
57
+ docs: 'https://www.npmjs.com/package/ytstats',
58
+ },
59
+ };
60
+
61
+ const json = JSON.stringify(envelope, null, compact ? undefined : 2);
62
+ return redact(json);
63
+ }
64
+
65
+ /** Accept a diagnostic, a YtStatsError carrying one, or any stray throw. */
66
+ function toDiagnostic(value) {
67
+ if (isDiagnostic(value)) return value;
68
+
69
+ if (value?.diagnostic && isDiagnostic(value.diagnostic)) return value.diagnostic;
70
+
71
+ if (value instanceof Error) {
72
+ return diagnose(DIAGNOSTICS.UNEXPECTED, { detail: value.message });
73
+ }
74
+
75
+ return diagnose(DIAGNOSTICS.UNEXPECTED, { detail: String(value) });
76
+ }
77
+
78
+ /**
79
+ * Flatten remediation into an ordered, deduplicated list of things to do next.
80
+ * Errors first, warnings after — an agent should fix what blocked it before
81
+ * acting on advisories.
82
+ */
83
+ function buildNextSteps(diagnostics) {
84
+ const steps = [];
85
+ const seen = new Set();
86
+
87
+ const push = text => {
88
+ const value = String(text).trim();
89
+ if (value && !seen.has(value)) {
90
+ seen.add(value);
91
+ steps.push(value);
92
+ }
93
+ };
94
+
95
+ const ordered = [
96
+ ...diagnostics.filter(d => d.severity === SEVERITY.ERROR),
97
+ ...diagnostics.filter(d => d.severity === SEVERITY.WARNING),
98
+ ];
99
+
100
+ for (const d of ordered) {
101
+ for (const cmd of d.remediation.commands ?? []) push(`${cmd.run} # ${cmd.description}`);
102
+ if (!d.remediation.commands?.length) push(d.remediation.summary);
103
+ }
104
+
105
+ return steps;
106
+ }
107
+
108
+ /**
109
+ * Reporter bound to a pair of sinks. Injecting them keeps stream discipline
110
+ * directly testable rather than relying on process-level capture.
111
+ */
112
+ export function createReporter({
113
+ stdout = s => process.stdout.write(s + '\n'),
114
+ stderr = s => process.stderr.write(s + '\n'),
115
+ quiet = false,
116
+ compact = false,
117
+ exitZero = false,
118
+ } = {}) {
119
+ const warningsBuffer = [];
120
+
121
+ const reporter = {
122
+ /** Human-facing progress. Never stdout. */
123
+ progress(message) {
124
+ if (!quiet) stderr(redact(String(message)));
125
+ },
126
+
127
+ /**
128
+ * Attach a non-fatal diagnostic to the eventual envelope, and echo it for
129
+ * humans. Warnings never make `ok` false and never affect the exit code.
130
+ *
131
+ * Severity is forced to 'warning' here: routing a diagnostic through warn()
132
+ * IS the decision that it is non-fatal. Without this, `doctor` — whose whole
133
+ * job is to report auth problems it found — would exit 2 while reporting
134
+ * ok:true, which is incoherent.
135
+ */
136
+ warn(diagnostic) {
137
+ const d = { ...toDiagnostic(diagnostic), severity: SEVERITY.WARNING };
138
+ warningsBuffer.push(d);
139
+ if (!quiet) stderr(redact(`warning: ${d.title} — ${d.detail}`));
140
+ return d;
141
+ },
142
+
143
+ /** Terminal success. Emits the one and only stdout document. */
144
+ succeed(command, data, opts = {}) {
145
+ stdout(renderEnvelope({ command, data, warnings: warningsBuffer, compact, ...opts }));
146
+ return exitZero ? EXIT.OK : exitCodeFor(warningsBuffer);
147
+ },
148
+
149
+ /** Terminal failure. Envelope to stdout, guidance to stderr. Returns the exit code. */
150
+ fail(command, errors, opts = {}) {
151
+ const list = (Array.isArray(errors) ? errors : [errors]).map(toDiagnostic);
152
+ stdout(renderEnvelope({ command, errors: [...list, ...warningsBuffer], compact, ...opts }));
153
+
154
+ if (!quiet) {
155
+ for (const d of list) {
156
+ stderr(redact(`error [${d.code}]: ${d.title}`));
157
+ stderr(redact(` ${d.detail}`));
158
+ if (d.remediation.summary) stderr(redact(` fix: ${d.remediation.summary}`));
159
+ for (const cmd of d.remediation.commands ?? []) stderr(redact(` $ ${cmd.run}`));
160
+ }
161
+ }
162
+
163
+ return exitZero ? EXIT.OK : exitCodeFor(list);
164
+ },
165
+ };
166
+
167
+ return reporter;
168
+ }