copyhub-cli 1.0.1 → 1.0.4

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/.env.example CHANGED
@@ -1,4 +1,5 @@
1
- # Copy to .env and fill in values. CopyHub reads .env when you run the CLI from the current directory.
1
+ # Copy to `.env` (project folder), `~/.copyhub/.env`, or both. CopyHub merges these without relying on cwd alone:
2
+ # package dir `.env` → ~/.copyhub/.env → current working directory `.env` (later wins). Shell-exported vars always win.
2
3
  #
3
4
  # Google Cloud Console → APIs: enable "Google Sheets API" on the SAME project as your OAuth client ID
4
5
  # (if sync fails with project=..., open the Enable link in the log or Library → Google Sheets API → Enable).
@@ -10,6 +11,7 @@
10
11
  # copyhub start opens the Electron overlay by default; set to 1 for clipboard + Sheet only:
11
12
  # COPYHUB_START_NO_OVERLAY=1
12
13
 
14
+ # OAuth Client ID / Secret: optional here — running copyhub login opens a browser wizard that saves them to ~/.copyhub/config.json instead.
13
15
  COPYHUB_GOOGLE_CLIENT_ID=
14
16
  COPYHUB_GOOGLE_CLIENT_SECRET=
15
17
  COPYHUB_OAUTH_REDIRECT_PORT=19999
package/README.md CHANGED
@@ -45,6 +45,68 @@ Or run without linking:
45
45
  node src/cli.js <command>
46
46
  ```
47
47
 
48
+ ## Updating
49
+
50
+ Your settings (`~/.copyhub/config.json`, tokens, history) are kept when you upgrade the CLI.
51
+
52
+ **Recommended:** stop the background daemon before upgrading so Electron can reinstall cleanly; after upgrading start again:
53
+
54
+ ```bash
55
+ copyhub stop
56
+ ```
57
+
58
+ *(Skip `stop` if you only run `--foreground` or `overlay` and nothing is in the background.)*
59
+
60
+ ### Global install (`npm install -g copyhub-cli`)
61
+
62
+ Check what npm considers latest vs what you have:
63
+
64
+ ```bash
65
+ npm view copyhub-cli version
66
+ ```
67
+
68
+ Upgrade to the latest published release:
69
+
70
+ ```bash
71
+ npm install -g copyhub-cli@latest
72
+ ```
73
+
74
+ Alternatively:
75
+
76
+ ```bash
77
+ npm update -g copyhub-cli
78
+ ```
79
+
80
+ Restart CopyHub:
81
+
82
+ ```bash
83
+ copyhub start
84
+ ```
85
+
86
+ To see the installed package version: `npm list -g copyhub-cli --depth=0`.
87
+
88
+ ### From source
89
+
90
+ Pull latest commits and reinstall modules:
91
+
92
+ ```bash
93
+ copyhub stop
94
+ git pull
95
+ npm install
96
+ ```
97
+
98
+ If you previously ran **`npm link`**, linking stays tied to this folder — after `npm install` you usually **do not** need to link again unless npm warns otherwise:
99
+
100
+ ```bash
101
+ npm link
102
+ ```
103
+
104
+ Then:
105
+
106
+ ```bash
107
+ copyhub start
108
+ ```
109
+
48
110
  ## Google Cloud setup
49
111
 
50
112
  1. Enable **[Google Sheets API](https://console.cloud.google.com/apis/library/sheets.googleapis.com)** on your OAuth project.
@@ -54,13 +116,13 @@ node src/cli.js <command>
54
116
  http://127.0.0.1:19999/oauth2callback
55
117
  ```
56
118
 
57
- 3. Copy `.env.example` to `.env` and set:
119
+ 3. Provide OAuth credentials in **any one** of these ways:
58
120
 
