@paperboyseo/cli 0.0.0-stage → 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/README.md CHANGED
@@ -1,3 +1,189 @@
1
- # Temporary Holding Version
1
+ # @paperboyseo/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The command line for [Paperboy](https://paperboyseo.com), the open-source growth engine. Paperboy reads your Google Search Console data, picks topics worth writing, writes and fact-checks articles, publishes them and measures the result. This CLI drives all of that from your terminal or from CI.
4
+
5
+ - Zero runtime dependencies, Node 22 or newer.
6
+ - Works against the hosted service (`https://paperboyseo.com`) or your own self-hosted instance.
7
+ - Every command has a stable `--json` mode.
8
+
9
+ ## Install
10
+
11
+ `@paperboyseo/cli` is not on npm. Run it from a clone of the repository (Node 22 or newer):
12
+
13
+ ```sh
14
+ git clone https://github.com/paperboyseo/paperboy.git && cd paperboy
15
+ npm ci --prefix packages/cli && npm run build --prefix packages/cli
16
+ alias paperboy="node $PWD/packages/cli/dist/cli.js" # also available as paperboy-seo via the package bin
17
+ paperboy connect
18
+ ```
19
+
20
+ Note: the unscoped npm package named `paperboy` is a different project, so do not `npx paperboy`.
21
+
22
+ ## Quick start
23
+
24
+ ```sh
25
+ paperboy connect https://yoursite.com # sign in, add the site, connect Search Console
26
+ paperboy topics # what should we write next?
27
+ paperboy write --topic top_abc123 --wait
28
+ paperboy report
29
+ ```
30
+
31
+ ## Commands
32
+
33
+ Global flags: `--json`, `--site <id|domain>`, `--api-key <key>`, `--base-url <url>`, `--no-color`, `--yes`, `--help`, `--version`.
34
+
35
+ ### connect
36
+
37
+ ```sh
38
+ paperboy connect [site-url] [--self-host <baseUrl>] [--api-key <key>] [--no-wait] [--no-open]
39
+ ```
40
+
41
+ Signs in with the OAuth 2.0 device flow: the CLI prints a short code and a URL, opens your browser, and waits until you approve. The API key is saved to the config file. With `--api-key` the browser step is skipped and the key you paste is verified and saved.
42
+
43
+ If you pass a site URL, Paperboy adds the site, saves it as your default, and walks you through the Search Console authorization. The CLI waits up to 5 minutes for it to complete; use `--no-wait` to just print the link.
44
+
45
+ ```sh
46
+ paperboy connect https://yoursite.com
47
+ paperboy connect --self-host http://localhost:3000
48
+ paperboy connect --api-key pb_live_xxxxxxxx --self-host https://paperboy.example.com
49
+ ```
50
+
51
+ ### topics
52
+
53
+ ```sh
54
+ paperboy topics [--n 5]
55
+ ```
56
+
57
+ Lists the best topics to write next, each with why it was picked and the signals behind it (impressions, average position, SERP gate).
58
+
59
+ ```
60
+ 1. Best coffee grinders [top_1]
61
+ query: best coffee grinder
62
+ why: Page two with rising impressions
63
+ signals: 12,400 impressions, position 11.3, serp gate: pass
64
+ ```
65
+
66
+ ### write
67
+
68
+ ```sh
69
+ paperboy write [--topic <id|"free text">] [--publish] [--wait | --no-wait]
70
+ ```
71
+
72
+ Starts an article job. `--topic` takes a topic id from `paperboy topics`, or free text in quotes (anything containing whitespace is treated as text). Without `--topic` Paperboy picks one. With `--wait` (the default on a terminal) the CLI follows the job, then prints the title, status, URL and the fact-check summary (claims verified and held). Without it, the job id is printed and the command returns immediately. Each write sends an `Idempotency-Key` so retries never double-charge credits.
73
+
74
+ ### check
75
+
76
+ ```sh
77
+ paperboy check <url|file.md> [--sources sources.txt]
78
+ ```
79
+
80
+ Fact-checks a live URL or a local markdown file. `--sources` is a text file with one extra source URL per line (blank lines and lines starting with `#` are ignored). Claims are reported as VERIFIED, HELD, UNSUPPORTED or MISMATCH. The exit code is 0 when the check passes and 1 when it does not, so it drops straight into CI:
81
+
82
+ ```sh
83
+ paperboy check docs/launch-post.md || exit 1
84
+ ```
85
+
86
+ ### publish
87
+
88
+ ```sh
89
+ paperboy publish <article-id>
90
+ ```
91
+
92
+ Publishes an article through the site's configured publish method. On a terminal it asks for confirmation unless you pass `--yes`.
93
+
94
+ ### status
95
+
96
+ ```sh
97
+ paperboy status
98
+ ```
99
+
100
+ Shows the account owner, the credits balance (when credits are enabled), the site domain, its publish method, whether Search Console is connected, and the latest articles.
101
+
102
+ ### report
103
+
104
+ ```sh
105
+ paperboy report [--days 28] [--markdown]
106
+ ```
107
+
108
+ Prints totals (clicks, impressions, average position, CTR), a unicode sparkline of daily clicks and the last seven days. With `--markdown` the server renders a full markdown report instead.
109
+
110
+ ### mcp
111
+
112
+ ```sh
113
+ paperboy mcp
114
+ ```
115
+
116
+ Runs a Model Context Protocol server on stdio, so an AI agent can use the commands above as tools (`list_sites`, `suggest_topics`, `write_article`, `get_job`, `get_article`, `fact_check`, `publish_article`, `get_performance`). It uses the same key and base URL as every other command. Setup for Claude Code, Claude Desktop and Cursor: [docs/mcp.md](../../docs/mcp.md).
117
+
118
+ ## Choosing a site
119
+
120
+ Resolution order: `--site <id|domain>`, then the saved `default_site`, then the only site on your account. If you have several sites and none is selected, the CLI lists them and exits with an error.
121
+
122
+ ## JSON output
123
+
124
+ With `--json`, every command prints exactly one JSON object on stdout and nothing else. Progress text and the device-flow code go to stderr.
125
+
126
+ Success:
127
+
128
+ ```json
129
+ { "ok": true, "command": "topics", "data": { "topics": [] } }
130
+ ```
131
+
132
+ `data` is the API response for the command (for `write --wait` it is `{ "job_id", "article" }`, for `connect` it holds the base URL, a masked key preview, the config path and the site).
133
+
134
+ Failure:
135
+
136
+ ```json
137
+ { "ok": false, "error": { "code": "unauthorized", "message": "...", "status": 401 } }
138
+ ```
139
+
140
+ `status` is present for HTTP errors. Error codes come from the API when available, plus CLI codes such as `usage`, `network_error`, `timeout`, `bad_response`, `job_failed`, `site_required`, `no_site`, `expired_token` and `access_denied`.
141
+
142
+ ## Exit codes
143
+
144
+ | Code | Meaning |
145
+ | ---- | ------- |
146
+ | 0 | Success (for `check`: the fact-check passed) |
147
+ | 1 | `check` did not pass, or `publish` did not publish |
148
+ | 2 | Error: bad usage, network or API error, job failure |
149
+
150
+ In `--json` mode a failing `check` still prints `"ok": true` (the command ran) with `data.pass` set to `false`, and exits 1.
151
+
152
+ ## Configuration
153
+
154
+ Settings resolve in this order: command-line flag, then environment variable, then config file.
155
+
156
+ | Setting | Flag | Environment | Config key |
157
+ | ------- | ---- | ----------- | ---------- |
158
+ | API key | `--api-key` | `PAPERBOY_API_KEY` | `api_key` |
159
+ | Base URL | `--base-url` | `PAPERBOY_BASE_URL` | `base_url` |
160
+ | Site | `--site` | none | `default_site` |
161
+
162
+ The config file lives at `~/.config/paperboy/config.json` (or `$XDG_CONFIG_HOME/paperboy/config.json`; `PAPERBOY_CONFIG_DIR` overrides the directory). The directory is created with mode 0700 and the file with mode 0600. The CLI never prints your API key, only the first 12 characters followed by `...`.
163
+
164
+ Other environment variables: `NO_COLOR` (disable color), `FORCE_COLOR` (enable color), `PAPERBOY_NO_OPEN=1` (never open a browser).
165
+
166
+ ## Self-hosting
167
+
168
+ Point the CLI at your own instance once and it remembers:
169
+
170
+ ```sh
171
+ paperboy connect --self-host http://localhost:3000
172
+ paperboy status
173
+ ```
174
+
175
+ Or per command: `paperboy topics --base-url http://localhost:3000`, or set `PAPERBOY_BASE_URL`.
176
+
177
+ ## Development
178
+
179
+ ```sh
180
+ npm ci
181
+ npm run build
182
+ npm test
183
+ ```
184
+
185
+ Tests run the CLI end to end against a local mock HTTP server.
186
+
187
+ ## License
188
+
189
+ AGPL-3.0-only, copyright Hive Asset Group, LLC. See the LICENSE file in this package.
package/dist/api.js ADDED
@@ -0,0 +1,121 @@
1
+ import { ApiError, CliError } from './errors.js';
2
+ import { VERSION } from './version.js';
3
+ export class ApiClient {
4
+ baseUrl;
5
+ apiKey;
6
+ fetchFn;
7
+ timeoutMs;
8
+ constructor(opts) {
9
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
10
+ this.apiKey = opts.apiKey;
11
+ this.fetchFn = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
12
+ this.timeoutMs = opts.timeoutMs ?? 30_000;
13
+ }
14
+ get(path, opts = {}) {
15
+ return this.request('GET', path, opts);
16
+ }
17
+ post(path, body, opts = {}) {
18
+ return this.request('POST', path, { ...opts, body: body ?? {} });
19
+ }
20
+ async request(method, path, opts = {}) {
21
+ const auth = opts.auth ?? true;
22
+ if (auth && !this.apiKey) {
23
+ throw new ApiError({ code: 'unauthenticated', status: 401 });
24
+ }
25
+ let url = this.baseUrl + path;
26
+ const params = new URLSearchParams();
27
+ for (const [key, value] of Object.entries(opts.query ?? {})) {
28
+ if (value !== undefined)
29
+ params.set(key, String(value));
30
+ }
31
+ const qs = params.toString();
32
+ if (qs)
33
+ url += (url.includes('?') ? '&' : '?') + qs;
34
+ const headers = {
35
+ Accept: 'application/json',
36
+ 'User-Agent': `paperboy-cli/${VERSION}`,
37
+ };
38
+ if (auth && this.apiKey)
39
+ headers['Authorization'] = `Bearer ${this.apiKey}`;
40
+ if (opts.idempotencyKey)
41
+ headers['Idempotency-Key'] = opts.idempotencyKey;
42
+ const init = { method, headers, signal: AbortSignal.timeout(opts.timeoutMs ?? this.timeoutMs) };
43
+ if (opts.body !== undefined) {
44
+ headers['Content-Type'] = 'application/json';
45
+ init.body = JSON.stringify(opts.body);
46
+ }
47
+ let res;
48
+ try {
49
+ res = await this.fetchFn(url, init);
50
+ }
51
+ catch (err) {
52
+ throw networkError(err, this.baseUrl);
53
+ }
54
+ let text;
55
+ try {
56
+ text = await res.text();
57
+ }
58
+ catch (err) {
59
+ throw networkError(err, this.baseUrl);
60
+ }
61
+ const parsed = parseJson(text);
62
+ if (!res.ok)
63
+ throw toApiError(res.status, parsed, text);
64
+ if (parsed === undefined && text.trim() !== '') {
65
+ throw new CliError('bad_response', `${this.baseUrl} did not return JSON. Check the URL (use --base-url or --self-host).`, res.status);
66
+ }
67
+ return { status: res.status, data: (parsed ?? {}) };
68
+ }
69
+ }
70
+ function parseJson(text) {
71
+ if (text.trim() === '')
72
+ return undefined;
73
+ try {
74
+ return JSON.parse(text);
75
+ }
76
+ catch {
77
+ return undefined;
78
+ }
79
+ }
80
+ function networkError(err, baseUrl) {
81
+ const name = err instanceof Error ? err.name : '';
82
+ if (name === 'TimeoutError' || name === 'AbortError') {
83
+ return new CliError('timeout', `The request to ${baseUrl} timed out.`);
84
+ }
85
+ const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
86
+ const message = cause ?? (err instanceof Error ? err.message : String(err));
87
+ return new CliError('network_error', `Could not reach ${baseUrl}: ${message}`);
88
+ }
89
+ /** Accepts both {error:{code,message,request_id}} and the RFC 6749 style {error:"code"}. */
90
+ export function toApiError(status, body, rawText = '') {
91
+ let code = `http_${status}`;
92
+ let apiMessage;
93
+ let requestId;
94
+ if (body && typeof body === 'object') {
95
+ const err = body['error'];
96
+ if (typeof err === 'string') {
97
+ code = err;
98
+ const desc = body['error_description'];
99
+ if (typeof desc === 'string')
100
+ apiMessage = desc;
101
+ }
102
+ else if (err && typeof err === 'object') {
103
+ const e = err;
104
+ if (typeof e['code'] === 'string')
105
+ code = e['code'];
106
+ if (typeof e['message'] === 'string')
107
+ apiMessage = e['message'];
108
+ if (typeof e['request_id'] === 'string')
109
+ requestId = e['request_id'];
110
+ }
111
+ }
112
+ else if (rawText && rawText.length < 200 && !rawText.trimStart().startsWith('<')) {
113
+ apiMessage = rawText.trim();
114
+ }
115
+ const opts = { code, status };
116
+ if (apiMessage !== undefined)
117
+ opts.apiMessage = apiMessage;
118
+ if (requestId !== undefined)
119
+ opts.requestId = requestId;
120
+ return new ApiError(opts);
121
+ }
package/dist/args.js ADDED
@@ -0,0 +1,40 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { CliError } from './errors.js';
3
+ export const OPTIONS = {
4
+ json: { type: 'boolean' },
5
+ site: { type: 'string' },
6
+ 'api-key': { type: 'string' },
7
+ 'base-url': { type: 'string' },
8
+ 'no-color': { type: 'boolean' },
9
+ yes: { type: 'boolean', short: 'y' },
10
+ help: { type: 'boolean', short: 'h' },
11
+ version: { type: 'boolean', short: 'v' },
12
+ 'self-host': { type: 'string' },
13
+ 'no-wait': { type: 'boolean' },
14
+ 'no-open': { type: 'boolean' },
15
+ wait: { type: 'boolean' },
16
+ n: { type: 'string', short: 'n' },
17
+ topic: { type: 'string' },
18
+ publish: { type: 'boolean' },
19
+ sources: { type: 'string' },
20
+ days: { type: 'string' },
21
+ markdown: { type: 'boolean' },
22
+ };
23
+ export function parse(argv) {
24
+ try {
25
+ const { values, positionals } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
26
+ return { flags: values, positionals };
27
+ }
28
+ catch (err) {
29
+ throw new CliError('usage', (err instanceof Error ? err.message : String(err)) + ' (see `paperboy --help`)');
30
+ }
31
+ }
32
+ export function intFlag(value, name, fallback, max) {
33
+ if (value === undefined)
34
+ return fallback;
35
+ const n = Number(value);
36
+ if (!Number.isInteger(n) || n < 1 || n > max) {
37
+ throw new CliError('usage', `--${name} must be a whole number between 1 and ${max}.`);
38
+ }
39
+ return n;
40
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './main.js';
3
+ process.exitCode = await run(process.argv.slice(2), {
4
+ env: process.env, stdout: process.stdout, stderr: process.stderr,
5
+ });
@@ -0,0 +1,51 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { CliError } from '../errors.js';
4
+ import { pollJob } from '../jobs.js';
5
+ function readText(path, what) {
6
+ try {
7
+ return readFileSync(path, 'utf8');
8
+ }
9
+ catch (err) {
10
+ const reason = err instanceof Error && 'code' in err ? String(err.code) : 'unreadable';
11
+ throw new CliError('file_error', `Could not read ${what} "${path}" (${reason}).`);
12
+ }
13
+ }
14
+ export async function check(ctx) {
15
+ const { out, flags } = ctx;
16
+ const target = ctx.args[0];
17
+ if (!target)
18
+ throw new CliError('usage', 'Usage: paperboy check <url|file.md> [--sources file]');
19
+ const body = /^https?:\/\//i.test(target) ? { url: target } : { markdown: readText(target, 'file') };
20
+ if (flags.sources) {
21
+ body['sources'] = readText(flags.sources, 'sources file')
22
+ .split(/\r?\n/)
23
+ .map((l) => l.trim())
24
+ .filter((l) => l !== '' && !l.startsWith('#'));
25
+ }
26
+ const api = ctx.client({ timeoutMs: 120_000 });
27
+ const res = await api.post('/api/v1/fact-check', body, { idempotencyKey: randomUUID() });
28
+ let result;
29
+ if (res.status === 202 || 'job_id' in res.data) {
30
+ const jobId = res.data.job_id;
31
+ out.info(`Fact-check queued (${jobId}).`);
32
+ const job = await pollJob(ctx, api, jobId, { intervalMs: 2000 });
33
+ result = (job.result ?? job);
34
+ }
35
+ else {
36
+ result = res.data;
37
+ }
38
+ const checks = result.checks ?? [];
39
+ const total = result.claims_total ?? checks.length;
40
+ const verified = result.claims_verified ?? checks.filter((c) => c.status === 'verified').length;
41
+ out.result('check', result, () => {
42
+ const s = out.style;
43
+ out.line(`${result.pass ? s.green('PASS') : s.red('FAIL')}: ${verified} of ${total} claims verified`);
44
+ for (const c of checks) {
45
+ const label = c.status === 'verified' ? s.green('VERIFIED') : c.status === 'held' ? s.yellow('HELD') : s.red(c.status.toUpperCase());
46
+ const extra = [c.note, c.source ? `source: ${c.source}` : undefined].filter(Boolean).join('; ');
47
+ out.line(` ${label} ${c.claim}${extra ? s.dim(` (${extra})`) : ''}`);
48
+ }
49
+ });
50
+ return result.pass ? 0 : 1;
51
+ }
@@ -0,0 +1,161 @@
1
+ import { maskKey, updateConfig, configPath } from '../config.js';
2
+ import { ApiError, CliError } from '../errors.js';
3
+ const GSC_POLL_MS = 3000;
4
+ const GSC_TIMEOUT_MS = 5 * 60_000;
5
+ function normalizeBase(value) {
6
+ let url;
7
+ try {
8
+ url = new URL(value);
9
+ }
10
+ catch {
11
+ throw new CliError('usage', `"${value}" is not a valid URL. Example: http://localhost:3000`);
12
+ }
13
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
14
+ throw new CliError('usage', `"${value}" must start with http:// or https://`);
15
+ }
16
+ return value.replace(/\/+$/, '');
17
+ }
18
+ function normalizeSiteUrl(value) {
19
+ return /^https?:\/\//i.test(value) ? value : `https://${value}`;
20
+ }
21
+ export async function connect(ctx) {
22
+ const { flags, out, env } = ctx;
23
+ const baseUrl = normalizeBase(flags['self-host'] ?? ctx.settings.baseUrl);
24
+ const shouldOpen = !out.json && !flags['no-open'] && !env['PAPERBOY_NO_OPEN'];
25
+ const open = (url) => {
26
+ if (shouldOpen)
27
+ ctx.deps.open(url);
28
+ };
29
+ let apiKey;
30
+ let usedDeviceFlow = false;
31
+ if (flags['api-key']) {
32
+ apiKey = flags['api-key'];
33
+ await verifyKey(ctx.client({ baseUrl, apiKey }), baseUrl);
34
+ }
35
+ else {
36
+ apiKey = await deviceFlow(ctx, ctx.client({ baseUrl, apiKey: undefined }), open);
37
+ usedDeviceFlow = true;
38
+ }
39
+ const file = updateConfig(env, { base_url: baseUrl, api_key: apiKey });
40
+ const api = ctx.client({ baseUrl, apiKey });
41
+ const data = {
42
+ base_url: baseUrl,
43
+ api_key_preview: maskKey(apiKey),
44
+ config_path: file,
45
+ auth: usedDeviceFlow ? 'device_flow' : 'api_key',
46
+ };
47
+ out.prompt(`${out.style.green('Signed in.')} Saved key ${maskKey(apiKey)} to ${configPath(env)}`);
48
+ const siteArg = ctx.args[0];
49
+ if (siteArg) {
50
+ const site = await addSite(ctx, api, normalizeSiteUrl(siteArg), open, data);
51
+ data['site'] = site;
52
+ }
53
+ out.result('connect', data, () => {
54
+ if (data['site']) {
55
+ const site = data['site'];
56
+ out.line(`Site: ${site.domain} (${site.id})`);
57
+ out.line(`Search Console: ${site.gsc_connected ? out.style.green('connected') : out.style.yellow('not connected yet')}`);
58
+ }
59
+ out.line('Next: `paperboy topics` to see what to write.');
60
+ });
61
+ return 0;
62
+ }
63
+ async function verifyKey(api, baseUrl) {
64
+ try {
65
+ await api.get('/api/v1/me');
66
+ }
67
+ catch (err) {
68
+ if (err instanceof ApiError && err.status === 401) {
69
+ throw new CliError('invalid_api_key', `${baseUrl} rejected that API key.`, 401);
70
+ }
71
+ throw err;
72
+ }
73
+ }
74
+ async function deviceFlow(ctx, api, open) {
75
+ const { out } = ctx;
76
+ const { data: code } = await api.post('/api/v1/device/code', { client: 'paperboy-cli' }, { auth: false });
77
+ const url = code.verification_uri_complete ?? code.verification_uri;
78
+ out.prompt('To sign in, open this page in your browser:');
79
+ out.prompt(` ${out.style.cyan(url)}`);
80
+ out.prompt(`and confirm this code: ${out.style.bold(code.user_code)}`);
81
+ out.prompt('Waiting for approval...');
82
+ open(url);
83
+ let intervalMs = Math.max(0, (code.interval ?? 5) * 1000);
84
+ const expiresMs = (code.expires_in ?? 600) * 1000;
85
+ let waited = 0;
86
+ for (;;) {
87
+ if (waited >= expiresMs)
88
+ throw expired();
89
+ await ctx.deps.sleep(intervalMs);
90
+ waited += intervalMs;
91
+ try {
92
+ const { data } = await api.post('/api/v1/device/token', { device_code: code.device_code }, { auth: false });
93
+ if (!data.api_key)
94
+ throw new CliError('bad_response', 'The server approved the sign-in but returned no API key.');
95
+ return data.api_key;
96
+ }
97
+ catch (err) {
98
+ if (!(err instanceof ApiError))
99
+ throw err;
100
+ switch (err.code) {
101
+ case 'authorization_pending':
102
+ break;
103
+ case 'slow_down':
104
+ intervalMs += 5000;
105
+ break;
106
+ case 'expired_token':
107
+ throw expired();
108
+ case 'access_denied':
109
+ throw new CliError('access_denied', 'Sign-in was denied in the browser. Run `paperboy connect` to try again.');
110
+ default:
111
+ throw err;
112
+ }
113
+ }
114
+ }
115
+ }
116
+ function expired() {
117
+ return new CliError('expired_token', 'The sign-in code expired before it was approved. Run `paperboy connect` again.');
118
+ }
119
+ async function addSite(ctx, api, siteUrl, open, data) {
120
+ const { out, env } = ctx;
121
+ const created = await api.post('/api/v1/sites', { url: siteUrl });
122
+ let site = created.data.site;
123
+ updateConfig(env, { default_site: site.id });
124
+ out.prompt(`Added ${out.style.bold(site.domain)} (${site.id}) and set it as your default site.`);
125
+ if (site.gsc_connected)
126
+ return site;
127
+ let link = created.data.gsc_connect_url;
128
+ if (!link) {
129
+ try {
130
+ const res = await api.post(`/api/v1/sites/${encodeURIComponent(site.id)}/gsc/connect`, {});
131
+ link = res.data.url;
132
+ }
133
+ catch (err) {
134
+ // A self-host without its own Google OAuth client can still write and fact check, just without Search Console data.
135
+ if (err instanceof CliError && err.code === 'gsc_not_configured') {
136
+ out.prompt('Search Console is not set up on this server (GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET). Skipping it; see docs/self-host.md.');
137
+ return site;
138
+ }
139
+ throw err;
140
+ }
141
+ }
142
+ data['gsc_connect_url'] = link;
143
+ out.prompt('Connect Google Search Console so Paperboy can read your data:');
144
+ out.prompt(` ${out.style.cyan(link)}`);
145
+ open(link);
146
+ if (ctx.flags['no-wait'])
147
+ return site;
148
+ out.prompt('Waiting for Search Console authorization...');
149
+ let waited = 0;
150
+ while (!site.gsc_connected) {
151
+ if (waited >= GSC_TIMEOUT_MS) {
152
+ throw new CliError('gsc_timeout', 'Search Console was not connected within 5 minutes. Open the link above, then run `paperboy status`.');
153
+ }
154
+ await ctx.deps.sleep(GSC_POLL_MS);
155
+ waited += GSC_POLL_MS;
156
+ const res = await api.get(`/api/v1/sites/${encodeURIComponent(site.id)}`);
157
+ site = res.data.site;
158
+ }
159
+ out.prompt(out.style.green('Search Console connected.'));
160
+ return site;
161
+ }