copyhub-cli 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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();
@@ -18,30 +18,41 @@ export function resolveElectronBinary() {
18
18
  return abs;
19
19
  }
20
20
 
21
+ function spawnOptsBase(stdio, cwd, env, detached) {
22
+ /** @type {import('node:child_process').SpawnOptions} */
23
+ const o = {
24
+ stdio,
25
+ cwd,
26
+ env,
27
+ detached,
28
+ };
29
+ if (process.platform === 'win32') {
30
+ o.windowsHide = true;
31
+ }
32
+ return o;
33
+ }
34
+
21
35
  /**
22
36
  * Spawn the floating history overlay (Electron).
23
- * @param {{ stdio?: 'inherit' | 'ignore' | 'pipe', envExtra?: Record<string, string> }} [opts]
37
+ * @param {{
38
+ * stdio?: 'inherit' | 'ignore' | 'pipe',
39
+ * envExtra?: Record<string, string>,
40
+ * detached?: boolean,
41
+ * }} [opts]
24
42
  */
25
43
  export function spawnCopyhubOverlay(opts = {}) {
26
44
  const stdio = opts.stdio ?? 'inherit';
45
+ const detached = opts.detached ?? false;
27
46
  const root = getProjectRoot();
28
47
  const uiMain = join(root, 'ui', 'main.mjs');
29
48
  const env = { ...process.env, ...opts.envExtra };
30
49
  const direct = resolveElectronBinary();
31
50
  if (direct) {
32
- return spawn(direct, [uiMain], {
33
- stdio,
34
- cwd: root,
35
- env,
36
- detached: false,
37
- });
51
+ return spawn(direct, [uiMain], spawnOptsBase(stdio, root, env, detached));
38
52
  }
39
53
  const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
40
54
  return spawn(npx, ['--yes', 'electron', uiMain], {
41
- stdio,
42
- cwd: root,
55
+ ...spawnOptsBase(stdio, root, env, detached),
43
56
  shell: true,
44
- env,
45
- detached: false,
46
57
  });
47
58
  }
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;
@@ -1,4 +1,4 @@
1
- import { appendHistory } from './storage.js';
1
+ import { appendHistory, isDuplicateOfLatestHistory } from './storage.js';
2
2
  import { startClipboardWatcher } from './clipboard-watcher.js';
3
3
  import { appendClipboardToSheet } from './sheets.js';
4
4
  import { loadTokens } from './tokens.js';
@@ -73,6 +73,9 @@ export async function runCopyhubDaemon(opts, io = console) {
73
73
  let lastSheetLogAt = 0;
74
74
 
75
75
  const watcher = startClipboardWatcher(async (text) => {
76
+ if (isDuplicateOfLatestHistory(text)) {
77
+ return;
78
+ }
76
79
  let synced = false;
77
80
  if (useSheet && sheetTarget && (tokens?.refresh_token || tokens?.access_token)) {
78
81
  try {
package/src/storage.js CHANGED
@@ -57,3 +57,14 @@ export function readRecentHistorySync(maxLines = 200) {
57
57
  }
58
58
  return out.reverse();
59
59
  }
60
+
61
+ /**
62
+ * True when `text` equals the latest saved history row (skip consecutive identical copies).
63
+ * @param {string} text
64
+ */
65
+ export function isDuplicateOfLatestHistory(text) {
66
+ if (typeof text !== 'string' || text.length === 0) return false;
67
+ const recent = readRecentHistorySync(1);
68
+ const last = recent[0];
69
+ return typeof last?.text === 'string' && last.text === text;
70
+ }
@@ -0,0 +1,10 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { DIR } from './paths.js';
4
+
5
+ /** Delete the entire ~/.copyhub directory (all local CopyHub data). */
6
+ export async function wipeCopyhubDirectory() {
7
+ if (existsSync(DIR)) {
8
+ await rm(DIR, { recursive: true, force: true });
9
+ }
10
+ }