59
- - `COPYHUB_GOOGLE_CLIENT_ID`
60
- - `COPYHUB_GOOGLE_CLIENT_SECRET`
61
- - Optionally `COPYHUB_OAUTH_REDIRECT_PORT` (default **19999**)
121
+ - **Recommended for first setup:** run **`copyhub login`** with no secrets configured — your browser opens a **localhost wizard** where you paste **Client ID** and **Client secret** (the same values as `COPYHUB_GOOGLE_CLIENT_ID` / `COPYHUB_GOOGLE_CLIENT_SECRET`); they are stored in `~/.copyhub/config.json`.
62
122
 
63
- Alternatively, store credentials in `~/.copyhub/config.json` via:
123
+ - Copy `.env.example` to **`~/.copyhub/.env`** and/or a `.env` in your project folder and set `COPYHUB_GOOGLE_CLIENT_ID`, `COPYHUB_GOOGLE_CLIENT_SECRET`, and optionally `COPYHUB_OAUTH_REDIRECT_PORT` (default **19999**). This works even with **`npm install -g`** when your shell is not in the repo directory.
124
+
125
+ - Or run **`copyhub config`**:
64
126
 
65
127
  ```bash
66
128
  copyhub config --client-id "<ID>" --client-secret "<SECRET>" [--sheet-id "<SPREADSHEET_ID>"] [--redirect-port 19999]
@@ -68,13 +130,16 @@ copyhub config --client-id "<ID>" --client-secret "<SECRET>" [--sheet-id "<SPREA
68
130
 
69
131
  ## First run
70
132
 
71
- 1. **Login** (opens the browser for OAuth, then a setup page):
133
+ 1. **Login** opens the browser:
72
134
 
73
135
  ```bash
74
136
  copyhub login
