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.
@@ -0,0 +1,172 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readJson, writeJson, removeFile } from '../config/store.js';
4
+ import { CREDENTIALS_FILE } from '../config/paths.js';
5
+ import { YtStatsError, ERROR_CODES, SETUP_GUIDE, fail } from '../errors.js';
6
+ import { DIAGNOSTICS, diagnose } from '../diagnostics.js';
7
+
8
+ /**
9
+ * Normalise any of the shapes Google hands out into { clientId, clientSecret }.
10
+ * Accepts the Desktop-app ("installed"), web, and already-flat forms.
11
+ */
12
+ export function parseClientSecret(raw) {
13
+ if (!raw || typeof raw !== 'object') {
14
+ throw new YtStatsError('Credential file is not a JSON object.', {
15
+ code: ERROR_CODES.INVALID_CREDENTIALS,
16
+ });
17
+ }
18
+
19
+ if (raw.type === 'service_account') {
20
+ throw fail(DIAGNOSTICS.AUTH_SERVICE_ACCOUNT, {
21
+ detail: raw.client_email ? `Key belongs to ${raw.client_email}` : undefined,
22
+ });
23
+ }
24
+
25
+ const node = raw.installed || raw.web || raw;
26
+ const clientId = node.client_id ?? node.clientId;
27
+ const clientSecret = node.client_secret ?? node.clientSecret;
28
+
29
+ if (!clientId) throw fail(DIAGNOSTICS.AUTH_CREDENTIALS_MALFORMED, { detail: 'No client_id in the file' });
30
+ if (!clientSecret) throw fail(DIAGNOSTICS.AUTH_CREDENTIALS_MALFORMED, { detail: 'No client_secret in the file' });
31
+
32
+ return { clientId, clientSecret };
33
+ }
34
+
35
+ function readClientSecretFile(file) {
36
+ let raw;
37
+ try {
38
+ raw = fs.readFileSync(file, 'utf-8');
39
+ } catch {
40
+ throw fail(DIAGNOSTICS.AUTH_CREDENTIALS_NOT_FOUND, { value: file, flag: '--client-secret' });
41
+ }
42
+
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(raw);
46
+ } catch {
47
+ throw fail(DIAGNOSTICS.AUTH_CREDENTIALS_MALFORMED, {
48
+ value: file, detail: 'File is not valid JSON',
49
+ });
50
+ }
51
+
52
+ return parseClientSecret(parsed);
53
+ }
54
+
55
+ /**
56
+ * Look for a downloaded client secret in a directory. Google names these
57
+ * client_secret_<numbers>-<hash>.apps.googleusercontent.com.json, so we glob the
58
+ * prefix. Exact `client_secret.json` wins; otherwise sort for determinism.
59
+ */
60
+ export function discoverClientSecretFile(dir) {
61
+ let entries;
62
+ try {
63
+ entries = fs.readdirSync(dir);
64
+ } catch {
65
+ return null;
66
+ }
67
+
68
+ const candidates = entries
69
+ .filter(f => f.startsWith('client_secret') && f.endsWith('.json'))
70
+ .sort();
71
+ if (candidates.length === 0) return null;
72
+
73
+ const exact = candidates.find(f => f === 'client_secret.json');
74
+ return path.join(dir, exact ?? candidates[0]);
75
+ }
76
+
77
+ /** Google issues IDs shaped <project-number>-<hash>.apps.googleusercontent.com */
78
+ const CLIENT_ID_SUFFIX = '.apps.googleusercontent.com';
79
+ const CANONICAL_CLIENT_ID = /^\d+-[A-Za-z0-9_]+\.apps\.googleusercontent\.com$/;
80
+
81
+ /**
82
+ * Check the client ID before it is ever sent to Google.
83
+ *
84
+ * A malformed client ID does not produce an API error — Google renders
85
+ * "Access blocked: Authorization Error" in the browser and simply never
86
+ * redirects, so the CLI would block until its timeout with no useful diagnosis.
87
+ * Catching it here turns a five-minute hang into an instant, precise failure.
88
+ *
89
+ * Returns a warning diagnostic for merely-unusual IDs; throws for impossible ones.
90
+ */
91
+ export function validateClientId(clientId) {
92
+ const id = String(clientId ?? '');
93
+
94
+ if (!id.endsWith(CLIENT_ID_SUFFIX)) {
95
+ throw fail(DIAGNOSTICS.AUTH_CLIENT_ID_INVALID, {
96
+ value: id.length > 60 ? `${id.slice(0, 57)}...` : id,
97
+ expected: `an ID ending in ${CLIENT_ID_SUFFIX}`,
98
+ });
99
+ }
100
+
101
+ // Legacy clients occasionally deviate, so an unusual shape is a warning rather
102
+ // than a hard failure — being wrong here must not lock anyone out.
103
+ if (!CANONICAL_CLIENT_ID.test(id)) {
104
+ return diagnose(DIAGNOSTICS.AUTH_CLIENT_ID_SUSPICIOUS, {
105
+ value: id,
106
+ expected: '<project-number>-<hash>.apps.googleusercontent.com',
107
+ });
108
+ }
109
+
110
+ return null;
111
+ }
112
+
113
+ export function loadStoredCredentials() {
114
+ const stored = readJson(CREDENTIALS_FILE);
115
+ if (!stored?.clientId || !stored?.clientSecret) return null;
116
+ return stored;
117
+ }
118
+
119
+ export function saveCredentials({ clientId, clientSecret, source }) {
120
+ writeJson(CREDENTIALS_FILE, {
121
+ version: 1,
122
+ clientId,
123
+ clientSecret,
124
+ source: source ?? 'login',
125
+ savedAt: new Date().toISOString(),
126
+ });
127
+ return { clientId, clientSecret };
128
+ }
129
+
130
+ export function clearCredentials() {
131
+ return removeFile(CREDENTIALS_FILE);
132
+ }
133
+
134
+ /**
135
+ * Resolve BYO OAuth client credentials.
136
+ *
137
+ * Precedence:
138
+ * 1. --client-secret <file>
139
+ * 2. YTSTATS_CLIENT_ID + YTSTATS_CLIENT_SECRET (both required)
140
+ * 3. credentials.json saved by a previous `ytstats login`
141
+ * 4. client_secret*.json auto-discovered in the working directory
142
+ *
143
+ * Returns { clientId, clientSecret, source }. `source` is for display only and
144
+ * never contains the secret.
145
+ */
146
+ export function resolveCredentials({ clientSecretPath, env = process.env, cwd = process.cwd() } = {}) {
147
+ if (clientSecretPath) {
148
+ return { ...readClientSecretFile(clientSecretPath), source: clientSecretPath };
149
+ }
150
+
151
+ if (env.YTSTATS_CLIENT_ID && env.YTSTATS_CLIENT_SECRET) {
152
+ return {
153
+ clientId: env.YTSTATS_CLIENT_ID,
154
+ clientSecret: env.YTSTATS_CLIENT_SECRET,
155
+ source: 'environment',
156
+ };
157
+ }
158
+
159
+ const stored = loadStoredCredentials();
160
+ if (stored) {
161
+ return { clientId: stored.clientId, clientSecret: stored.clientSecret, source: 'stored' };
162
+ }
163
+
164
+ const discovered = discoverClientSecretFile(cwd);
165
+ if (discovered) {
166
+ return { ...readClientSecretFile(discovered), source: discovered };
167
+ }
168
+
169
+ throw fail(DIAGNOSTICS.AUTH_NO_CREDENTIALS, {
170
+ detail: `Searched: --client-secret, YTSTATS_CLIENT_ID/SECRET env vars, stored credentials, and client_secret*.json in ${cwd}`,
171
+ });
172
+ }
@@ -0,0 +1,167 @@
1
+ import http from 'node:http';
2
+ import crypto from 'node:crypto';
3
+ import { URL } from 'node:url';
4
+ import { YtStatsError, ERROR_CODES, fail } from '../errors.js';
5
+ import { DIAGNOSTICS } from '../diagnostics.js';
6
+
7
+ /** Read-only scopes only. ytstats never requests write access to a channel. */
8
+ export const SCOPES = Object.freeze([
9
+ 'https://www.googleapis.com/auth/youtube.readonly',
10
+ 'https://www.googleapis.com/auth/yt-analytics.readonly',
11
+ 'https://www.googleapis.com/auth/yt-analytics-monetary.readonly',
12
+ ]);
13
+
14
+ const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
15
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
16
+
17
+ const base64url = buf =>
18
+ buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
19
+
20
+ /**
21
+ * RFC 7636 PKCE pair. Protects the authorization code against interception by
22
+ * another local process racing us on the loopback port.
23
+ */
24
+ export function createPkcePair() {
25
+ const verifier = base64url(crypto.randomBytes(64)).slice(0, 128);
26
+ const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
27
+ return { verifier, challenge };
28
+ }
29
+
30
+ /** Unguessable value tying the callback back to this specific login attempt. */
31
+ export function createState() {
32
+ return base64url(crypto.randomBytes(24));
33
+ }
34
+
35
+ export function buildAuthUrl({ clientId, redirectUri, state, codeChallenge, scopes = SCOPES }) {
36
+ const url = new URL(AUTH_ENDPOINT);
37
+ url.searchParams.set('client_id', clientId);
38
+ url.searchParams.set('redirect_uri', redirectUri);
39
+ url.searchParams.set('response_type', 'code');
40
+ url.searchParams.set('scope', scopes.join(' '));
41
+ url.searchParams.set('state', state);
42
+ url.searchParams.set('code_challenge', codeChallenge);
43
+ url.searchParams.set('code_challenge_method', 'S256');
44
+ // offline + consent are what make Google return a refresh token.
45
+ url.searchParams.set('access_type', 'offline');
46
+ url.searchParams.set('prompt', 'consent');
47
+ url.searchParams.set('include_granted_scopes', 'true');
48
+ return url.toString();
49
+ }
50
+
51
+ function page(title, body, accent) {
52
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
53
+ <style>
54
+ body{font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
55
+ display:flex;align-items:center;justify-content:center;height:100vh;margin:0;
56
+ background:#0f1115;color:#e6e6e6}
57
+ .card{max-width:30rem;padding:2.5rem;text-align:center}
58
+ h1{font-size:1.35rem;margin:0 0 .5rem;color:${accent}}
59
+ p{margin:0;color:#9aa0a6}
60
+ </style></head><body><div class="card"><h1>${title}</h1><p>${body}</p></div></body></html>`;
61
+ }
62
+
63
+ const SUCCESS_PAGE = page('Signed in to ytstats', 'You can close this tab and return to your terminal.', '#4ade80');
64
+ const failPage = reason => page('Sign-in failed', reason, '#f87171');
65
+
66
+ /**
67
+ * Start the local callback listener.
68
+ *
69
+ * Binds 127.0.0.1 on an ephemeral port — never 0.0.0.0, so nothing off-machine can
70
+ * reach it. The returned promise settles on the first legitimate callback.
71
+ */
72
+ export async function startLoopbackServer({ state, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
73
+ let resolveCode, rejectCode;
74
+ const codePromise = new Promise((res, rej) => { resolveCode = res; rejectCode = rej; });
75
+
76
+ let settled = false;
77
+ const settle = (fn, value) => {
78
+ if (settled) return;
79
+ settled = true;
80
+ clearTimeout(timer);
81
+ fn(value);
82
+ };
83
+
84
+ const server = http.createServer((req, res) => {
85
+ const url = new URL(req.url, 'http://127.0.0.1');
86
+
87
+ // The browser asks for this unprompted; it is not the callback.
88
+ if (url.pathname === '/favicon.ico') {
89
+ res.writeHead(404).end();
90
+ return;
91
+ }
92
+ if (url.pathname !== '/' && url.pathname !== '/callback') {
93
+ res.writeHead(404).end();
94
+ return;
95
+ }
96
+
97
+ const params = url.searchParams;
98
+ const error = params.get('error');
99
+ const code = params.get('code');
100
+ const returnedState = params.get('state');
101
+
102
+ if (error) {
103
+ const denied = error === 'access_denied';
104
+ res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
105
+ .end(failPage(denied ? 'You declined the permission request.' : `Google returned: ${error}`));
106
+ settle(rejectCode, denied
107
+ ? fail(DIAGNOSTICS.AUTH_CONSENT_DECLINED, { detail: `Google returned: ${error}` })
108
+ : fail(DIAGNOSTICS.AUTH_STATE_MISMATCH, { detail: `Google returned: ${error}` }));
109
+ return;
110
+ }
111
+
112
+ // Constant-time compare so the state cannot be probed byte by byte.
113
+ if (!returnedState || !safeEqual(returnedState, state)) {
114
+ res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
115
+ .end(failPage('Security check failed. Please start the login again.'));
116
+ settle(rejectCode, fail(DIAGNOSTICS.AUTH_STATE_MISMATCH));
117
+ return;
118
+ }
119
+
120
+ if (!code) {
121
+ res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' })
122
+ .end(failPage('No authorization code was returned.'));
123
+ settle(rejectCode, fail(DIAGNOSTICS.AUTH_STATE_MISMATCH, { detail: 'No authorization code in the callback' }));
124
+ return;
125
+ }
126
+
127
+ // The success page deliberately contains no code/token material.
128
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(SUCCESS_PAGE);
129
+ settle(resolveCode, { code });
130
+ });
131
+
132
+ const timer = setTimeout(() => {
133
+ settle(rejectCode, fail(DIAGNOSTICS.AUTH_TIMEOUT));
134
+ }, timeoutMs);
135
+ timer.unref?.();
136
+
137
+ await new Promise((resolve, reject) => {
138
+ server.once('error', reject);
139
+ server.listen(0, '127.0.0.1', resolve);
140
+ });
141
+
142
+ const { port, address } = server.address();
143
+ let closed = false;
144
+
145
+ return {
146
+ port,
147
+ address,
148
+ redirectUri: `http://127.0.0.1:${port}`,
149
+ waitForCode: () => codePromise,
150
+ close() {
151
+ if (closed) return;
152
+ closed = true;
153
+ clearTimeout(timer);
154
+ server.close();
155
+ // Nothing should be waiting, but never leave a dangling promise.
156
+ settle(rejectCode, new YtStatsError('Login cancelled.', { code: ERROR_CODES.AUTH_FAILED }));
157
+ codePromise.catch(() => {});
158
+ },
159
+ };
160
+ }
161
+
162
+ function safeEqual(a, b) {
163
+ const ab = Buffer.from(String(a));
164
+ const bb = Buffer.from(String(b));
165
+ if (ab.length !== bb.length) return false;
166
+ return crypto.timingSafeEqual(ab, bb);
167
+ }
@@ -0,0 +1,257 @@
1
+ import readline from 'node:readline';
2
+ import { google } from 'googleapis';
3
+ import open from 'open';
4
+ import { resolveCredentials, saveCredentials, clearCredentials, validateClientId } from './credentials.js';
5
+ import { saveAccount, loadAccount, removeAccount, clearAllAccounts, listAccounts } from './tokens.js';
6
+ import { createPkcePair, createState, buildAuthUrl, startLoopbackServer, SCOPES } from './oauth.js';
7
+ import { YtStatsError, ERROR_CODES, mapGoogleError, fail } from '../errors.js';
8
+ import { DIAGNOSTICS } from '../diagnostics.js';
9
+
10
+ /**
11
+ * Everything that touches the network or the terminal is injected, so the whole
12
+ * session layer is testable without hitting Google or opening a browser.
13
+ */
14
+ function defaultDeps() {
15
+ return {
16
+ OAuth2: google.auth.OAuth2,
17
+ startLoopbackServer,
18
+ openBrowser: url => open(url),
19
+ fetchIdentity: defaultFetchIdentity,
20
+ promptForRedirectUrl: defaultPrompt,
21
+ log: msg => process.stderr.write(msg + '\n'),
22
+ };
23
+ }
24
+
25
+ async function defaultFetchIdentity(client) {
26
+ const yt = google.youtube({ version: 'v3', auth: client });
27
+ const res = await yt.channels.list({ part: 'snippet,contentDetails', mine: true });
28
+ const channel = res.data.items?.[0];
29
+ if (!channel) return null;
30
+ return {
31
+ channelId: channel.id,
32
+ channelTitle: channel.snippet?.title ?? null,
33
+ customUrl: channel.snippet?.customUrl ?? null,
34
+ uploadsPlaylistId: channel.contentDetails?.relatedPlaylists?.uploads ?? null,
35
+ };
36
+ }
37
+
38
+ function defaultPrompt(question) {
39
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
40
+ return new Promise(resolve => rl.question(question, answer => { rl.close(); resolve(answer); }));
41
+ }
42
+
43
+ function newClient(deps, { clientId, clientSecret }, redirectUri) {
44
+ const Ctor = deps.OAuth2;
45
+ return new Ctor(clientId, clientSecret, redirectUri);
46
+ }
47
+
48
+ /**
49
+ * Build an OAuth client bound to a stored account, wired so that any token Google
50
+ * rotates during the process lifetime is written back to disk.
51
+ */
52
+ export function getAuthenticatedClient({ account: selector, clientSecretPath, env, cwd, deps: injected } = {}) {
53
+ const deps = { ...defaultDeps(), ...injected };
54
+
55
+ // Distinguish the ways "not authenticated" can happen — each has a different
56
+ // fix, so each gets its own diagnostic.
57
+ //
58
+ // Credentials are diagnosed BEFORE tokens: an OAuth client is the prerequisite
59
+ // for logging in at all, so reporting "not signed in" to someone who has not
60
+ // yet created a Google Cloud project sends them down the wrong recovery path.
61
+ const credentials = resolveCredentials({ clientSecretPath, env, cwd });
62
+
63
+ const account = loadAccount(selector);
64
+ if (!account) {
65
+ const known = listAccounts();
66
+ if (selector) {
67
+ throw fail(DIAGNOSTICS.AUTH_ACCOUNT_UNKNOWN, {
68
+ account: selector,
69
+ allowed: known.map(a => a.channelId),
70
+ detail: known.length
71
+ ? `Signed in: ${known.map(a => `${a.channelTitle ?? a.channelId} (${a.channelId})`).join(', ')}`
72
+ : 'No channels are signed in on this machine.',
73
+ });
74
+ }
75
+ throw fail(DIAGNOSTICS.AUTH_NO_TOKENS, {
76
+ detail: `OAuth client resolved from: ${credentials.source}`,
77
+ });
78
+ }
79
+ const client = newClient(deps, credentials, 'http://127.0.0.1');
80
+ client.setCredentials(account.tokens);
81
+
82
+ // Google omits refresh_token on a refresh response; saveAccount merges rather
83
+ // than replaces, so the long-lived token survives.
84
+ client.on('tokens', tokens => {
85
+ try {
86
+ saveAccount({
87
+ channelId: account.channelId,
88
+ channelTitle: account.channelTitle,
89
+ customUrl: account.customUrl,
90
+ tokens,
91
+ });
92
+ } catch {
93
+ // A read-only config dir must not break an otherwise working command.
94
+ }
95
+ });
96
+
97
+ return { client, account, credentials };
98
+ }
99
+
100
+ /**
101
+ * Interactive login. Default path opens the browser and captures the callback on
102
+ * 127.0.0.1; `noBrowser` falls back to printing the URL and reading the pasted
103
+ * redirect (for SSH/headless).
104
+ */
105
+ export async function login({
106
+ credentials: provided,
107
+ clientSecretPath,
108
+ noBrowser = false,
109
+ env,
110
+ cwd,
111
+ timeoutMs,
112
+ deps: injected,
113
+ } = {}) {
114
+ const deps = { ...defaultDeps(), ...injected };
115
+ const credentials = provided ?? resolveCredentials({ clientSecretPath, env, cwd });
116
+
117
+ // Validate before opening a browser. Google does not reject a bad client ID via
118
+ // the API — it renders "Access blocked" in the browser and never redirects, so
119
+ // without this the command would block until timeout with a misleading cause.
120
+ const idWarning = validateClientId(credentials.clientId);
121
+ if (idWarning) deps.log(`warning: ${idWarning.title} — ${idWarning.detail}`);
122
+
123
+ const { verifier, challenge } = createPkcePair();
124
+ const state = createState();
125
+
126
+ const { code, redirectUri } = noBrowser
127
+ ? await pasteFlow({ deps, credentials, state, challenge })
128
+ : await browserFlow({ deps, credentials, state, challenge, timeoutMs });
129
+
130
+ const client = newClient(deps, credentials, redirectUri);
131
+
132
+ let tokens;
133
+ try {
134
+ ({ tokens } = await client.getToken({ code, codeVerifier: verifier, redirect_uri: redirectUri }));
135
+ } catch (err) {
136
+ throw mapGoogleError(err);
137
+ }
138
+ client.setCredentials(tokens);
139
+
140
+ // Identify the channel before persisting anything, so a failed lookup cannot
141
+ // leave a half-written account behind.
142
+ let identity;
143
+ try {
144
+ identity = await deps.fetchIdentity(client);
145
+ } catch (err) {
146
+ throw mapGoogleError(err);
147
+ }
148
+
149
+ if (!identity?.channelId) throw fail(DIAGNOSTICS.AUTH_NO_CHANNEL);
150
+
151
+ saveCredentials({ ...credentials, source: credentials.source });
152
+ saveAccount({
153
+ channelId: identity.channelId,
154
+ channelTitle: identity.channelTitle,
155
+ customUrl: identity.customUrl,
156
+ tokens,
157
+ });
158
+
159
+ return identity;
160
+ }
161
+
162
+ async function browserFlow({ deps, credentials, state, challenge, timeoutMs }) {
163
+ const server = await deps.startLoopbackServer({ state, timeoutMs });
164
+ try {
165
+ const authUrl = buildAuthUrl({
166
+ clientId: credentials.clientId,
167
+ redirectUri: server.redirectUri,
168
+ state,
169
+ codeChallenge: challenge,
170
+ scopes: SCOPES,
171
+ });
172
+
173
+ deps.log('Opening your browser to sign in with Google...');
174
+ deps.log(`If it does not open, paste this URL:\n${authUrl}\n`);
175
+ await deps.openBrowser(authUrl);
176
+
177
+ const { code } = await server.waitForCode();
178
+ return { code, redirectUri: server.redirectUri };
179
+ } finally {
180
+ server.close();
181
+ }
182
+ }
183
+
184
+ async function pasteFlow({ deps, credentials, state, challenge }) {
185
+ // No loopback listener here, so Google's redirect will simply fail to load and
186
+ // the user copies the URL out of the address bar.
187
+ const redirectUri = 'http://127.0.0.1:1';
188
+ const authUrl = buildAuthUrl({
189
+ clientId: credentials.clientId,
190
+ redirectUri,
191
+ state,
192
+ codeChallenge: challenge,
193
+ scopes: SCOPES,
194
+ });
195
+
196
+ deps.log('Open this URL in any browser, approve access, then copy the URL you land on:\n');
197
+ deps.log(authUrl + '\n');
198
+
199
+ const answer = await deps.promptForRedirectUrl('Paste the redirect URL (or just the code): ');
200
+ const code = extractCode(answer);
201
+ if (!code) {
202
+ throw new YtStatsError('Could not find an authorization code in that input.', {
203
+ code: ERROR_CODES.AUTH_FAILED,
204
+ hint: 'Paste the full URL from the address bar, including the ?code=... part.',
205
+ });
206
+ }
207
+ return { code, redirectUri };
208
+ }
209
+
210
+ function extractCode(input) {
211
+ const text = String(input ?? '').trim();
212
+ if (!text) return null;
213
+ try {
214
+ return new URL(text).searchParams.get('code');
215
+ } catch {
216
+ return text; // They pasted the bare code.
217
+ }
218
+ }
219
+
220
+ /** Revoke with Google (best effort) and forget the account locally. */
221
+ export async function logout({ account: selector, all = false, forgetCredentials = false, env, cwd, deps: injected } = {}) {
222
+ const deps = { ...defaultDeps(), ...injected };
223
+ const accounts = all ? listAccounts() : [loadAccount(selector)].filter(Boolean);
224
+
225
+ if (accounts.length === 0) {
226
+ if (forgetCredentials) clearCredentials();
227
+ return { loggedOut: false, revoked: false, accounts: [] };
228
+ }
229
+
230
+ let credentials = null;
231
+ try {
232
+ credentials = resolveCredentials({ env, cwd });
233
+ } catch {
234
+ // Without the client secret we cannot revoke, but we can still forget locally.
235
+ }
236
+
237
+ let revoked = false;
238
+ for (const summary of accounts) {
239
+ const full = loadAccount(summary.channelId);
240
+ if (credentials && full?.tokens) {
241
+ try {
242
+ const client = newClient(deps, credentials, 'http://127.0.0.1');
243
+ client.setCredentials(full.tokens);
244
+ await client.revokeToken(full.tokens.refresh_token || full.tokens.access_token);
245
+ revoked = true;
246
+ } catch {
247
+ // Offline or already-invalid token: local removal below is what matters.
248
+ }
249
+ }
250
+ removeAccount(summary.channelId);
251
+ }
252
+
253
+ if (all) clearAllAccounts();
254
+ if (forgetCredentials) clearCredentials();
255
+
256
+ return { loggedOut: true, revoked, accounts: accounts.map(a => a.channelId) };
257
+ }