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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-07-27
|
|
10
|
+
|
|
11
|
+
Initial release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `login` / `logout` / `status` / `use` — bring-your-own-credentials OAuth. No shared
|
|
16
|
+
client ID: each user supplies their own Google Cloud OAuth client, so quota and
|
|
17
|
+
verification are their own.
|
|
18
|
+
- Loopback OAuth flow with PKCE (S256) and a constant-time CSRF state check, bound to
|
|
19
|
+
`127.0.0.1` on an ephemeral port. `--no-browser` fallback for headless machines.
|
|
20
|
+
- Multi-account token storage in a per-user config directory (macOS, Windows, Linux),
|
|
21
|
+
written atomically at `0600` inside a `0700` directory.
|
|
22
|
+
- `fetch` — every dimension in one JSON document: channel, videos, daily metrics,
|
|
23
|
+
per-video analytics, traffic sources and details, demographics, devices, content
|
|
24
|
+
types, search terms, geography, playback locations, retention curves, optional reach.
|
|
25
|
+
- Individual dataset commands: `channel`, `videos`, `daily`, `traffic`, `demographics`,
|
|
26
|
+
`devices`, `content-types`, `search-terms`, `geography`, `playback-locations`,
|
|
27
|
+
`video-analytics`, `retention`, `reach`, `reach-jobs`, `query`.
|
|
28
|
+
- `doctor` — checks config writability, credentials, sign-in state and live API
|
|
29
|
+
reachability independently, and reports the exact blocking diagnostics.
|
|
30
|
+
- `import-legacy` — import tokens from a pre-ytstats project-local `tokens.json`.
|
|
31
|
+
- Agent-first output contract: stdout is always exactly one shape-invariant envelope
|
|
32
|
+
(`ok`, `command`, `fetchedAt`, `data`, `errors`, `warnings`, `nextSteps`, `meta`),
|
|
33
|
+
including for unknown commands and invalid flags. Every diagnostic carries a stable
|
|
34
|
+
code, cause, `recoverable`/`retryable` flags, and runnable remediation commands.
|
|
35
|
+
- Input validation runs before authentication and reports every problem at once.
|
|
36
|
+
- Client ID pre-flight validation, so a malformed OAuth client fails immediately
|
|
37
|
+
instead of hanging until the browser callback times out.
|
|
38
|
+
|
|
39
|
+
[Unreleased]: https://github.com/nicolasdao/ytstats/compare/v0.1.0...HEAD
|
|
40
|
+
[0.1.0]: https://github.com/nicolasdao/ytstats/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Nicolas Dao
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# ytstats
|
|
2
|
+
|
|
3
|
+
Pull your YouTube channel's stats and analytics as JSON, from the command line.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx ytstats login
|
|
7
|
+
npx ytstats fetch --days 90 > snapshot.json
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
No install. No server. No shared API key. Your data and your credentials never leave your machine.
|
|
11
|
+
|
|
12
|
+
## Table of Contents
|
|
13
|
+
|
|
14
|
+
<!-- BEGIN toc -->
|
|
15
|
+
- [Overview](#overview)
|
|
16
|
+
- [Why bring your own credentials](#why-bring-your-own-credentials)
|
|
17
|
+
- [Getting Started](#getting-started)
|
|
18
|
+
- [Prerequisites](#prerequisites)
|
|
19
|
+
- [1. Create a Google Cloud project](#1-create-a-google-cloud-project)
|
|
20
|
+
- [2. Enable the three APIs](#2-enable-the-three-apis)
|
|
21
|
+
- [3. Configure the OAuth consent screen](#3-configure-the-oauth-consent-screen)
|
|
22
|
+
- [4. Create the OAuth client](#4-create-the-oauth-client)
|
|
23
|
+
- [5. Log in](#5-log-in)
|
|
24
|
+
- [Where credentials are stored](#where-credentials-are-stored)
|
|
25
|
+
- [Commands](#commands)
|
|
26
|
+
- [Self-diagnosis](#self-diagnosis)
|
|
27
|
+
- [Output](#output)
|
|
28
|
+
- [Use as a library](#use-as-a-library)
|
|
29
|
+
- [Things worth knowing](#things-worth-knowing)
|
|
30
|
+
- [Quota](#quota)
|
|
31
|
+
- [Project Structure](#project-structure)
|
|
32
|
+
- [Documentation](#documentation)
|
|
33
|
+
- [Requirements](#requirements)
|
|
34
|
+
- [License](#license)
|
|
35
|
+
<!-- END toc -->
|
|
36
|
+
|
|
37
|
+
## Overview
|
|
38
|
+
|
|
39
|
+
`ytstats` reads a channel you own — metadata, videos, daily metrics, traffic sources, demographics, devices, content types, search terms, geography, playback locations, retention curves, and thumbnail CTR — and prints it as one JSON document.
|
|
40
|
+
|
|
41
|
+
It is built for programs first. **stdout is exactly one JSON document, always** — success, failure, bad flag, unknown command, crash. Progress goes to stderr and is safe to discard. Every failure carries a stable code, a cause, `recoverable`/`retryable` flags, and runnable next steps, so an agent in a retry loop knows whether to retry, fix something, or stop.
|
|
42
|
+
|
|
43
|
+
### Why bring your own credentials
|
|
44
|
+
|
|
45
|
+
`ytstats` has **no built-in Google client ID**, by design. You create a Google Cloud project, generate an OAuth client, and the CLI uses yours. This means:
|
|
46
|
+
|
|
47
|
+
- **Your quota is yours.** The YouTube Data API gives every project 10,000 units/day. A shared client ID would make everyone compete for one pool.
|
|
48
|
+
- **No verification bottleneck.** Apps using YouTube scopes need Google's OAuth verification to serve strangers. You're not a stranger to yourself.
|
|
49
|
+
- **Nothing to trust.** There is no backend to send your data to, because there is no backend.
|
|
50
|
+
|
|
51
|
+
The cost is about five minutes of setup, once.
|
|
52
|
+
|
|
53
|
+
## Getting Started
|
|
54
|
+
|
|
55
|
+
### Prerequisites
|
|
56
|
+
|
|
57
|
+
Node.js 18+. No native dependencies, so `npx` is instant.
|
|
58
|
+
|
|
59
|
+
### 1. Create a Google Cloud project
|
|
60
|
+
|
|
61
|
+
<https://console.cloud.google.com/projectcreate>
|
|
62
|
+
|
|
63
|
+
### 2. Enable the three APIs
|
|
64
|
+
|
|
65
|
+
| API | Link |
|
|
66
|
+
|---|---|
|
|
67
|
+
| YouTube Data API v3 | [enable](https://console.cloud.google.com/apis/library/youtube.googleapis.com) |
|
|
68
|
+
| YouTube Analytics API | [enable](https://console.cloud.google.com/apis/library/youtubeanalytics.googleapis.com) |
|
|
69
|
+
| YouTube Reporting API | [enable](https://console.cloud.google.com/apis/library/youtubereporting.googleapis.com) |
|
|
70
|
+
|
|
71
|
+
All three must be enabled in the **same project** that issues your OAuth client.
|
|
72
|
+
|
|
73
|
+
### 3. Configure the OAuth consent screen
|
|
74
|
+
|
|
75
|
+
<https://console.cloud.google.com/apis/credentials/consent> — choose **External**, fill in the app name and your email, and add your own Google account as a **test user**.
|
|
76
|
+
|
|
77
|
+
> **Publish it to Production when you're done.** While the consent screen is in *Testing*, Google expires refresh tokens after **7 days** and you'll be logging in every week. Publishing (you'll click past an "unverified app" warning once) stops that.
|
|
78
|
+
|
|
79
|
+
### 4. Create the OAuth client
|
|
80
|
+
|
|
81
|
+
<https://console.cloud.google.com/apis/credentials> → **Create credentials → OAuth client ID → Application type: Desktop app**. Download the JSON.
|
|
82
|
+
|
|
83
|
+
> A **service account will not work** — not with any amount of configuration. Service accounts have no YouTube channel, so Google rejects them with `NoLinkedYouTubeAccount`. This is [documented](https://developers.google.com/youtube/v3/guides/authentication) and there is no workaround. You need an OAuth client ID.
|
|
84
|
+
|
|
85
|
+
### 5. Log in
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx ytstats login --client-secret ~/Downloads/client_secret_1234.json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Your browser opens, you approve, and you're done. Every later command needs no flags:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npx ytstats channel
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`--no-browser` prints a URL and reads the pasted redirect back, for SSH and headless machines.
|
|
98
|
+
|
|
99
|
+
### Where credentials are stored
|
|
100
|
+
|
|
101
|
+
Both the OAuth client and your tokens are written to a per-user directory, `0600`, readable only by you:
|
|
102
|
+
|
|
103
|
+
| OS | Location |
|
|
104
|
+
|---|---|
|
|
105
|
+
| macOS | `~/Library/Application Support/ytstats/` |
|
|
106
|
+
| Linux | `$XDG_CONFIG_HOME/ytstats/` (default `~/.config/ytstats/`) |
|
|
107
|
+
| Windows | `%APPDATA%\ytstats\` |
|
|
108
|
+
|
|
109
|
+
Override with `YTSTATS_CONFIG_DIR`. For CI, set `YTSTATS_CLIENT_ID` and `YTSTATS_CLIENT_SECRET` instead of a file. These are plaintext files, like `gcloud`, `gh`, and `aws` use. `ytstats logout` revokes the token with Google and deletes them.
|
|
110
|
+
|
|
111
|
+
## Commands
|
|
112
|
+
|
|
113
|
+
The one you'll actually use:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
ytstats fetch [--days 90] [--no-retention] [--retention-limit 50] [--reach]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Every dimension in a single JSON document. Individual analytics steps degrade rather than abort — YouTube rejects some metric combinations for some channels, and losing demographics shouldn't cost you the other twelve datasets. Anything that failed appears in `warnings`.
|
|
120
|
+
|
|
121
|
+
Individual datasets and account management:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
ytstats channel # metadata and lifetime stats
|
|
125
|
+
ytstats videos [-n 10] [-t SHORTS] [-s viewCount]
|
|
126
|
+
ytstats daily [-d 30] # day-by-day metrics
|
|
127
|
+
ytstats traffic # where views come from
|
|
128
|
+
ytstats demographics # age and gender
|
|
129
|
+
ytstats devices
|
|
130
|
+
ytstats content-types # Shorts vs long-form vs live
|
|
131
|
+
ytstats search-terms # what people search to find you
|
|
132
|
+
ytstats geography [-n 50]
|
|
133
|
+
ytstats playback-locations
|
|
134
|
+
ytstats video-analytics # per-video, top 200 by views
|
|
135
|
+
ytstats retention <videoId> # where viewers drop off
|
|
136
|
+
ytstats reach # thumbnail impressions and CTR
|
|
137
|
+
ytstats query -m views,likes --dimensions day
|
|
138
|
+
|
|
139
|
+
ytstats login | logout | status | doctor | use <channel> | import-legacy <file>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
All analytics commands accept `--days N`, or `--start YYYY-MM-DD --end YYYY-MM-DD`. Global flags: `-a, --account <channel>`, `--compact`, `-q, --quiet`.
|
|
143
|
+
|
|
144
|
+
Full reference with every flag and default: [docs/cli.md](docs/cli.md).
|
|
145
|
+
|
|
146
|
+
### Self-diagnosis
|
|
147
|
+
|
|
148
|
+
When something is wrong and you don't know what:
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
ytstats doctor
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
It checks config writability, credentials, sign-in state, and live API reachability independently, and returns a pass/fail list plus the exact blocking diagnostics. `doctor` itself always succeeds (`ok: true`); the verdict is in `data.healthy`.
|
|
155
|
+
|
|
156
|
+
## Output
|
|
157
|
+
|
|
158
|
+
Every response is the same shape — every key present, every time, so a consumer never branches on whether a field exists:
|
|
159
|
+
|
|
160
|
+
```jsonc
|
|
161
|
+
{
|
|
162
|
+
"ok": true,
|
|
163
|
+
"command": "channel",
|
|
164
|
+
"fetchedAt": "2026-07-27T10:00:00.000Z",
|
|
165
|
+
"data": { }, // null whenever ok is false — never partial
|
|
166
|
+
"errors": [], // non-empty iff ok is false
|
|
167
|
+
"warnings": [], // non-fatal; never affects ok or the exit code
|
|
168
|
+
"nextSteps": [], // ordered, deduplicated, ready-to-run commands
|
|
169
|
+
"meta": { "version": "0.1.0", "exitCode": 0, "helpCommand": "ytstats --help" }
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Exit codes: `0` success · `2` authentication · `3` bad input · `4` API error · `1` anything else. Also available as `meta.exitCode`, so a consumer that can only see stdout still knows.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
ytstats fetch --days 30 2>/dev/null | jq '.data.channel.subscriberCount'
|
|
177
|
+
ytstats fetch 2>/dev/null | jq -r 'if .ok then "fine" else .nextSteps[0] end'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Input is validated **before** authentication, and every input problem is reported together — so one loop iteration fixes everything rather than discovering a bad date only after fixing auth.
|
|
181
|
+
|
|
182
|
+
The envelope, the diagnostic schema, and the full failure-code catalog are in [docs/output-contract.md](docs/output-contract.md).
|
|
183
|
+
|
|
184
|
+
## Use as a library
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
import { getAuthenticatedClient, createApis, fetchAll, resolveDateRange } from 'ytstats';
|
|
188
|
+
|
|
189
|
+
const { client } = getAuthenticatedClient();
|
|
190
|
+
const result = await fetchAll(createApis(client), { range: resolveDateRange({ days: 90 }) });
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Library callers get no envelope: `fetchAll` returns its result object directly and fetchers throw `YtStatsError`. The full export surface is listed in [docs/architecture.md](docs/architecture.md#programmatic-api).
|
|
194
|
+
|
|
195
|
+
## Things worth knowing
|
|
196
|
+
|
|
197
|
+
**CTR only comes from `ytstats reach`.** The Analytics API documents `videoThumbnailImpressions` but it has never worked ([issue 254665034](https://issuetracker.google.com/issues/254665034)). CTR is served asynchronously by the Reporting API instead: the first `reach` run only creates a job, and data appears **24-48 hours later** with a 30-day backfill. It's also permanently 1-2 days behind — the same lag YouTube Studio shows.
|
|
198
|
+
|
|
199
|
+
**Retention ratios above 1.0 are correct.** A Short showing `1.54` means viewers looped it. Not a bug, and never clamped.
|
|
200
|
+
|
|
201
|
+
**Shorts detection is duration-based.** ≤60s is `SHORTS`. A 62-second video meant as a Short will read `VIDEO_ON_DEMAND`. YouTube's own classification uses extra signals — read `content-types` for its opinion.
|
|
202
|
+
|
|
203
|
+
**Per-video analytics caps at 200 videos**, sorted by views. An API limit, not ours.
|
|
204
|
+
|
|
205
|
+
**Subscriber counts are rounded** to 3 significant figures above 1,000. Small week-over-week changes are invisible.
|
|
206
|
+
|
|
207
|
+
**`fetch --reach` and retention cost extra calls.** Retention is one API call per video, hence `--retention-limit` (default 50, newest first).
|
|
208
|
+
|
|
209
|
+
The rest, with the handling sites named, is in [docs/gotchas.md](docs/gotchas.md).
|
|
210
|
+
|
|
211
|
+
## Quota
|
|
212
|
+
|
|
213
|
+
The Data API allows 10,000 units/day per project. `ytstats` uses the uploads playlist (1 unit per 50 videos) rather than `search.list` (100 units per call), so a full fetch for a 100-video channel costs about 5 units. The Analytics and Reporting APIs have separate quotas.
|
|
214
|
+
|
|
215
|
+
## Project Structure
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
bin/ytstats.js thin shim; guards stdout against stack traces
|
|
219
|
+
src/
|
|
220
|
+
cli.js command definitions, validation ordering, error capture
|
|
221
|
+
index.js the library entry point
|
|
222
|
+
auth/ credentials, OAuth loopback flow, token store, session
|
|
223
|
+
api/ Data v3, Analytics v2, Reporting v1, pure transforms
|
|
224
|
+
config/ per-user config dir, atomic 0600 store
|
|
225
|
+
fetch-all.js one-document orchestrator with per-step degradation
|
|
226
|
+
output.js the envelope; stdout/stderr discipline
|
|
227
|
+
diagnostics.js the failure catalog
|
|
228
|
+
errors.js YtStatsError, Google error classification, redaction
|
|
229
|
+
dates.js reporting window resolution and validation
|
|
230
|
+
test/ 316 tests, none requiring network access
|
|
231
|
+
docs/ topic documentation, indexed below
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Documentation
|
|
235
|
+
|
|
236
|
+
<!-- BEGIN doc-index -->
|
|
237
|
+
- [Architecture](docs/architecture.md) — How ytstats is put together — module layout, design principles, request flow, and the programmatic API surface.
|
|
238
|
+
- [Authentication](docs/auth.md) — The bring-your-own-credentials OAuth model — credential resolution, the PKCE loopback flow, token storage, and multi-account handling.
|
|
239
|
+
- [CLI Reference](docs/cli.md) — Complete ytstats command reference — every command, flag, default, and exit code.
|
|
240
|
+
- [Configuration](docs/configuration.md) — Environment variables, the per-user config directory, stored file formats, and CI setup.
|
|
241
|
+
- [Contributing](docs/contributing.md) — How to extend ytstats — adding datasets, commands, and diagnostics; the dependency policy; and the release process.
|
|
242
|
+
- [Gotchas](docs/gotchas.md)
|
|
243
|
+
- [Output Contract](docs/output-contract.md) — The JSON envelope, the diagnostic schema, the full failure-code catalog, and exit-code derivation.
|
|
244
|
+
- [Testing](docs/testing.md) — How ytstats is tested — injection seams, temp config dirs, real-HTTP loopback tests, subprocess end-to-end runs, and what coverage numbers actually mean.
|
|
245
|
+
- [YouTube APIs](docs/youtube-apis.md) — How ytstats calls the YouTube Data, Analytics, and Reporting APIs — exact queries, encoded limits, quota costs, and transforms.
|
|
246
|
+
<!-- END doc-index -->
|
|
247
|
+
|
|
248
|
+
Also: [CHANGELOG.md](CHANGELOG.md).
|
|
249
|
+
|
|
250
|
+
## Requirements
|
|
251
|
+
|
|
252
|
+
Node.js 18+. No native dependencies.
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
BSD-3-Clause
|
package/bin/ytstats.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from '../src/cli.js';
|
|
3
|
+
|
|
4
|
+
main().catch(err => {
|
|
5
|
+
// Last-resort guard: never let a stack trace land on stdout, which is reserved
|
|
6
|
+
// for the JSON document.
|
|
7
|
+
process.stderr.write(`ytstats: ${err?.message ?? err}\n`);
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: How ytstats is put together — module layout, design principles, request flow, and the programmatic API surface.
|
|
3
|
+
tags: [architecture, design, modules, orchestration]
|
|
4
|
+
source:
|
|
5
|
+
- bin/ytstats.js
|
|
6
|
+
- src/index.js
|
|
7
|
+
- src/cli.js
|
|
8
|
+
- src/fetch-all.js
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Architecture
|
|
12
|
+
|
|
13
|
+
Contributor-facing notes on how `ytstats` is built and why. For the command surface see [cli.md](cli.md); for how to change it see [contributing.md](contributing.md).
|
|
14
|
+
|
|
15
|
+
## Shape
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
bin/ytstats.js thin shim; last-resort guard against stdout pollution
|
|
19
|
+
└─ src/cli.js command definitions, validation ordering, error capture
|
|
20
|
+
├─ src/auth/ credentials, OAuth, token store, session
|
|
21
|
+
├─ src/api/ Data v3, Analytics v2, Reporting v1, pure transforms
|
|
22
|
+
├─ src/fetch-all.js one-document orchestrator with per-step degradation
|
|
23
|
+
├─ src/output.js the envelope; stdout/stderr discipline
|
|
24
|
+
├─ src/diagnostics.js the failure catalog
|
|
25
|
+
├─ src/errors.js YtStatsError, Google error classification, redaction
|
|
26
|
+
├─ src/dates.js reporting window resolution and validation
|
|
27
|
+
└─ src/config/ per-user config dir, atomic 0600 store
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`src/index.js` is a separate entry point — the library surface — and imports the same modules the CLI does.
|
|
31
|
+
|
|
32
|
+
## Design principles
|
|
33
|
+
|
|
34
|
+
**Everything I/O is injected.** API clients, the OAuth2 constructor, the loopback server, the browser opener, the identity lookup, the output sinks, and even `now()` are parameters with real defaults. This is why 316 tests run without a network connection and without opening a browser.
|
|
35
|
+
|
|
36
|
+
**Pure logic is separated from effects.** `api/transforms.js`, `dates.js`, `config/paths.js` and `diagnostics.js` are pure and directly tested. Everything awkward to test is pushed to the edges.
|
|
37
|
+
|
|
38
|
+
**No native dependencies.** `npx ytstats` must start instantly, which rules out anything requiring a prebuild download or a node-gyp compile. Runtime dependencies are `commander`, `googleapis`, and `open` — all pure JS. Think hard before adding a fourth.
|
|
39
|
+
|
|
40
|
+
**Read-only by design.** Only three read-only scopes are ever requested (`SCOPES` in `src/auth/oauth.js`). `ytstats` has no code path that modifies a channel.
|
|
41
|
+
|
|
42
|
+
**The consumer is assumed to be a program.** stdout carries exactly one JSON document on every code path; diagnostics are structured, coded, and carry runnable remediation. See [output-contract.md](output-contract.md).
|
|
43
|
+
|
|
44
|
+
## Module responsibilities
|
|
45
|
+
|
|
46
|
+
| Module | Responsibility |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `config/paths.js` | Per-OS config dir. Pure — platform, env, and home are injected, so Windows and Linux behaviour is asserted from any machine. |
|
|
49
|
+
| `config/store.js` | Atomic `0600` JSON read/write, traversal-safe filenames. |
|
|
50
|
+
| `auth/credentials.js` | BYO credential resolution and validation. Rejects service accounts and malformed client IDs before they cost a browser round trip. |
|
|
51
|
+
| `auth/oauth.js` | PKCE pair, CSRF state, loopback callback server, auth URL builder, scope list. |
|
|
52
|
+
| `auth/tokens.js` | Multi-account token store keyed by channel; legacy import. |
|
|
53
|
+
| `auth/session.js` | Ties the above together: `login`, `logout`, `getAuthenticatedClient` with refresh persistence. |
|
|
54
|
+
| `api/client.js` | Builds the three API surfaces plus an authenticated CSV downloader; wraps calls in error mapping. |
|
|
55
|
+
| `api/transforms.js` | Pure shaping: duration parsing, content classification, CSV, date normalization, row zipping. |
|
|
56
|
+
| `api/data.js` | Data API v3 fetchers — channel, video ids, video resources. |
|
|
57
|
+
| `api/analytics.js` | Analytics API v2 fetchers, with the undocumented limits encoded as constants. |
|
|
58
|
+
| `api/reporting.js` | Reporting API v1 job lifecycle and reach download. |
|
|
59
|
+
| `fetch-all.js` | Orchestrates every dataset into one document, degrading per step. |
|
|
60
|
+
| `dates.js` | Reporting window resolution and validation. |
|
|
61
|
+
| `output.js` | The envelope and the stdout/stderr split. |
|
|
62
|
+
| `diagnostics.js` | The failure catalog and exit-code derivation. |
|
|
63
|
+
| `errors.js` | `YtStatsError`, Google error classification, secret redaction. |
|
|
64
|
+
| `cli.js` | Commander wiring, validation-before-auth ordering, Commander error capture. |
|
|
65
|
+
|
|
66
|
+
## Request flow
|
|
67
|
+
|
|
68
|
+
A data command travels the same path every time:
|
|
69
|
+
|
|
70
|
+
1. **`bin/ytstats.js`** calls `main(process.argv)`, with a final `catch` that guarantees no stack trace ever reaches stdout.
|
|
71
|
+
2. **`main()`** builds the program and calls `parseAsync`. Commander errors are thrown rather than exited (`exitOverride()`) and converted by `diagnoseCommanderError()`.
|
|
72
|
+
3. **The `run()` wrapper** executes the command's `validate` callback first, collecting *every* input problem into one envelope before any network call. See [validation ordering](gotchas/cli-output.md#input-is-validated-before-authentication).
|
|
73
|
+
4. **`withApis()`** calls `getAuthenticatedClient()` — which resolves credentials, loads the stored account, and registers a `tokens` listener so refreshed tokens are written back — then `createApis()` bundles the three API surfaces.
|
|
74
|
+
5. **The command body** calls one or more fetchers, passing the `apis` bundle as the first argument.
|
|
75
|
+
6. **`reporter.succeed()` / `reporter.fail()`** renders the single JSON envelope to stdout and returns the exit code.
|
|
76
|
+
|
|
77
|
+
Progress messages go to stderr throughout via `reporter.progress()` and are safe to discard.
|
|
78
|
+
|
|
79
|
+
## fetch-all orchestration
|
|
80
|
+
|
|
81
|
+
`fetchAll()` is the one place that runs every dataset in a single pass. Three properties matter:
|
|
82
|
+
|
|
83
|
+
**Channel identity is the only hard requirement.** It is fetched outside `step()`, and a missing channel throws `NO_YOUTUBE_CHANNEL` — everything downstream keys off `channel.uploadsPlaylistId`.
|
|
84
|
+
|
|
85
|
+
**Individual steps degrade rather than abort.** Each dataset runs inside `step(name, fn, fallback)`, which catches, records `{ step, code, message }` in `warnings`, and returns the fallback. YouTube rejects certain metric/dimension combinations for certain channels, and losing demographics should not cost you the other twelve datasets.
|
|
86
|
+
|
|
87
|
+
**Auth and quota failures are fatal anyway.** `FATAL_CODES` — `NOT_AUTHENTICATED`, `MISSING_CREDENTIALS`, `INVALID_CREDENTIALS`, `NO_YOUTUBE_CHANNEL`, `QUOTA_EXCEEDED` — rethrow instead of degrading, because continuing past them yields a document that is empty for reasons the caller cannot act on step by step.
|
|
88
|
+
|
|
89
|
+
Independent steps run concurrently in two `Promise.all` batches (daily + cards, then the eight analytics datasets). Traffic-source details are then fetched only for the source types the channel actually has, and retention runs sequentially because it costs one API call per video.
|
|
90
|
+
|
|
91
|
+
The return value is `{ period, warnings, notes, data }`. `notes` carries non-diagnostic information such as "retention fetched for 50 of 120 videos".
|
|
92
|
+
|
|
93
|
+
## Programmatic API
|
|
94
|
+
|
|
95
|
+
`src/index.js` re-exports the module surface so a Node caller can skip the process spawn and the JSON round-trip:
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
import { getAuthenticatedClient, createApis, fetchAll, resolveDateRange } from 'ytstats';
|
|
99
|
+
|
|
100
|
+
const { client } = getAuthenticatedClient();
|
|
101
|
+
const result = await fetchAll(createApis(client), { range: resolveDateRange({ days: 90 }) });
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The exported surface, grouped:
|
|
105
|
+
|
|
106
|
+
| Group | Exports |
|
|
107
|
+
|---|---|
|
|
108
|
+
| Session | `getAuthenticatedClient`, `login`, `logout` |
|
|
109
|
+
| Credentials | `resolveCredentials`, `saveCredentials`, `clearCredentials`, `loadStoredCredentials`, `discoverClientSecretFile`, `parseClientSecret` |
|
|
110
|
+
| Accounts | `loadAccount`, `listAccounts`, `saveAccount`, `removeAccount`, `setDefaultAccount`, `clearAllAccounts`, `migrateLegacyTokens` |
|
|
111
|
+
| APIs | `createApis`, `data`, `analytics`, `reporting` (namespace exports), plus everything in `api/transforms.js` |
|
|
112
|
+
| Orchestration | `fetchAll` |
|
|
113
|
+
| Dates | `resolveDateRange`, `daysBetween`, `toIsoDate` |
|
|
114
|
+
| Output | `renderEnvelope`, `createReporter` |
|
|
115
|
+
| Errors | `YtStatsError`, `ERROR_CODES`, `EXIT_CODES`, `mapGoogleError`, `diagnoseGoogleError`, `fail`, `redact` |
|
|
116
|
+
| Diagnostics | `DIAGNOSTICS`, `diagnose`, `isDiagnostic`, `SEVERITY`, `EXIT` |
|
|
117
|
+
| CLI | `buildProgram`, `main`, `SCOPES`, `configDir` |
|
|
118
|
+
|
|
119
|
+
Library callers get no envelope: `fetchAll` returns its result object directly and fetchers throw `YtStatsError`. Use `renderEnvelope()` if you want the CLI's output shape.
|
|
120
|
+
|
|
121
|
+
## Two error vocabularies
|
|
122
|
+
|
|
123
|
+
`ytstats` carries two code sets, and the distinction matters when reading the source:
|
|
124
|
+
|
|
125
|
+
- **`DIAGNOSTICS` codes** (`src/diagnostics.js`) — the fine-grained public catalog surfaced in the envelope: `AUTH_TOKEN_EXPIRED`, `API_QUOTA_EXCEEDED`, `INPUT_INVALID_DATE`, and so on. This is what consumers branch on.
|
|
126
|
+
- **`ERROR_CODES`** (`src/errors.js`) — the coarser internal vocabulary carried on `YtStatsError.code`: `NOT_AUTHENTICATED`, `QUOTA_EXCEEDED`, `INVALID_INPUT`. Used for control flow such as `FATAL_CODES` in `fetch-all.js`.
|
|
127
|
+
|
|
128
|
+
`legacyCodeFor()` bridges the first onto the second when `mapGoogleError()` builds an error. Both are documented in [output-contract.md](output-contract.md).
|
package/docs/auth.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: The bring-your-own-credentials OAuth model — credential resolution, the PKCE loopback flow, token storage, and multi-account handling.
|
|
3
|
+
tags: [auth, oauth, pkce, credentials, tokens, security]
|
|
4
|
+
source:
|
|
5
|
+
- src/auth/**
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Authentication
|
|
9
|
+
|
|
10
|
+
`ytstats` has **no built-in Google client ID**. Each user supplies their own Google Cloud OAuth client, and the CLI uses it. This document covers how that works internally; for the traps, see [gotchas/auth.md](gotchas/auth.md).
|
|
11
|
+
|
|
12
|
+
## Why bring your own credentials
|
|
13
|
+
|
|
14
|
+
Three consequences follow from having no shared client:
|
|
15
|
+
|
|
16
|
+
- **Quota is per-project.** The YouTube Data API grants every Google Cloud project 10,000 units/day. A shared client ID would make every user compete for one pool.
|
|
17
|
+
- **No verification bottleneck.** Apps requesting YouTube scopes need Google's OAuth verification to serve strangers. You are not a stranger to yourself, so the unverified-app warning is a one-time click rather than a blocker.
|
|
18
|
+
- **Nothing to trust.** There is no backend to send data to, because there is no backend.
|
|
19
|
+
|
|
20
|
+
The cost is roughly five minutes of one-time Google Cloud setup, walked through in the [README](../README.md#getting-started) and reproduced in the `SETUP_GUIDE` constant (`src/errors.js`) that ships inside diagnostics.
|
|
21
|
+
|
|
22
|
+
## Scopes
|
|
23
|
+
|
|
24
|
+
Three, all read-only, frozen in `src/auth/oauth.js`:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
export const SCOPES = Object.freeze([
|
|
28
|
+
'https://www.googleapis.com/auth/youtube.readonly',
|
|
29
|
+
'https://www.googleapis.com/auth/yt-analytics.readonly',
|
|
30
|
+
'https://www.googleapis.com/auth/yt-analytics-monetary.readonly',
|
|
31
|
+
]);
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
There is no code path that writes to a channel.
|
|
35
|
+
|
|
36
|
+
## Credential resolution
|
|
37
|
+
|
|
38
|
+
`resolveCredentials()` returns `{ clientId, clientSecret, source }`, checking four sources in order and taking the first that yields a complete pair:
|
|
39
|
+
|
|
40
|
+
| Order | Source | `source` value |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| 1 | `--client-secret <file>` | the path given |
|
|
43
|
+
| 2 | `YTSTATS_CLIENT_ID` **and** `YTSTATS_CLIENT_SECRET` (both required) | `environment` |
|
|
44
|
+
| 3 | `credentials.json` saved by a previous `ytstats login` | `stored` |
|
|
45
|
+
| 4 | `client_secret*.json` auto-discovered in the working directory | the discovered path |
|
|
46
|
+
|
|
47
|
+
If none match, it throws `AUTH_NO_CREDENTIALS` with a `detail` naming everything it searched.
|
|
48
|
+
|
|
49
|
+
`source` is for display only and never contains the secret — it is what `ytstats status` reports as `credentialSource` and what `login` prints to stderr.
|
|
50
|
+
|
|
51
|
+
### File shapes accepted
|
|
52
|
+
|
|
53
|
+
`parseClientSecret()` normalises the shapes Google hands out. It reads `raw.installed` (Desktop app), `raw.web`, or an already-flat object, and accepts both `client_id`/`client_secret` and `clientId`/`clientSecret` casings.
|
|
54
|
+
|
|
55
|
+
It rejects two things outright:
|
|
56
|
+
|
|
57
|
+
- `type: 'service_account'` → `AUTH_SERVICE_ACCOUNT`, marked `recoverable: false`. Service accounts can never work with YouTube APIs; see [the gotcha](gotchas/auth.md#service-accounts-can-never-be-used).
|
|
58
|
+
- A missing `client_id` or `client_secret` → `AUTH_CREDENTIALS_MALFORMED`.
|
|
59
|
+
|
|
60
|
+
### Auto-discovery
|
|
61
|
+
|
|
62
|
+
`discoverClientSecretFile(dir)` scans a directory for files starting with `client_secret` and ending in `.json` — the shape of Google's downloads, `client_secret_<numbers>-<hash>.apps.googleusercontent.com.json`. An exact `client_secret.json` wins; otherwise candidates are sorted for determinism so the same directory always yields the same file.
|
|
63
|
+
|
|
64
|
+
### Client ID pre-flight validation
|
|
65
|
+
|
|
66
|
+
`validateClientId()` runs before a browser is ever opened, because Google does not reject a bad client ID through the API — it renders "Access blocked" in the browser and never redirects, so the CLI would hang until its timeout.
|
|
67
|
+
|
|
68
|
+
Two tiers:
|
|
69
|
+
|
|
70
|
+
- Does not end in `.apps.googleusercontent.com` → **throws** `AUTH_CLIENT_ID_INVALID`.
|
|
71
|
+
- Right suffix, but not the canonical `<project-number>-<hash>` shape → **returns a warning diagnostic**, `AUTH_CLIENT_ID_SUSPICIOUS`, and the flow proceeds. Legacy clients occasionally deviate, and being wrong here must not lock anyone out.
|
|
72
|
+
|
|
73
|
+
`login` surfaces the warning in the envelope as well as on stderr, since it is the leading cause of a subsequent timeout.
|
|
74
|
+
|
|
75
|
+
## The login flow
|
|
76
|
+
|
|
77
|
+
`login()` in `src/auth/session.js` runs the loopback flow Google recommends for desktop apps:
|
|
78
|
+
|
|
79
|
+
1. **Resolve and validate credentials** — as above, before anything else happens.
|
|
80
|
+
2. **Generate a PKCE pair (S256) and an unguessable `state`.** The verifier is 64 random bytes base64url-encoded (86 characters, within RFC 7636's 43-128 range — the `slice(0, 128)` is a ceiling that never triggers at this size); the challenge is its base64url SHA-256 digest, 43 characters. The state is 24 random bytes, 32 characters. PKCE protects the authorization code against interception by another local process racing on the loopback port.
|
|
81
|
+
3. **Bind an HTTP server to `127.0.0.1` on an ephemeral port** — `server.listen(0, '127.0.0.1')`, never `0.0.0.0`.
|
|
82
|
+
4. **Open the browser** at Google's authorization endpoint with `access_type=offline&prompt=consent` (what makes Google return a refresh token) and `include_granted_scopes=true`.
|
|
83
|
+
5. **Capture the callback.** The handler 404s `/favicon.ico` and any path other than `/` or `/callback`, then compares the returned `state` in constant time via `crypto.timingSafeEqual`. A mismatch, a Google `error` parameter, or a missing code each rejects with its own diagnostic.
|
|
84
|
+
6. **Exchange the code** with the PKCE verifier and the same `redirect_uri` used in the authorization request.
|
|
85
|
+
7. **Fetch the channel identity** via `channels.list({ part: 'snippet,contentDetails', mine: true })` — *before* persisting, so a failed lookup cannot leave a half-written account behind.
|
|
86
|
+
8. **Persist** the credentials and the account.
|
|
87
|
+
|
|
88
|
+
The success page served to the browser contains no token material and never echoes the authorization code. A test asserts this.
|
|
89
|
+
|
|
90
|
+
### Timeout
|
|
91
|
+
|
|
92
|
+
The loopback server takes a `timeoutMs` (default 300 seconds, set via `login --timeout`). On expiry it rejects with `AUTH_TIMEOUT`, which is marked `retryable: false` — the usual cause is Google refusing the request in the browser and never redirecting at all, which a plain retry cannot fix.
|
|
93
|
+
|
|
94
|
+
### The --no-browser flow
|
|
95
|
+
|
|
96
|
+
`pasteFlow()` starts **no** listener. It sets `redirectUri` to `http://127.0.0.1:1` so the browser's redirect fails immediately and visibly, leaving the full URL — including `?code=…` — in the address bar. The user pastes it back, and `extractCode()` pulls the `code` parameter out (or accepts a bare code if that is what was pasted).
|
|
97
|
+
|
|
98
|
+
The same dead `redirect_uri` must be sent to the token exchange, because Google validates that it matches the authorization request.
|
|
99
|
+
|
|
100
|
+
## Token storage
|
|
101
|
+
|
|
102
|
+
Tokens live in `tokens.json` inside the per-user config directory (see [configuration.md](configuration.md)):
|
|
103
|
+
|
|
104
|
+
```jsonc
|
|
105
|
+
{
|
|
106
|
+
"version": 1,
|
|
107
|
+
"default": "UC...",
|
|
108
|
+
"accounts": {
|
|
109
|
+
"UC...": {
|
|
110
|
+
"channelId": "UC...",
|
|
111
|
+
"channelTitle": "…",
|
|
112
|
+
"customUrl": "@…",
|
|
113
|
+
"tokens": { "access_token": "…", "refresh_token": "…", "expiry_date": 0 },
|
|
114
|
+
"savedAt": "2026-07-27T10:00:00.000Z"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Keyed by channel, so one machine can hold several. The first account logged in wins `default`; `ytstats use` changes it.
|
|
121
|
+
|
|
122
|
+
**Saving merges rather than replaces.** A refresh response from Google contains a new `access_token` but no `refresh_token`, so `saveAccount()` spreads the existing tokens under the new ones. Overwriting would destroy the long-lived credential.
|
|
123
|
+
|
|
124
|
+
### Refresh persistence
|
|
125
|
+
|
|
126
|
+
`getAuthenticatedClient()` registers a `tokens` listener on the OAuth2 client:
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
client.on('tokens', tokens => {
|
|
130
|
+
try { saveAccount({ …account, tokens }); } catch { /* read-only dir must not break the command */ }
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Any token the googleapis client rotates during the process lifetime is written back to disk. The error is swallowed deliberately: if the config directory has become unwritable, the in-memory access token is still valid and the command can complete.
|
|
135
|
+
|
|
136
|
+
The client secret is required on every refresh, not only the initial exchange — which is why `saveCredentials()` runs at login and `getAuthenticatedClient()` resolves credentials before loading tokens.
|
|
137
|
+
|
|
138
|
+
### Account selection
|
|
139
|
+
|
|
140
|
+
`loadAccount(selector)` resolves in this order:
|
|
141
|
+
|
|
142
|
+
1. No selector → the `default` account, or `null` if none.
|
|
143
|
+
2. Exact match on the `accounts` key (the channel id).
|
|
144
|
+
3. Case-insensitive match against `customUrl` or `channelTitle`.
|
|
145
|
+
|
|
146
|
+
An unrecognised selector returns `null` — never a silent fallback to the default, which would answer a question about one channel with another channel's data. The caller turns that into `AUTH_ACCOUNT_UNKNOWN`, listing the channels that are available.
|
|
147
|
+
|
|
148
|
+
`listAccounts()` returns summaries without token material, safe to print.
|
|
149
|
+
|
|
150
|
+
## Failure diagnosis order
|
|
151
|
+
|
|
152
|
+
`getAuthenticatedClient()` resolves credentials **before** loading tokens. There is no single "not authenticated" bucket, because each cause needs a different fix:
|
|
153
|
+
|
|
154
|
+
| Situation | Diagnostic |
|
|
155
|
+
|---|---|
|
|
156
|
+
| No OAuth client anywhere | `AUTH_NO_CREDENTIALS` |
|
|
157
|
+
| Client exists, never logged in | `AUTH_NO_TOKENS` |
|
|
158
|
+
| `--account` names an unknown channel | `AUTH_ACCOUNT_UNKNOWN` |
|
|
159
|
+
| Refresh token rejected (`invalid_grant`) | `AUTH_TOKEN_EXPIRED` |
|
|
160
|
+
| Access explicitly revoked | `AUTH_TOKEN_REVOKED` |
|
|
161
|
+
| Consent screen dismissed | `AUTH_CONSENT_DECLINED` |
|
|
162
|
+
| Callback never arrived | `AUTH_TIMEOUT` |
|
|
163
|
+
| State check failed | `AUTH_STATE_MISMATCH` |
|
|
164
|
+
| Service account key supplied | `AUTH_SERVICE_ACCOUNT` |
|
|
165
|
+
| Client ID malformed | `AUTH_CLIENT_ID_INVALID` |
|
|
166
|
+
| Google account owns no channel | `AUTH_NO_CHANNEL` |
|
|
167
|
+
|
|
168
|
+
Telling someone who has not yet created a Google Cloud project to "run login" sends them down a path that cannot succeed — hence the ordering. Full catalog in [output-contract.md](output-contract.md#diagnostic-catalog).
|
|
169
|
+
|
|
170
|
+
## Logout
|
|
171
|
+
|
|
172
|
+
`logout()` revokes with Google and forgets locally:
|
|
173
|
+
|
|
174
|
+
1. Collect the target accounts — all of them with `--all`, otherwise the selected or default one.
|
|
175
|
+
2. Resolve credentials if possible. Without them revocation is impossible, but local removal still proceeds.
|
|
176
|
+
3. For each account, call `client.revokeToken()` on the refresh token (falling back to the access token), wrapped in `try`/`catch`.
|
|
177
|
+
4. Remove the account locally regardless of whether revocation succeeded.
|
|
178
|
+
5. With `--forget-credentials`, delete `credentials.json` too.
|
|
179
|
+
|
|
180
|
+
Returns `{ loggedOut, revoked, accounts }`. `revoked` is false when the machine was offline or the token was already invalid — the local state is still clean.
|
|
181
|
+
|
|
182
|
+
## Legacy import
|
|
183
|
+
|
|
184
|
+
`migrateLegacyTokens()` performs a one-time import of a pre-`ytstats` per-project token file. It never overwrites an existing account, returning `{ migrated: false, reason }` for `no-legacy-file`, `no-refresh-token`, `unknown-channel`, or `already-logged-in`.
|
|
185
|
+
|
|
186
|
+
Because the legacy file carries no channel identity, `import-legacy` in `src/cli.js` exchanges the tokens for one via `channels.list` before calling it.
|