75
137
  ```
76
138
 
77
- 2. On the setup page, enter your **Spreadsheet ID** (from the URL `…/d/<SPREADSHEET_ID>/edit`), choose **platform** (Windows / macOS / Linux) for shortcut hints, set the **overlay accelerator** if you want, and save.
139
+ - If Client ID / Secret are **not** already in `.env` or `~/.copyhub/config.json`, the first screen collects them (**Google OAuth credentials** page). Submit it to save into config.
140
+ - After Google sign-in, another setup page asks for **Spreadsheet ID**, **platform**, and optional **overlay shortcut**.
141
+
142
+ 2. On that spreadsheet/setup page, enter your **Spreadsheet ID** (from the URL `…/d/<SPREADSHEET_ID>/edit`), choose **platform** (Windows / macOS / Linux) for shortcut hints, set the **overlay accelerator** if you want, and save.
78
143
 
79
144
  3. **Start** the background watcher (clipboard + Sheets + overlay by default):
80
145
 
@@ -116,7 +181,7 @@ copyhub --help
116
181
  | Command | What it does |
117
182
  |---------|----------------|
118
183
  | `copyhub config` | Writes OAuth client ID/secret (and optional Sheet ID, redirect port) to `~/.copyhub/config.json`. `--client-id` and `--client-secret` are required. |
119
- | `copyhub login` | Opens browser for Google OAuth, then the setup page (spreadsheet ID, platform, overlay shortcut). |
184
+ | `copyhub login` | Opens browser: localhost wizard for Client ID/secret if missing, then Google OAuth, then spreadsheet/platform setup. |
120
185
  | `copyhub logout` | Deletes saved OAuth tokens (`~/.copyhub/tokens.json`). |
121
186
  | `copyhub status` | Prints OAuth config source, sheet target, tokens, overlay settings, and whether the background daemon is running. |
122
187
  | `copyhub start` | Starts clipboard watcher + optional Sheets sync + Electron overlay in the **background** (closing the terminal does not stop it). Only one instance at a time. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copyhub-cli",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "CopyHub — clipboard, local history, Google Sheets sync (OAuth). Windows, macOS, Linux.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import 'dotenv/config';
2
+ import { loadCopyhubEnv } from './load-env.js';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { program } from 'commander';
@@ -31,6 +31,9 @@ import {
31
31
  import { killDaemonTree } from './stop-process.js';
32
32
  import { ensureDir } from './storage.js';
33
33
  import { runCopyhubDaemon } from './start-daemon-logic.js';
34
+ import { wipeCopyhubDirectory } from './wipe-data.js';
35
+
36
+ loadCopyhubEnv();
34
37
 
35
38
  const CLI_JS = fileURLToPath(new URL('./cli.js', import.meta.url));
36
39
 
@@ -72,7 +75,7 @@ program
72
75
  program
73
76
  .command('login')
74
77
  .description(
75
- `Google sign-in (OAuth Sheets), then Spreadsheet ID setup page — port ${DEFAULT_OAUTH_REDIRECT_PORT} or ${ENV_OAUTH_REDIRECT_PORT}`,
78
+ `OAuth Sheets flow (opens browser). If Client ID/Secret are missing, a localhost wizard saves them first callback port ${DEFAULT_OAUTH_REDIRECT_PORT} or ${ENV_OAUTH_REDIRECT_PORT}`,
76
79
  )
77
80
  .action(async () => {
78
81
  await runLoginFlow();
@@ -86,6 +89,35 @@ program
86
89
  console.log(`Removed tokens: ${TOKENS_PATH}`);
87
90
  });
88
91
 
92
+ program
93
+ .command('reset')
94
+ .description(
95
+ 'Stop background daemon if running; delete ~/.copyhub entirely (config, tokens, history, run state). Requires --yes',
96
+ )
97
+ .option('--yes', 'Required confirmation flag (destructive)')
98
+ .action(async (opts) => {
99
+ if (!opts.yes) {
100
+ console.error(
101
+ 'Refusing to wipe without --yes. Deletes ~/.copyhub (Windows: %USERPROFILE%\\.copyhub). Example: copyhub reset --yes',
102
+ );
103
+ process.exit(1);
104
+ }
105
+ pruneStaleRunState();
106
+ const s = readRunState();
107
+ if (s?.pid && isPidAlive(s.pid)) {
108
+ killDaemonTree(s.pid);
109
+ }
110
+ try {
111
+ await wipeCopyhubDirectory();
112
+ } catch (e) {
113
+ console.error((/** @type {Error} */ (e)).message || String(e));
114
+ process.exit(1);
115
+ }
116
+ console.log(`Removed CopyHub data directory: ${DIR}`);
117
+ console.log('.env is not changed — remove OAuth vars there if you want.');
118
+ console.log('If an overlay window was open separately, close it manually.');
119
+ });
120
+
89
121
  program
90
122
  .command('overlay')
91
123
  .description(
@@ -163,7 +195,7 @@ program
163
195
  if (!cfg) {
164
196
  console.log('OAuth config: missing');
165
197
  console.log(
166
- ` Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env (see .env.example), or run: copyhub config`,
198
+ ` Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env (see .env.example), run copyhub config, or copyhub login (browser wizard)`,
167
199
  );
168
200
  } else {
169
201
  const srcLabel =
@@ -333,6 +365,7 @@ program
333
365
  .action(() => {
334
366
  console.log(`copyhub config [--client-id ID] [--client-secret SEC] [--redirect-port P] [--sheet-id ID]
335
367
  copyhub login | copyhub logout | copyhub status
368
+ copyhub reset --yes (delete ~/.copyhub — stop daemon first is recommended)
336
369
  copyhub start [--no-sheet] [--no-overlay] [--foreground]
337
370
  Default runs in background (terminal can close). Single instance — second start is blocked.
338
371
  copyhub list (ls) | copyhub stop
package/src/config.js CHANGED
@@ -22,6 +22,26 @@ function parseRedirectPortFromEnv() {
22
22
  return n;
23
23
  }
24
24
 
