copyhub-cli 1.0.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/.env.example +24 -0
- package/README.md +122 -0
- package/package.json +39 -0
- package/src/cli.js +337 -0
- package/src/clipboard-watcher.js +45 -0
- package/src/config.js +180 -0
- package/src/daemon-state.js +51 -0
- package/src/electron-launcher.js +47 -0
- package/src/oauth.js +579 -0
- package/src/paths.js +9 -0
- package/src/platform.js +17 -0
- package/src/sheet-api-errors.js +50 -0
- package/src/sheet-daily.js +11 -0
- package/src/sheets.js +106 -0
- package/src/start-daemon-logic.js +114 -0
- package/src/stop-process.js +29 -0
- package/src/storage.js +59 -0
- package/src/tokens.js +24 -0
- package/ui/main.mjs +331 -0
- package/ui/preload.cjs +12 -0
- package/ui/renderer/index.html +224 -0
package/src/oauth.js
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { URL } from 'node:url';
|
|
4
|
+
import { OAuth2Client } from 'google-auth-library';
|
|
5
|
+
import open from 'open';
|
|
6
|
+
import {
|
|
7
|
+
loadConfig,
|
|
8
|
+
DEFAULT_OAUTH_REDIRECT_PORT,
|
|
9
|
+
ENV_GOOGLE_CLIENT_ID,
|
|
10
|
+
ENV_GOOGLE_CLIENT_SECRET,
|
|
11
|
+
mergeConfigPartial,
|
|
12
|
+
loadSheetSyncTarget,
|
|
13
|
+
loadOverlayAcceleratorFromConfigSync,
|
|
14
|
+
loadOverlayPlatformFromConfigSync,
|
|
15
|
+
} from './config.js';
|
|
16
|
+
import { saveTokens, loadTokens } from './tokens.js';
|
|
17
|
+
import { TOKENS_PATH } from './paths.js';
|
|
18
|
+
|
|
19
|
+
const SCOPES = ['https://www.googleapis.com/auth/spreadsheets'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {import('./config.js').CopyHubConfig} cfg
|
|
23
|
+
*/
|
|
24
|
+
export function createOAuthClient(cfg) {
|
|
25
|
+
const port = cfg.redirectPort ?? DEFAULT_OAUTH_REDIRECT_PORT;
|
|
26
|
+
const redirectUri = `http://127.0.0.1:${port}/oauth2callback`;
|
|
27
|
+
return new OAuth2Client(cfg.clientId, cfg.clientSecret, redirectUri);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @returns {Promise<import('google-auth-library').OAuth2Client>}
|
|
32
|
+
*/
|
|
33
|
+
export async function getAuthorizedClient() {
|
|
34
|
+
const cfg = await loadConfig();
|
|
35
|
+
if (!cfg) {
|
|
36
|
+
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`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const client = createOAuthClient(cfg);
|
|
41
|
+
const tokens = await loadTokens();
|
|
42
|
+
if (tokens?.refresh_token || tokens?.access_token) {
|
|
43
|
+
client.setCredentials(tokens);
|
|
44
|
+
}
|
|
45
|
+
return client;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('node:http').IncomingMessage} req
|
|
50
|
+
*/
|
|
51
|
+
const MAX_BODY = 32 * 1024;
|
|
52
|
+
|
|
53
|
+
function readRequestBody(req) {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const chunks = [];
|
|
56
|
+
let n = 0;
|
|
57
|
+
req.on('data', (c) => {
|
|
58
|
+
n += c.length;
|
|
59
|
+
if (n > MAX_BODY) {
|
|
60
|
+
reject(new Error('Body too large'));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
chunks.push(c);
|
|
64
|
+
});
|
|
65
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
66
|
+
req.on('error', reject);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function escapeHtml(s) {
|
|
71
|
+
return String(s)
|
|
72
|
+
.replace(/&/g, '&')
|
|
73
|
+
.replace(/</g, '<')
|
|
74
|
+
.replace(/"/g, '"')
|
|
75
|
+
.replace(/'/g, ''');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Shortcut presets (Electron Accelerator) per platform — embedded as JSON in the setup page. */
|
|
79
|
+
const PLATFORM_PRESETS = {
|
|
80
|
+
win: [
|
|
81
|
+
{ label: 'Ctrl + Shift + H · recommended', value: 'CommandOrControl+Shift+H' },
|
|
82
|
+
{ label: 'Control + Shift + H', value: 'Control+Shift+H' },
|
|
83
|
+
{ label: 'Alt + Shift + H', value: 'Alt+Shift+H' },
|
|
84
|
+
],
|
|
85
|
+
mac: [
|
|
86
|
+
{ label: '⌘ + Shift + H · recommended', value: 'CommandOrControl+Shift+H' },
|
|
87
|
+
{ label: 'Command + Shift + H', value: 'Command+Shift+H' },
|
|
88
|
+
{ label: '⌘ + Shift + V', value: 'Command+Shift+V' },
|
|
89
|
+
],
|
|
90
|
+
linux: [
|
|
91
|
+
{ label: 'Ctrl + Shift + H · recommended', value: 'CommandOrControl+Shift+H' },
|
|
92
|
+
{ label: 'Control + Shift + H', value: 'Control+Shift+H' },
|
|
93
|
+
{ label: 'Alt + Shift + H', value: 'Alt+Shift+H' },
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} setupToken
|
|
99
|
+
* @param {string} currentSheetId
|
|
100
|
+
* @param {string} currentAccelerator
|
|
101
|
+
* @param {string} currentPlatform win | mac | linux | ''
|
|
102
|
+
*/
|
|
103
|
+
function setupPageHtml(setupToken, currentSheetId, currentAccelerator, currentPlatform) {
|
|
104
|
+
const idVal = escapeHtml(currentSheetId);
|
|
105
|
+
const accVal = escapeHtml(currentAccelerator);
|
|
106
|
+
const tVal = escapeHtml(setupToken);
|
|
107
|
+
const plat =
|
|
108
|
+
currentPlatform === 'mac' || currentPlatform === 'linux' ? currentPlatform : 'win';
|
|
109
|
+
const platJson = JSON.stringify(plat);
|
|
110
|
+
const presetsJson = JSON.stringify(PLATFORM_PRESETS);
|
|
111
|
+
|
|
112
|
+
return `<!DOCTYPE html>
|
|
113
|
+
<html lang="en">
|
|
114
|
+
<head>
|
|
115
|
+
<meta charset="utf-8" />
|
|
116
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
117
|
+
<title>CopyHub — Setup</title>
|
|
118
|
+
<style>
|
|
119
|
+
:root {
|
|
120
|
+
--bg: #f0f4fa;
|
|
121
|
+
--card: #ffffff;
|
|
122
|
+
--text: #141824;
|
|
123
|
+
--muted: #5a6272;
|
|
124
|
+
--line: #e2e8f1;
|
|
125
|
+
--accent: #2563eb;
|
|
126
|
+
--accent-soft: #eff6ff;
|
|
127
|
+
--radius: 14px;
|
|
128
|
+
--shadow: 0 4px 24px rgba(20, 24, 36, 0.08), 0 1px 3px rgba(20, 24, 36, 0.04);
|
|
129
|
+
}
|
|
130
|
+
* { box-sizing: border-box; }
|
|
131
|
+
body {
|
|
132
|
+
margin: 0;
|
|
133
|
+
min-height: 100vh;
|
|
134
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
135
|
+
background: linear-gradient(160deg, #e8eef9 0%, #f5f7fb 45%, #eef2f8 100%);
|
|
136
|
+
color: var(--text);
|
|
137
|
+
padding: 32px 16px 48px;
|
|
138
|
+
line-height: 1.55;
|
|
139
|
+
}
|
|
140
|
+
.wrap { max-width: 560px; margin: 0 auto; }
|
|
141
|
+
.brand {
|
|
142
|
+
font-size: 13px;
|
|
143
|
+
font-weight: 600;
|
|
144
|
+
letter-spacing: 0.06em;
|
|
145
|
+
text-transform: uppercase;
|
|
146
|
+
color: var(--accent);
|
|
147
|
+
margin-bottom: 8px;
|
|
148
|
+
}
|
|
149
|
+
h1 {
|
|
150
|
+
font-size: 1.65rem;
|
|
151
|
+
font-weight: 700;
|
|
152
|
+
letter-spacing: -0.02em;
|
|
153
|
+
margin: 0 0 8px;
|
|
154
|
+
line-height: 1.25;
|
|
155
|
+
}
|
|
156
|
+
.sub { color: var(--muted); font-size: 15px; margin-bottom: 28px; }
|
|
157
|
+
.card {
|
|
158
|
+
background: var(--card);
|
|
159
|
+
border-radius: var(--radius);
|
|
160
|
+
box-shadow: var(--shadow);
|
|
161
|
+
border: 1px solid rgba(255,255,255,0.8);
|
|
162
|
+
padding: 28px 26px 26px;
|
|
163
|
+
margin-bottom: 20px;
|
|
164
|
+
}
|
|
165
|
+
.card h2 {
|
|
166
|
+
font-size: 0.75rem;
|
|
167
|
+
text-transform: uppercase;
|
|
168
|
+
letter-spacing: 0.08em;
|
|
169
|
+
color: var(--muted);
|
|
170
|
+
margin: 0 0 14px;
|
|
171
|
+
font-weight: 600;
|
|
172
|
+
}
|
|
173
|
+
label.field-label {
|
|
174
|
+
display: block;
|
|
175
|
+
font-weight: 600;
|
|
176
|
+
font-size: 14px;
|
|
177
|
+
margin-bottom: 8px;
|
|
178
|
+
}
|
|
179
|
+
input[type="text"] {
|
|
180
|
+
width: 100%;
|
|
181
|
+
padding: 12px 14px;
|
|
182
|
+
font-size: 15px;
|
|
183
|
+
border: 1px solid var(--line);
|
|
184
|
+
border-radius: 10px;
|
|
185
|
+
background: #fafbfd;
|
|
186
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
187
|
+
}
|
|
188
|
+
input[type="text"]:focus {
|
|
189
|
+
outline: none;
|
|
190
|
+
border-color: var(--accent);
|
|
191
|
+
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
|
192
|
+
background: #fff;
|
|
193
|
+
}
|
|
194
|
+
.hint {
|
|
195
|
+
font-size: 13px;
|
|
196
|
+
color: var(--muted);
|
|
197
|
+
margin-top: 10px;
|
|
198
|
+
line-height: 1.5;
|
|
199
|
+
}
|
|
200
|
+
.hint code {
|
|
201
|
+
font-size: 12px;
|
|
202
|
+
background: var(--accent-soft);
|
|
203
|
+
color: #1d4ed8;
|
|
204
|
+
padding: 2px 7px;
|
|
205
|
+
border-radius: 6px;
|
|
206
|
+
font-weight: 500;
|
|
207
|
+
}
|
|
208
|
+
.platform-row {
|
|
209
|
+
display: grid;
|
|
210
|
+
grid-template-columns: repeat(3, 1fr);
|
|
211
|
+
gap: 10px;
|
|
212
|
+
margin-bottom: 16px;
|
|
213
|
+
}
|
|
214
|
+
@media (max-width: 480px) {
|
|
215
|
+
.platform-row { grid-template-columns: 1fr; }
|
|
216
|
+
}
|
|
217
|
+
.platform-btn {
|
|
218
|
+
appearance: none;
|
|
219
|
+
border: 2px solid var(--line);
|
|
220
|
+
background: #fafbfd;
|
|
221
|
+
border-radius: 12px;
|
|
222
|
+
padding: 14px 12px;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
font-size: 14px;
|
|
225
|
+
font-weight: 600;
|
|
226
|
+
color: var(--text);
|
|
227
|
+
transition: all 0.15s ease;
|
|
228
|
+
display: flex;
|
|
229
|
+
flex-direction: column;
|
|
230
|
+
align-items: center;
|
|
231
|
+
gap: 4px;
|
|
232
|
+
}
|
|
233
|
+
.platform-btn:hover {
|
|
234
|
+
border-color: #c7d4ea;
|
|
235
|
+
background: #fff;
|
|
236
|
+
}
|
|
237
|
+
.platform-btn[aria-pressed="true"] {
|
|
238
|
+
border-color: var(--accent);
|
|
239
|
+
background: var(--accent-soft);
|
|
240
|
+
color: #1e40af;
|
|
241
|
+
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.2);
|
|
242
|
+
}
|
|
243
|
+
.platform-btn .ico { font-size: 22px; line-height: 1; }
|
|
244
|
+
.platform-btn .name { font-size: 13px; }
|
|
245
|
+
.platform-btn .tag { font-size: 11px; font-weight: 500; color: var(--muted); }
|
|
246
|
+
.platform-btn[aria-pressed="true"] .tag { color: #3b82f6; }
|
|
247
|
+
.chips {
|
|
248
|
+
display: flex;
|
|
249
|
+
flex-wrap: wrap;
|
|
250
|
+
gap: 8px;
|
|
251
|
+
margin-top: 12px;
|
|
252
|
+
margin-bottom: 6px;
|
|
253
|
+
}
|
|
254
|
+
.chip {
|
|
255
|
+
border: 1px solid var(--line);
|
|
256
|
+
background: #fff;
|
|
257
|
+
padding: 8px 12px;
|
|
258
|
+
border-radius: 999px;
|
|
259
|
+
font-size: 12px;
|
|
260
|
+
font-weight: 500;
|
|
261
|
+
cursor: pointer;
|
|
262
|
+
color: var(--text);
|
|
263
|
+
transition: background 0.15s, border-color 0.15s;
|
|
264
|
+
}
|
|
265
|
+
.chip:hover {
|
|
266
|
+
border-color: var(--accent);
|
|
267
|
+
background: var(--accent-soft);
|
|
268
|
+
color: #1e40af;
|
|
269
|
+
}
|
|
270
|
+
.sheet-note {
|
|
271
|
+
font-size: 13px;
|
|
272
|
+
color: var(--muted);
|
|
273
|
+
padding: 12px 14px;
|
|
274
|
+
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
|
275
|
+
border-radius: 10px;
|
|
276
|
+
border: 1px solid var(--line);
|
|
277
|
+
margin-top: 14px;
|
|
278
|
+
}
|
|
279
|
+
.sheet-note strong { color: var(--text); }
|
|
280
|
+
button.submit {
|
|
281
|
+
width: 100%;
|
|
282
|
+
margin-top: 22px;
|
|
283
|
+
padding: 14px 20px;
|
|
284
|
+
font-size: 16px;
|
|
285
|
+
font-weight: 600;
|
|
286
|
+
color: #fff;
|
|
287
|
+
background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%);
|
|
288
|
+
border: none;
|
|
289
|
+
border-radius: 12px;
|
|
290
|
+
cursor: pointer;
|
|
291
|
+
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.35);
|
|
292
|
+
transition: transform 0.1s, box-shadow 0.15s;
|
|
293
|
+
}
|
|
294
|
+
button.submit:hover {
|
|
295
|
+
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.4);
|
|
296
|
+
transform: translateY(-1px);
|
|
297
|
+
}
|
|
298
|
+
button.submit:active { transform: translateY(0); }
|
|
299
|
+
</style>
|
|
300
|
+
</head>
|
|
301
|
+
<body>
|
|
302
|
+
<div class="wrap">
|
|
303
|
+
<div class="brand">CopyHub</div>
|
|
304
|
+
<h1>Post-login setup</h1>
|
|
305
|
+
<p class="sub">Saved to <code style="background:#e2e8f0;padding:2px 8px;border-radius:6px;font-size:13px;">~/.copyhub/config.json</code></p>
|
|
306
|
+
|
|
307
|
+
<form method="POST" action="/setup">
|
|
308
|
+
<input type="hidden" name="t" value="${tVal}" />
|
|
309
|
+
<input type="hidden" name="overlayPlatform" id="overlayPlatformField" value="${escapeHtml(plat)}" />
|
|
310
|
+
|
|
311
|
+
<div class="card">
|
|
312
|
+
<h2>Google Sheet</h2>
|
|
313
|
+
<label class="field-label" for="sid">Spreadsheet ID</label>
|
|
314
|
+
<input id="sid" type="text" name="googleSheetId" value="${idVal}" placeholder="From URL: …/spreadsheets/d/<ID>/edit" autocomplete="off" />
|
|
315
|
+
<p class="hint">Leave blank and fill later if needed. One tab per day named <code>COPYHUB-YYYY-MM-DD</code>.</p>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
<div class="card">
|
|
319
|
+
<h2>History overlay shortcut</h2>
|
|
320
|
+
<label class="field-label">Your platform</label>
|
|
321
|
+
<div class="platform-row" role="group" aria-label="Choose operating system">
|
|
322
|
+
<button type="button" class="platform-btn" data-platform="win" aria-pressed="false">
|
|
323
|
+
<span class="ico" aria-hidden="true">⊞</span>
|
|
324
|
+
<span class="name">Windows</span>
|
|
325
|
+
<span class="tag">Electron · Control</span>
|
|
326
|
+
</button>
|
|
327
|
+
<button type="button" class="platform-btn" data-platform="mac" aria-pressed="false">
|
|
328
|
+
<span class="ico" aria-hidden="true">⌘</span>
|
|
329
|
+
<span class="name">macOS</span>
|
|
330
|
+
<span class="tag">⌘ Command</span>
|
|
331
|
+
</button>
|
|
332
|
+
<button type="button" class="platform-btn" data-platform="linux" aria-pressed="false">
|
|
333
|
+
<span class="ico" aria-hidden="true">🐧</span>
|
|
334
|
+
<span class="name">Linux</span>
|
|
335
|
+
<span class="tag">X11 / Wayland</span>
|
|
336
|
+
</button>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
<label class="field-label" for="acc">Accelerator (blank = default Ctrl/⌘ + Shift + H)</label>
|
|
340
|
+
<input id="acc" type="text" name="overlayAccelerator" value="${accVal}" placeholder="Pick a preset below or type your own" autocomplete="off" spellcheck="false" />
|
|
341
|
+
|
|
342
|
+
<p class="hint">On Windows use <code>Control</code> in config, not <code>Ctrl</code>. Avoid <code>Control+Alt+…</code> (often grabbed by drivers).</p>
|
|
343
|
+
<p class="hint"><code>COPYHUB_OVERLAY_ACCELERATOR</code> in <code>.env</code>, if set, overrides this value.</p>
|
|
344
|
+
|
|
345
|
+
<div id="chipRegion" class="chips" aria-live="polite"></div>
|
|
346
|
+
<p id="platformHint" class="hint" style="margin-top:4px;"></p>
|
|
347
|
+
|
|
348
|
+
<div class="sheet-note">
|
|
349
|
+
<strong>Note:</strong> Linux needs a GUI session; you may need <code>xclip</code> / <code>wl-clipboard</code>. macOS may require Accessibility for global shortcuts.
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
<button type="submit" class="submit">Save settings</button>
|
|
354
|
+
</form>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<script>
|
|
358
|
+
(function () {
|
|
359
|
+
var PRESETS = ${presetsJson};
|
|
360
|
+
var initial = ${platJson};
|
|
361
|
+
var field = document.getElementById('overlayPlatformField');
|
|
362
|
+
var acc = document.getElementById('acc');
|
|
363
|
+
var chips = document.getElementById('chipRegion');
|
|
364
|
+
var hintEl = document.getElementById('platformHint');
|
|
365
|
+
var btns = document.querySelectorAll('.platform-btn');
|
|
366
|
+
|
|
367
|
+
var hints = {
|
|
368
|
+
win: 'Windows: CommandOrControl = Ctrl.',
|
|
369
|
+
mac: 'macOS: CommandOrControl = ⌘ Command.',
|
|
370
|
+
linux: 'Linux: same as Windows with Ctrl; clipboard depends on your desktop.'
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
function setPlatform(p) {
|
|
374
|
+
field.value = p;
|
|
375
|
+
btns.forEach(function (b) {
|
|
376
|
+
var on = b.getAttribute('data-platform') === p;
|
|
377
|
+
b.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
378
|
+
});
|
|
379
|
+
chips.innerHTML = '';
|
|
380
|
+
(PRESETS[p] || PRESETS.win).forEach(function (item) {
|
|
381
|
+
var el = document.createElement('button');
|
|
382
|
+
el.type = 'button';
|
|
383
|
+
el.className = 'chip';
|
|
384
|
+
el.textContent = item.label;
|
|
385
|
+
el.title = item.value;
|
|
386
|
+
el.addEventListener('click', function () {
|
|
387
|
+
acc.value = item.value;
|
|
388
|
+
acc.focus();
|
|
389
|
+
});
|
|
390
|
+
chips.appendChild(el);
|
|
391
|
+
});
|
|
392
|
+
hintEl.textContent = hints[p] || '';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
btns.forEach(function (b) {
|
|
396
|
+
b.addEventListener('click', function () {
|
|
397
|
+
setPlatform(b.getAttribute('data-platform'));
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
setPlatform(initial === 'mac' || initial === 'linux' ? initial : 'win');
|
|
402
|
+
})();
|
|
403
|
+
</script>
|
|
404
|
+
</body>
|
|
405
|
+
</html>`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const successHtml = `<!DOCTYPE html>
|
|
409
|
+
<html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>CopyHub</title>
|
|
410
|
+
<style>
|
|
411
|
+
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;}
|
|
412
|
+
.box{background:#fff;padding:36px 40px;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;}
|
|
413
|
+
.box h2{margin:0 0 12px;font-size:1.35rem;}
|
|
414
|
+
.box p{margin:.65rem 0;color:#5a6272;font-size:15px;}
|
|
415
|
+
.box code{background:#eff6ff;color:#1d4ed8;padding:2px 8px;border-radius:6px;font-size:13px;}
|
|
416
|
+
</style></head>
|
|
417
|
+
<body><div class="box"><h2>Settings saved</h2>
|
|
418
|
+
<p>You can close this tab.</p>
|
|
419
|
+
<p>Run <code>copyhub start</code> or restart the overlay to apply the shortcut.</p></div></body></html>`;
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Open browser for OAuth, then Spreadsheet ID setup page.
|
|
423
|
+
* @returns {Promise<void>}
|
|
424
|
+
*/
|
|
425
|
+
export async function runLoginFlow() {
|
|
426
|
+
const cfg = await loadConfig();
|
|
427
|
+
if (!cfg) {
|
|
428
|
+
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 ...`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
const port = cfg.redirectPort ?? DEFAULT_OAUTH_REDIRECT_PORT;
|
|
433
|
+
const redirectUri = `http://127.0.0.1:${port}/oauth2callback`;
|
|
434
|
+
const oauth2Client = new OAuth2Client(cfg.clientId, cfg.clientSecret, redirectUri);
|
|
435
|
+
|
|
436
|
+
const authUrl = oauth2Client.generateAuthUrl({
|
|
437
|
+
access_type: 'offline',
|
|
438
|
+
scope: SCOPES,
|
|
439
|
+
prompt: 'consent',
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await new Promise((resolve, reject) => {
|
|
443
|
+
/** @type {string | null} */
|
|
444
|
+
let setupToken = null;
|
|
445
|
+
let settled = false;
|
|
446
|
+
|
|
447
|
+
const finish = () => {
|
|
448
|
+
if (settled) return;
|
|
449
|
+
settled = true;
|
|
450
|
+
server.close(() => resolve(undefined));
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const server = createServer(async (req, res) => {
|
|
454
|
+
try {
|
|
455
|
+
if (!req.url) {
|
|
456
|
+
res.writeHead(400);
|
|
457
|
+
res.end('Bad request');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const u = new URL(req.url, `http://127.0.0.1:${port}`);
|
|
461
|
+
|
|
462
|
+
if (u.pathname === '/oauth2callback') {
|
|
463
|
+
const code = u.searchParams.get('code');
|
|
464
|
+
const errParam = u.searchParams.get('error');
|
|
465
|
+
if (errParam) {
|
|
466
|
+
res.writeHead(400);
|
|
467
|
+
res.end(`OAuth error: ${escapeHtml(errParam)}`);
|
|
468
|
+
server.close(() => reject(new Error(errParam)));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (!code) {
|
|
472
|
+
res.writeHead(400);
|
|
473
|
+
res.end('Missing code');
|
|
474
|
+
server.close(() => reject(new Error('Missing authorization code')));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const { tokens } = await oauth2Client.getToken(code);
|
|
478
|
+
oauth2Client.setCredentials(tokens);
|
|
479
|
+
await saveTokens(tokens);
|
|
480
|
+
setupToken = randomBytes(24).toString('hex');
|
|
481
|
+
res.writeHead(302, {
|
|
482
|
+
Location: `http://127.0.0.1:${port}/setup?t=${encodeURIComponent(setupToken)}`,
|
|
483
|
+
});
|
|
484
|
+
res.end();
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (u.pathname === '/setup' && req.method === 'GET') {
|
|
489
|
+
const t = u.searchParams.get('t');
|
|
490
|
+
if (!setupToken || t !== setupToken) {
|
|
491
|
+
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
492
|
+
res.end('<p>Invalid setup session. Run <code>copyhub login</code> again.</p>');
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const sheet = await loadSheetSyncTarget();
|
|
496
|
+
const currentId = sheet?.spreadsheetId ?? '';
|
|
497
|
+
const currentAcc = loadOverlayAcceleratorFromConfigSync();
|
|
498
|
+
const currentPlat = loadOverlayPlatformFromConfigSync();
|
|
499
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
500
|
+
res.end(setupPageHtml(setupToken, currentId, currentAcc, currentPlat));
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (u.pathname === '/setup' && req.method === 'POST') {
|
|
505
|
+
let body = '';
|
|
506
|
+
try {
|
|
507
|
+
body = await readRequestBody(req);
|
|
508
|
+
} catch {
|
|
509
|
+
res.writeHead(413);
|
|
510
|
+
res.end('Payload too large');
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const params = new URLSearchParams(body);
|
|
514
|
+
const t = params.get('t');
|
|
515
|
+
if (!setupToken || t !== setupToken) {
|
|
516
|
+
res.writeHead(403);
|
|
517
|
+
res.end('Forbidden');
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const sheetId = (params.get('googleSheetId') || '').trim();
|
|
521
|
+
const overlayAccelerator = (params.get('overlayAccelerator') ?? '').trim();
|
|
522
|
+
let overlayPlatform = (params.get('overlayPlatform') || 'win').trim().toLowerCase();
|
|
523
|
+
if (overlayPlatform === 'windows') overlayPlatform = 'win';
|
|
524
|
+
if (overlayPlatform === 'darwin' || overlayPlatform === 'macos') {
|
|
525
|
+
overlayPlatform = 'mac';
|
|
526
|
+
}
|
|
527
|
+
if (overlayPlatform !== 'win' && overlayPlatform !== 'mac' && overlayPlatform !== 'linux') {
|
|
528
|
+
overlayPlatform = 'win';
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
/** @type {Record<string, unknown>} */
|
|
532
|
+
const partial = { overlayAccelerator, overlayPlatform };
|
|
533
|
+
if (sheetId) partial.googleSheetId = sheetId;
|
|
534
|
+
await mergeConfigPartial(partial);
|
|
535
|
+
} catch {
|
|
536
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
537
|
+
res.end('<p>Could not write config. Check write permissions on ~/.copyhub/</p>');
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
setupToken = null;
|
|
541
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
542
|
+
res.end(successHtml);
|
|
543
|
+
setTimeout(finish, 400);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
res.writeHead(404);
|
|
548
|
+
res.end('Not found');
|
|
549
|
+
} catch (e) {
|
|
550
|
+
res.writeHead(500);
|
|
551
|
+
res.end('Server error');
|
|
552
|
+
server.close(() => reject(/** @type {Error} */ (e)));
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const idleMs = 20 * 60 * 1000;
|
|
557
|
+
const idleTimer = setTimeout(() => {
|
|
558
|
+
if (!settled) {
|
|
559
|
+
console.warn('Setup page idle timeout (20 minutes).');
|
|
560
|
+
finish();
|
|
561
|
+
}
|
|
562
|
+
}, idleMs);
|
|
563
|
+
server.on('close', () => clearTimeout(idleTimer));
|
|
564
|
+
|
|
565
|
+
server.on('error', reject);
|
|
566
|
+
server.listen(port, '127.0.0.1', async () => {
|
|
567
|
+
console.log(`OAuth: http://127.0.0.1:${port}/oauth2callback → then setup page /setup`);
|
|
568
|
+
console.log('Opening browser for Google sign-in (OAuth — Google Sheets)...');
|
|
569
|
+
try {
|
|
570
|
+
await open(authUrl);
|
|
571
|
+
} catch {
|
|
572
|
+
console.log('Could not open browser. Open this URL manually:');
|
|
573
|
+
console.log(authUrl);
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
console.log(`Saved OAuth tokens to ${TOKENS_PATH}`);
|
|
579
|
+
}
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const DIR = join(homedir(), '.copyhub');
|
|
5
|
+
export const CONFIG_PATH = join(DIR, 'config.json');
|
|
6
|
+
export const TOKENS_PATH = join(DIR, 'tokens.json');
|
|
7
|
+
export const HISTORY_PATH = join(DIR, 'history.jsonl');
|
|
8
|
+
/** Background copyhub start process state (JSON: pid, startedAt, ...) */
|
|
9
|
+
export const RUN_STATE_PATH = join(DIR, 'run.json');
|
package/src/platform.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { platform } from 'node:os';
|
|
2
|
+
|
|
3
|
+
/** Clipboard poll interval (ms). Env COPYHUB_POLL_MS overrides default. */
|
|
4
|
+
export function clipboardPollIntervalMs() {
|
|
5
|
+
const raw = process.env.COPYHUB_POLL_MS;
|
|
6
|
+
if (!raw) return 400;
|
|
7
|
+
const n = parseInt(raw, 10);
|
|
8
|
+
return Number.isFinite(n) && n >= 100 && n <= 60_000 ? n : 400;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Log a one-line hint on Linux (clipboard depends on X11/Wayland). */
|
|
12
|
+
export function logLinuxClipboardHint() {
|
|
13
|
+
if (platform() !== 'linux') return;
|
|
14
|
+
console.log(
|
|
15
|
+
'Linux: needs a GUI session (DISPLAY or WAYLAND_DISPLAY). If clipboard fails, install xclip, xsel, or wl-clipboard (X11 vs Wayland).',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {unknown} err
|
|
3
|
+
* @returns {string}
|
|
4
|
+
*/
|
|
5
|
+
function extractMessage(err) {
|
|
6
|
+
const g = /** @type {{ response?: { data?: { error?: { message?: string } } } }} */ (err);
|
|
7
|
+
return (
|
|
8
|
+
g.response?.data?.error?.message ||
|
|
9
|
+
/** @type {Error} */ (err)?.message ||
|
|
10
|
+
String(err)
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Short user-facing message for Google Sheets API errors (especially API disabled).
|
|
16
|
+
* @param {unknown} err
|
|
17
|
+
*/
|
|
18
|
+
export function formatGoogleSheetUserMessage(err) {
|
|
19
|
+
const msg = extractMessage(err);
|
|
20
|
+
|
|
21
|
+
if (
|
|
22
|
+
/Google Sheets API has not been used|it is disabled|SERVICE_DISABLED|has not been used in project/i.test(
|
|
23
|
+
msg,
|
|
24
|
+
)
|
|
25
|
+
) {
|
|
26
|
+
const m = msg.match(/project[= ](\d+)/i);
|
|
27
|
+
const projectId = m ? m[1] : '';
|
|
28
|
+
const enableUrl = projectId
|
|
29
|
+
? `https://console.cloud.google.com/apis/library/sheets.googleapis.com?project=${projectId}`
|
|
30
|
+
: 'https://console.cloud.google.com/apis/library/sheets.googleapis.com';
|
|
31
|
+
return (
|
|
32
|
+
`Google Sheets API is not enabled for Cloud project${projectId ? ` ${projectId}` : ''}. ` +
|
|
33
|
+
`Open Console → APIs & Services → Library → search "Google Sheets API" → Enable. ` +
|
|
34
|
+
`Or: ${enableUrl} — after enabling, wait 1–3 minutes then copy again.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (/PERMISSION_DENIED|does not have permission|insufficient authentication scopes/i.test(msg)) {
|
|
39
|
+
return (
|
|
40
|
+
'No permission to write this spreadsheet. Share the Sheet with the Google account used for copyhub login, ' +
|
|
41
|
+
`or run copyhub login again. Details: ${msg}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (/NOT_FOUND|not found|Unable to parse range|Requested entity was not found/i.test(msg)) {
|
|
46
|
+
return `Spreadsheet or range not found. Check Spreadsheet ID in ~/.copyhub/config.json. Details: ${msg}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return msg;
|
|
50
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daily tab name (machine local timezone): COPYHUB-YYYY-MM-DD
|
|
3
|
+
* Google Sheets: title max 100 chars; fixed format avoids forbidden characters.
|
|
4
|
+
*/
|
|
5
|
+
export function dailySheetTabName() {
|
|
6
|
+
const d = new Date();
|
|
7
|
+
const y = d.getFullYear();
|
|
8
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
9
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
10
|
+
return `COPYHUB-${y}-${m}-${day}`;
|
|
11
|
+
}
|