25
+ /**
26
+ * Port for the OAuth HTTP listener (env wins, then saved config, then default).
27
+ * Does not require Client ID / Secret (used before credential bootstrap).
28
+ */
29
+ export function resolveOAuthListenPort() {
30
+ const envPort = parseRedirectPortFromEnv();
31
+ if (envPort != null) return envPort;
32
+ if (existsSync(CONFIG_PATH)) {
33
+ try {
34
+ const j = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
35
+ if (typeof j.redirectPort === 'number' && Number.isFinite(j.redirectPort)) {
36
+ return j.redirectPort;
37
+ }
38
+ } catch {
39
+ /* ignore */
40
+ }
41
+ }
42
+ return DEFAULT_OAUTH_REDIRECT_PORT;
43
+ }
44
+
25
45
  /** Both Client ID and Secret come from environment (or .env). */
26
46
  export function hasOAuthCredentialsInEnv() {
27
47
  const id = process.env[ENV_GOOGLE_CLIENT_ID]?.trim();
@@ -0,0 +1,39 @@
1
+ import dotenv from 'dotenv';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { DIR } from './paths.js';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ /**
10
+ * Merge `.env` files into `process.env` without overwriting shell-exported vars.
11
+ * Order (later wins among files only): package directory → ~/.copyhub/.env → cwd.
12
+ * Fixes global `copyhub login` when cwd has no `.env` (dotenv/config default looks only at cwd).
13
+ */
14
+ export function loadCopyhubEnv() {
15
+ const pkgRoot = join(__dirname, '..');
16
+ const paths = [
17
+ join(pkgRoot, '.env'),
18
+ join(DIR, '.env'),
19
+ join(process.cwd(), '.env'),
20
+ ];
21
+
22
+ /** @type {Record<string, string>} */
23
+ const merged = {};
24
+ for (const p of paths) {
25
+ if (!existsSync(p)) continue;
26
+ try {
27
+ const raw = readFileSync(p, 'utf8');
28
+ Object.assign(merged, dotenv.parse(raw));
29
+ } catch {
30
+ /* ignore unreadable or malformed .env */
31
+ }
32
+ }
33
+
34
+ for (const [key, value] of Object.entries(merged)) {
35
+ if (process.env[key] === undefined) {
36
+ process.env[key] = value;
37
+ }
38
+ }
39
+ }
package/src/oauth.js CHANGED
@@ -8,7 +8,9 @@ import {
8
8
  DEFAULT_OAUTH_REDIRECT_PORT,
9
9
  ENV_GOOGLE_CLIENT_ID,
10
10
  ENV_GOOGLE_CLIENT_SECRET,
11
+ ENV_OAUTH_REDIRECT_PORT,
11
12
  mergeConfigPartial,
13
+ resolveOAuthListenPort,
12
14
  loadSheetSyncTarget,
13
15
  loadOverlayAcceleratorFromConfigSync,
14
16
  loadOverlayPlatformFromConfigSync,
@@ -34,7 +36,7 @@ export async function getAuthorizedClient() {
34
36
  const cfg = await loadConfig();
35
37
  if (!cfg) {
36
38
  throw new Error(
37
- `OAuth is not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} (.env or environment), or run: copyhub config --client-id ID --client-secret SECRET`,
39
+ `OAuth is not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} (.env or environment), run copyhub config, or copyhub login (browser wizard).`,
38
40
  );
39
41
  }
40
42
  const client = createOAuthClient(cfg);
@@ -75,6 +77,256 @@ function escapeHtml(s) {
75
77
  .replace(/'/g, '&#39;');
76
78
  }
77
79
 
80
+ /**
81
+ * @param {string} bootstrapToken
82
+ * @param {number} listenPort
83
+ */
84
+ function credentialSetupPageHtml(bootstrapToken, listenPort) {
85
+ const tVal = escapeHtml(bootstrapToken);
86
+ const callbackUri = `http://127.0.0.1:${listenPort}/oauth2callback`;
87
+ return `<!DOCTYPE html>
88
+ <html lang="en">
89
+ <head>
90
+ <meta charset="utf-8" />
91
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
92
+ <title>CopyHub — Google OAuth credentials</title>
93
+ <style>
94
+ :root {
95
+ --text: #141824;
96
+ --muted: #5a6272;
97
+ --line: #e2e8f1;
98
+ --accent: #2563eb;
99
+ --accent-soft: #eff6ff;
100
+ --radius: 14px;
101
+ --shadow: 0 4px 24px rgba(20, 24, 36, 0.08), 0 1px 3px rgba(20, 24, 36, 0.04);
102
+ }
103
+ * { box-sizing: border-box; }
104
+ body {
105
+ margin: 0;
106
+ min-height: 100vh;
107
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
108
+ background: linear-gradient(160deg, #e8eef9 0%, #f5f7fb 45%, #eef2f8 100%);
109
+ color: var(--text);
110
+ padding: 32px 16px 48px;
111
+ line-height: 1.55;
112
+ }
113
+ .wrap { max-width: 520px; margin: 0 auto; }
114
+ .brand {
115
+ font-size: 13px;
116
+ font-weight: 600;
117
+ letter-spacing: 0.06em;
118
+ text-transform: uppercase;
119
+ color: var(--accent);
120
+ margin-bottom: 8px;
121
+ }
122
+ h1 { font-size: 1.55rem; font-weight: 700; margin: 0 0 8px; letter-spacing: -0.02em; }
123
+ .sub { color: var(--muted); font-size: 15px; margin-bottom: 24px; }
124
+ .card {
125
+ background: #fff;
126
+ border-radius: var(--radius);
127
+ box-shadow: var(--shadow);
128
+ border: 1px solid rgba(255,255,255,0.8);
129
+ padding: 26px 24px;
130
+ margin-bottom: 18px;
131
+ }
132
+ label.field-label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 8px; }
133
+ input[type="password"], input[type="text"] {
134
+ width: 100%;
135
+ padding: 12px 14px;
136
+ font-size: 15px;
137
+ border: 1px solid var(--line);
138
+ border-radius: 10px;
139
+ background: #fafbfd;
140
+ }
141
+ input:focus {
142
+ outline: none;
143
+ border-color: var(--accent);
144
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
145
+ background: #fff;
146
+ }
147
+ .hint { font-size: 13px; color: var(--muted); margin-top: 10px; line-height: 1.5; }
148
+ .hint code {
149
+ font-size: 12px;
150
+ background: var(--accent-soft);
151
+ color: #1d4ed8;
152
+ padding: 2px 7px;
153
+ border-radius: 6px;
154
+ font-weight: 500;
155
+ }
156
+ button.submit {
157
+ width: 100%;
158
+ margin-top: 20px;
159
+ padding: 14px 20px;
160
+ font-size: 16px;
161
+ font-weight: 600;
162
+ color: #fff;
163
+ background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
164
+ border: none;
165
+ border-radius: 12px;
166
+ cursor: pointer;
167
+ box-shadow: 0 2px 8px rgba(37, 99, 235, 0.35);
168
+ }
169
+ button.submit:hover { filter: brightness(1.05); }
170
+ .callback-box {
171
+ font-size: 13px;
172
+ padding: 12px 14px;
173
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
174
+ border-radius: 10px;
175
+ border: 1px solid var(--line);
176
+ word-break: break-all;
177
+ }
178
+ </style>
179
+ </head>
180
+ <body>
181
+ <div class="wrap">
182
+ <div class="brand">CopyHub</div>
183
+ <h1>Google Cloud OAuth</h1>
184
+ <p class="sub">Paste your OAuth 2.0 Client ID and Client Secret (same as <code>${ENV_GOOGLE_CLIENT_ID}</code> / <code>${ENV_GOOGLE_CLIENT_SECRET}</code> in <code>.env</code>). Stored in <code>~/.copyhub/config.json</code>.</p>
185
+
186
+ <div class="card">
187
+ <p class="hint" style="margin-top:0;"><strong>Authorized redirect URI</strong> in Google Cloud Console must include:</p>
188
+ <div class="callback-box"><code>${escapeHtml(callbackUri)}</code></div>
189
+ <p class="hint">Port comes from <code>${ENV_OAUTH_REDIRECT_PORT}</code> (currently <strong>${listenPort}</strong>) or your saved config.</p>
190
+ </div>
191
+
192
+ <form method="POST" action="/credentials">
193
+ <input type="hidden" name="t" value="${tVal}" />
194
+ <div class="card">
195
+ <label class="field-label" for="cid">Client ID</label>
196
+ <input id="cid" type="text" name="clientId" autocomplete="off" spellcheck="false" required />
197
+ <label class="field-label" for="csec" style="margin-top:16px;">Client secret</label>
198
+ <input id="csec" type="password" name="clientSecret" autocomplete="new-password" spellcheck="false" required />
199
+ </div>
200
+ <button type="submit" class="submit">Save and continue to Google sign-in</button>
201
+ </form>
202
+ </div>
203
+ </body>
204
+ </html>`;
205
+ }
206
+
207
+ const credentialSavedHtml = `<!DOCTYPE html>
208
+ <html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>CopyHub</title>
209
+ <style>
210
+ body{font-family:system-ui,sans-serif;min-height:100vh;margin:0;display:flex;align-items:center;justify-content:center;background:linear-gradient(160deg,#e8eef9,#f5f7fb);padding:24px;}
211
+ .box{background:#fff;padding:32px 36px;border-radius:16px;box-shadow:0 8px 32px rgba(20,24,36,.1);max-width:420px;text-align:center;line-height:1.65;color:#141824;}
212
+ .box h2{margin:0 0 12px;font-size:1.25rem;}
213
+ .box p{margin:.6rem 0;color:#5a6272;font-size:15px;}
214
+ .box code{background:#eff6ff;color:#1d4ed8;padding:2px 8px;border-radius:6px;font-size:13px;}
215
+ </style></head>
216
+ <body><div class="box"><h2>Credentials saved</h2>
217
+ <p>The login flow will open Google next (this tab can stay open).</p></div></body></html>`;
218
+
219
+ /**
220
+ * Localhost wizard when Client ID / Secret are missing from env and config file.
221
+ * @returns {Promise<void>}
222
+ */
223
+ async function runCredentialBootstrap() {
224
+ const listenPort = resolveOAuthListenPort();
225
+ await new Promise((resolve, reject) => {
226
+ /** @type {string | null} */
227
+ let bootstrapToken = randomBytes(24).toString('hex');
228
+ let settled = false;
229
+
230
+ const finish = () => {
231
+ if (settled) return;
232
+ settled = true;
233
+ server.close(() => resolve(undefined));
234
+ };
235
+
236
+ const server = createServer(async (req, res) => {
237
+ try {
238
+ if (!req.url) {
239
+ res.writeHead(400);
240
+ res.end('Bad request');
241
+ return;
242
+ }
243
+ const u = new URL(req.url, `http://127.0.0.1:${listenPort}`);
244
+
245
+ if (u.pathname === '/credentials' && req.method === 'GET') {
246
+ const t = u.searchParams.get('t');
247
+ if (!bootstrapToken || t !== bootstrapToken) {
248
+ res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
249
+ res.end('<p>Invalid session. Run <code>copyhub login</code> again.</p>');
250
+ return;
251
+ }
252
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
253
+ res.end(credentialSetupPageHtml(bootstrapToken, listenPort));
254
+ return;
255
+ }
256
+
257
+ if (u.pathname === '/credentials' && req.method === 'POST') {
258
+ let body = '';
259
+ try {
260
+ body = await readRequestBody(req);
261
+ } catch {
262
+ res.writeHead(413);
263
+ res.end('Payload too large');
264
+ return;
265
+ }
266
+ const params = new URLSearchParams(body);
267
+ const t = params.get('t');
268
+ if (!bootstrapToken || t !== bootstrapToken) {
269
+ res.writeHead(403);
270
+ res.end('Forbidden');
271
+ return;
272
+ }
273
+ const clientId = (params.get('clientId') || '').trim();
274
+ const clientSecret = (params.get('clientSecret') || '').trim();
275
+ if (!clientId || !clientSecret) {
276
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
277
+ res.end('<p>Client ID and Client secret are required.</p>');
278
+ return;
279
+ }
280
+ try {
281
+ await mergeConfigPartial({ clientId, clientSecret });
282
+ } catch {
283
+ res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
284
+ res.end('<p>Could not write config. Check write permissions on ~/.copyhub/</p>');
285
+ return;
286
+ }
287
+ bootstrapToken = null;
288
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
289
+ res.end(credentialSavedHtml);
290
+ setTimeout(finish, 400);
291
+ return;
292
+ }
293
+
294
+ res.writeHead(404);
295
+ res.end('Not found');
296
+ } catch (e) {
297
+ res.writeHead(500);
298
+ res.end('Server error');
299
+ server.close(() => reject(/** @type {Error} */ (e)));
300
+ }
301
+ });
302
+
303
+ const idleMs = 20 * 60 * 1000;
304
+ const idleTimer = setTimeout(() => {
305
+ if (!settled) {
306
+ console.warn('Credential setup idle timeout (20 minutes).');
307
+ finish();
308
+ }
309
+ }, idleMs);
310
+ server.on('close', () => clearTimeout(idleTimer));
311
+
312
+ server.on('error', (err) => {
313
+ if (!settled) reject(err);
314
+ });
315
+
316
+ server.listen(listenPort, '127.0.0.1', async () => {
317
+ const credUrl = `http://127.0.0.1:${listenPort}/credentials?t=${encodeURIComponent(bootstrapToken)}`;
318
+ console.log('Opening browser for Google OAuth credentials (localhost wizard)...');
319
+ console.log(`If it does not open: ${credUrl}`);
320
+ try {
321
+ await open(credUrl);
322
+ } catch {
323
+ console.log('Open this URL manually:');
324
+ console.log(credUrl);
325
+ }
326
+ });
327
+ });
328
+ }
329
+
78
330
  /** Shortcut presets (Electron Accelerator) per platform — embedded as JSON in the setup page. */
79
331
  const PLATFORM_PRESETS = {
80
332
  win: [
@@ -423,10 +675,25 @@ body{font-family:system-ui,sans-serif;min-height:100vh;margin:0;display:flex;ali
423
675
  * @returns {Promise<void>}
424
676
  */
425
677
  export async function runLoginFlow() {
426
- const cfg = await loadConfig();
678
+ let cfg = await loadConfig();
679
+ if (!cfg) {
680
+ const listenPort = resolveOAuthListenPort();
681
+ try {
682
+ await runCredentialBootstrap();
683
+ } catch (e) {
684
+ const code = /** @type {NodeJS.ErrnoException} */ (e)?.code;
685
+ if (code === 'EADDRINUSE') {
686
+ throw new Error(
687
+ `Port ${listenPort} is already in use. Stop the other process or set ${ENV_OAUTH_REDIRECT_PORT} to a free port.`,
688
+ );
689
+ }
690
+ throw /** @type {Error} */ (e);
691
+ }
692
+ cfg = await loadConfig();
693
+ }
427
694
  if (!cfg) {
428
695
  throw new Error(
429
- `Not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env, or run: copyhub config --client-id ... --client-secret ...`,
696
+ `OAuth is not configured. Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env, run copyhub config, or complete the browser credential wizard.`,
430
697
  );
431
698
  }
432
699
  const port = cfg.redirectPort ?? DEFAULT_OAUTH_REDIRECT_PORT;
@@ -0,0 +1,79 @@
1
+ import { google } from 'googleapis';
2
+ import { getAuthorizedClient } from './oauth.js';
3
+ import { loadSheetSyncTarget } from './config.js';
4
+ import { formatGoogleSheetUserMessage } from './sheet-api-errors.js';
5
+
6
+ /** @param {unknown} v */
7
+ function cellToString(v) {
8
+ if (v == null) return '';
9
+ if (typeof v === 'string') return v;
10
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
11
+ if (v instanceof Date) return v.toISOString();
12
+ return String(v);
13
+ }
14
+
15
+ /** @param {string} tabName */
16
+ function escapeTabName(tabName) {
17
+ return /[^A-Za-z0-9_]/.test(tabName)
18
+ ? `'${tabName.replace(/'/g, "''")}'`
19
+ : tabName;
20
+ }
21
+
22
+ /** @param {number} daysAgo */
23
+ function overlayDailyTabNameDaysAgo(daysAgo) {
24
+ const d = new Date();
25
+ d.setDate(d.getDate() - daysAgo);
26
+ const y = d.getFullYear();
27
+ const m = String(d.getMonth() + 1).padStart(2, '0');
28
+ const day = String(d.getDate()).padStart(2, '0');
29
+ return `COPYHUB-${y}-${m}-${day}`;
30
+ }
31
+
32
+ /**
33
+ * Clipboard rows from one COPYHUB-YYYY-MM-DD tab (newest timestamps first).
34
+ * Reads used cells in A:B from row 2 onward (append puts newer rows at the bottom).
35
+ * @param {number} daysAgo 0 = today
36
+ * @param {number} [maxRowsCap] Keep only this many newest rows (after sort).
37
+ */
38
+ export async function fetchOverlayDailyTabRows(daysAgo, maxRowsCap = 500) {
39
+ const day = Math.min(Math.max(Number(daysAgo), 0), 90);
40
+ const cap = Math.min(Math.max(Number(maxRowsCap), 1), 2000);
41
+
42
+ const target = await loadSheetSyncTarget();
43
+ if (!target) return [];
44
+
45
+ const auth = await getAuthorizedClient();
46
+ if (!auth.credentials.refresh_token && !auth.credentials.access_token) {
47
+ return [];
48
+ }
49
+
50
+ const sheets = google.sheets({ version: 'v4', auth });
51
+ const tab = overlayDailyTabNameDaysAgo(day);
52
+ const range = `${escapeTabName(tab)}!A2:B`;
53
+
54
+ try {
55
+ const res = await sheets.spreadsheets.values.get({
56
+ spreadsheetId: target.spreadsheetId,
57
+ range,
58
+ valueRenderOption: 'FORMATTED_VALUE',
59
+ });
60
+
61
+ const values = res.data.values ?? [];
62
+
63
+ /** @type {Array<{ ts: string, text: string, synced: boolean }>} */
64
+ const items = [];
65
+ for (const row of values) {
66
+ if (!Array.isArray(row) || row.length < 2) continue;
67
+ const ts = cellToString(row[0]).trim();
68
+ const text = cellToString(row[1]).trim();
69
+ if (!text) continue;
70
+ if (/^(time|thời gian)$/i.test(ts)) continue;
71
+ items.push({ ts: ts || '', text, synced: true });
72
+ }
73
+
74
+ items.sort((a, b) => (Date.parse(b.ts) || 0) - (Date.parse(a.ts) || 0));
75
+ return items.slice(0, cap);
76
+ } catch (e) {
77
+ throw new Error(formatGoogleSheetUserMessage(e));
78
+ }
79
+ }
package/src/sheets.js CHANGED
@@ -7,7 +7,7 @@ import { formatGoogleSheetUserMessage } from './sheet-api-errors.js';
7
7
  /**
8
8
  * @param {string} tabName
9
9
  */
10
- function a1RangeForTab(tabName) {
10
+ export function a1RangeForTab(tabName) {
11
11
  const escaped = /[^A-Za-z0-9_]/.test(tabName)
12
12
  ? `'${tabName.replace(/'/g, "''")}'`
13
13
  : tabName;