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 ADDED
@@ -0,0 +1,24 @@
1
+ # Copy to .env and fill in values. CopyHub reads .env when you run the CLI from the current directory.
2
+ #
3
+ # Google Cloud Console → APIs: enable "Google Sheets API" on the SAME project as your OAuth client ID
4
+ # (if sync fails with project=..., open the Enable link in the log or Library → Google Sheets API → Enable).
5
+ #
6
+ # Spreadsheet ID + platform + overlay shortcut: set after copyhub login (web setup page),
7
+ # or copyhub config ... --sheet-id <ID> and edit overlayPlatform / overlayAccelerator in ~/.copyhub/config.json
8
+ # One tab per local calendar day: COPYHUB-YYYY-MM-DD (machine timezone).
9
+ #
10
+ # copyhub start opens the Electron overlay by default; set to 1 for clipboard + Sheet only:
11
+ # COPYHUB_START_NO_OVERLAY=1
12
+
13
+ COPYHUB_GOOGLE_CLIENT_ID=
14
+ COPYHUB_GOOGLE_CLIENT_SECRET=
15
+ COPYHUB_OAUTH_REDIRECT_PORT=19999
16
+
17
+ # Overlay accelerator (optional): if set here it OVERRIDES the value saved after copyhub login (config overlayAccelerator).
18
+ COPYHUB_OVERLAY_ACCELERATOR=
19
+
20
+ # Set to 1 to NOT show the window when overlay starts (open via shortcut / tray only).
21
+ # COPYHUB_OVERLAY_HIDE_ON_START=1
22
+ # Set to 1 so the overlay does not close when clicking outside (default: click outside closes).
23
+ # COPYHUB_OVERLAY_STICKY=1
24
+ # Hide overlay icon from the taskbar: COPYHUB_OVERLAY_SKIP_TASKBAR=1
package/README.md ADDED
@@ -0,0 +1,122 @@
1
+ # CopyHub
2
+
3
+ CopyHub watches your **clipboard**, keeps a **local history** under `~/.copyhub/history.jsonl`, optionally syncs copies to **Google Sheets** (one tab per day), and shows an **Electron overlay** so you can browse recent clips quickly.
4
+
5
+ Runs on **Windows**, **macOS**, and **Linux**.
6
+
7
+ ## Requirements
8
+
9
+ - **Node.js** ≥ 18
10
+ - A **Google Cloud** project with:
11
+ - **Google Sheets API** enabled for the *same* project as your OAuth client
12
+ - **OAuth 2.0 Client** (Desktop app type works well for localhost redirect)
13
+
14
+ ## Installation
15
+
16
+ From this repository:
17
+
18
+ ```bash
19
+ npm install
20
+ ```
21
+
22
+ Link the CLI globally (optional):
23
+
24
+ ```bash
25
+ npm link
26
+ ```
27
+
28
+ Or run commands with:
29
+
30
+ ```bash
31
+ node src/cli.js <command>
32
+ ```
33
+
34
+ ## Google Cloud setup
35
+
36
+ 1. Enable **[Google Sheets API](https://console.cloud.google.com/apis/library/sheets.googleapis.com)** on your OAuth project.
37
+ 2. Create **OAuth 2.0 credentials** and add this **Authorized redirect URI** (adjust the port if you change it):
38
+
39
+ ```text
40
+ http://127.0.0.1:19999/oauth2callback
41
+ ```
42
+
43
+ 3. Copy `.env.example` to `.env` and set:
44
+
45
+ - `COPYHUB_GOOGLE_CLIENT_ID`
46
+ - `COPYHUB_GOOGLE_CLIENT_SECRET`
47
+ - Optionally `COPYHUB_OAUTH_REDIRECT_PORT` (default **19999**)
48
+
49
+ Alternatively, store credentials in `~/.copyhub/config.json` via:
50
+
51
+ ```bash
52
+ copyhub config --client-id "<ID>" --client-secret "<SECRET>" [--sheet-id "<SPREADSHEET_ID>"] [--redirect-port 19999]
53
+ ```
54
+
55
+ ## First run
56
+
57
+ 1. **Login** (opens the browser for OAuth, then a setup page):
58
+
59
+ ```bash
60
+ copyhub login
61
+ ```
62
+
63
+ 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.
64
+
65
+ 3. **Start** the background watcher (clipboard + Sheets + overlay by default):
66
+
67
+ ```bash
68
+ copyhub start
69
+ ```
70
+
71
+ You can close the terminal; the process keeps running. Check with `copyhub list` and stop with `copyhub stop`.
72
+
73
+ ### Useful flags and environment variables
74
+
75
+ | Action | How |
76
+ |--------|-----|
77
+ | Run in terminal (Ctrl+C stops everything) | `copyhub start --foreground` |
78
+ | No Google Sheets | `copyhub start --no-sheet` |
79
+ | No Electron overlay | `copyhub start --no-overlay` or `COPYHUB_START_NO_OVERLAY=1` |
80
+ | Override shortcut | `COPYHUB_OVERLAY_ACCELERATOR` in `.env` (overrides saved config) |
81
+ | Overlay stays open when clicking outside | `COPYHUB_OVERLAY_STICKY=1` |
82
+
83
+ Run `copyhub --help` or `copyhub commands` for the full command list.
84
+
85
+ ## CLI overview
86
+
87
+ | Command | Purpose |
88
+ |---------|---------|
89
+ | `copyhub config` | Save OAuth client ID/secret (and optional Sheet ID) to `~/.copyhub/config.json` |
90
+ | `copyhub login` | OAuth flow + setup page (Sheet ID, platform, overlay shortcut) |
91
+ | `copyhub logout` | Remove saved tokens |
92
+ | `copyhub status` | OAuth, Sheet, tokens, overlay platform/shortcut, daemon state |
93
+ | `copyhub start` | Background daemon: clipboard watcher + optional Sheets + overlay |
94
+ | `copyhub list` / `copyhub ls` | Show whether the daemon PID is running |
95
+ | `copyhub stop` | Stop daemon and overlay child |
96
+ | `copyhub overlay` | Launch only the Electron overlay (no clipboard daemon) |
97
+
98
+ ## Data locations
99
+
100
+ Everything lives under **`~/.copyhub/`** (or `%USERPROFILE%\.copyhub` on Windows):
101
+
102
+ | File | Contents |
103
+ |------|----------|
104
+ | `config.json` | OAuth credentials (if not only in `.env`), `googleSheetId`, `overlayAccelerator`, `overlayPlatform` |
105
+ | `tokens.json` | OAuth refresh/access tokens |
106
+ | `history.jsonl` | Local clipboard history (JSON Lines) |
107
+ | `run.json` | Daemon PID and metadata (when using `copyhub start` without `--foreground`) |
108
+
109
+ ## Google Sheets layout
110
+
111
+ - Rows are appended when Sheet sync is enabled and you are logged in.
112
+ - New tabs are created per **local calendar day**, named: **`COPYHUB-YYYY-MM-DD`**.
113
+
114
+ ## Overlay (Electron)
115
+
116
+ - Global shortcut defaults to **`CommandOrControl+Shift+H`** if nothing else is set (`Ctrl+Shift+H` on Windows/Linux, `⌘⇧H` on macOS-style wording in Electron).
117
+ - **macOS**: you may need to grant **Accessibility** permissions for global shortcuts.
118
+ - Some **`Control+Alt+…`** combinations do not register reliably on Windows; prefer alternatives suggested on the setup page.
119
+
120
+ ## License
121
+
122
+ MIT — see `package.json`.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "copyhub-cli",
3
+ "version": "1.0.0",
4
+ "description": "CopyHub — clipboard, local history, Google Sheets sync (OAuth). Windows, macOS, Linux.",
5
+ "type": "module",
6
+ "bin": {
7
+ "copyhub": "./src/cli.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node src/cli.js start",
11
+ "login": "node src/cli.js login",
12
+ "overlay": "node src/cli.js overlay"
13
+ },
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "clipboard",
19
+ "google-sheets",
20
+ "oauth",
21
+ "sync",
22
+ "cli",
23
+ "cross-platform",
24
+ "windows",
25
+ "linux",
26
+ "macos"
27
+ ],
28
+ "files": ["src", "ui", ".env.example"],
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "clipboardy": "^4.0.0",
32
+ "dotenv": "^16.4.5",
33
+ "electron": "^33.2.0",
34
+ "commander": "^12.1.0",
35
+ "google-auth-library": "^9.15.0",
36
+ "googleapis": "^144.0.0",
37
+ "open": "^10.1.0"
38
+ }
39
+ }
package/src/cli.js ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import { spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { program } from 'commander';
6
+ import { existsSync } from 'node:fs';
7
+ import {
8
+ loadConfig,
9
+ saveConfig,
10
+ loadSheetSyncTarget,
11
+ DEFAULT_OAUTH_REDIRECT_PORT,
12
+ describeOAuthCredentialSource,
13
+ ENV_GOOGLE_CLIENT_ID,
14
+ ENV_GOOGLE_CLIENT_SECRET,
15
+ ENV_OAUTH_REDIRECT_PORT,
16
+ loadOverlayAcceleratorFromConfigSync,
17
+ loadOverlayPlatformFromConfigSync,
18
+ } from './config.js';
19
+ import { runLoginFlow } from './oauth.js';
20
+ import { clearTokens, loadTokens } from './tokens.js';
21
+ import { spawnCopyhubOverlay } from './electron-launcher.js';
22
+ import { CONFIG_PATH, TOKENS_PATH, HISTORY_PATH, DIR } from './paths.js';
23
+ import { dailySheetTabName } from './sheet-daily.js';
24
+ import {
25
+ readRunState,
26
+ writeRunState,
27
+ clearRunState,
28
+ isPidAlive,
29
+ pruneStaleRunState,
30
+ } from './daemon-state.js';
31
+ import { killDaemonTree } from './stop-process.js';
32
+ import { ensureDir } from './storage.js';
33
+ import { runCopyhubDaemon } from './start-daemon-logic.js';
34
+
35
+ const CLI_JS = fileURLToPath(new URL('./cli.js', import.meta.url));
36
+
37
+ program.name('copyhub').description(
38
+ 'CopyHub — clipboard, overlay history, Google Sheets sync (COPYHUB-daily tabs). Windows, macOS, Linux.',
39
+ );
40
+
41
+ program
42
+ .command('config')
43
+ .description('Save Client ID / Secret (optional Sheet ID) to ~/.copyhub/config.json')
44
+ .requiredOption('--client-id <id>', 'OAuth 2.0 Client ID')
45
+ .requiredOption('--client-secret <secret>', 'OAuth 2.0 Client Secret')
46
+ .option(
47
+ '--redirect-port <port>',
48
+ `Localhost OAuth callback port (default ${DEFAULT_OAUTH_REDIRECT_PORT})`,
49
+ (v) => parseInt(v, 10),
50
+ )
51
+ .option('--sheet-id <id>', 'Google Spreadsheet ID (URL .../d/<ID>/edit); can be set later via copyhub login')
52
+ .action(async (opts) => {
53
+ const port =
54
+ typeof opts.redirectPort === 'number' && !Number.isNaN(opts.redirectPort)
55
+ ? opts.redirectPort
56
+ : DEFAULT_OAUTH_REDIRECT_PORT;
57
+ /** @type {Parameters<typeof saveConfig>[0]} */
58
+ const payload = {
59
+ clientId: opts.clientId,
60
+ clientSecret: opts.clientSecret,
61
+ redirectPort: port,
62
+ };
63
+ if (opts.sheetId) payload.googleSheetId = opts.sheetId;
64
+ await saveConfig(payload);
65
+ console.log(`Saved configuration: ${CONFIG_PATH}`);
66
+ console.log(
67
+ `In Google Cloud Console, add redirect URI: http://127.0.0.1:${port}/oauth2callback`,
68
+ );
69
+ console.log('Enable Google Sheets API for the same OAuth project.');
70
+ });
71
+
72
+ program
73
+ .command('login')
74
+ .description(
75
+ `Google sign-in (OAuth Sheets), then Spreadsheet ID setup page — port ${DEFAULT_OAUTH_REDIRECT_PORT} or ${ENV_OAUTH_REDIRECT_PORT}`,
76
+ )
77
+ .action(async () => {
78
+ await runLoginFlow();
79
+ });
80
+
81
+ program
82
+ .command('logout')
83
+ .description('Remove saved tokens')
84
+ .action(async () => {
85
+ await clearTokens();
86
+ console.log(`Removed tokens: ${TOKENS_PATH}`);
87
+ });
88
+
89
+ program
90
+ .command('overlay')
91
+ .description(
92
+ 'Run only the Electron overlay window (without copyhub start). macOS may require Accessibility permissions.',
93
+ )
94
+ .action(() => {
95
+ try {
96
+ const child = spawnCopyhubOverlay();
97
+ child.on('error', (err) => {
98
+ console.error(err.message);
99
+ process.exit(1);
100
+ });
101
+ } catch (e) {
102
+ console.error(/** @type {Error} */ (e).message);
103
+ process.exitCode = 1;
104
+ }
105
+ });
106
+
107
+ program
108
+ .command('list')
109
+ .alias('ls')
110
+ .description('Show whether the CopyHub background process (copyhub start) is running')
111
+ .action(() => {
112
+ pruneStaleRunState();
113
+ const s = readRunState();
114
+ if (!s) {
115
+ console.log('No CopyHub background process (no ~/.copyhub/run.json or already cleared).');
116
+ return;
117
+ }
118
+ if (!isPidAlive(s.pid)) {
119
+ console.log(`PID ${s.pid} is not running — removed run.json.`);
120
+ clearRunState();
121
+ return;
122
+ }
123
+ console.log('CopyHub background process is running:');
124
+ console.log(` PID: ${s.pid}`);
125
+ console.log(` Started: ${s.startedAt || '(unknown)'}`);
126
+ console.log(` Stop with: copyhub stop`);
127
+ });
128
+
129
+ program
130
+ .command('stop')
131
+ .description('Stop the background process started by copyhub start (and overlay child)')
132
+ .action(() => {
133
+ pruneStaleRunState();
134
+ const s = readRunState();
135
+ if (!s) {
136
+ console.log('No background process to stop.');
137
+ return;
138
+ }
139
+ if (!isPidAlive(s.pid)) {
140
+ console.log(`PID ${s.pid} is not running — cleared run.json.`);
141
+ clearRunState();
142
+ return;
143
+ }
144
+ killDaemonTree(s.pid);
145
+ clearRunState();
146
+ console.log(`Stopped process PID ${s.pid}.`);
147
+ });
148
+
149
+ program
150
+ .command('status')
151
+ .description('Check OAuth, Sheet, and tokens')
152
+ .action(async () => {
153
+ pruneStaleRunState();
154
+ const cfg = await loadConfig();
155
+ const sheet = await loadSheetSyncTarget();
156
+ const tok = await loadTokens();
157
+ const src = describeOAuthCredentialSource();
158
+
159
+ if (!cfg) {
160
+ console.log('OAuth config: missing');
161
+ console.log(
162
+ ` Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env (see .env.example), or run: copyhub config`,
163
+ );
164
+ } else {
165
+ const srcLabel =
166
+ src === 'env'
167
+ ? 'environment / .env'
168
+ : src === 'mixed'
169
+ ? 'mixed .env + config file'
170
+ : CONFIG_PATH;
171
+ console.log('OAuth config: ok');
172
+ console.log(` Client ID/Secret source: ${srcLabel}`);
173
+ console.log(` Callback: http://127.0.0.1:${cfg.redirectPort}/oauth2callback`);
174
+ }
175
+
176
+ if (!sheet) {
177
+ console.log(
178
+ 'Google Sheet: not set — run copyhub login (setup page) or copyhub config ... --sheet-id <ID>',
179
+ );
180
+ } else {
181
+ const todayTab = dailySheetTabName();
182
+ console.log(
183
+ `Google Sheet: ok — ID …${sheet.spreadsheetId.slice(-8)} · today's tab: "${todayTab}"`,
184
+ );
185
+ }
186
+
187
+ console.log(
188
+ 'Token:',
189
+ tok?.refresh_token || tok?.access_token ? `present (${TOKENS_PATH})` : 'missing (run copyhub login)',
190
+ );
191
+ if (existsSync(HISTORY_PATH)) {
192
+ console.log('History:', HISTORY_PATH);
193
+ }
194
+
195
+ const plat = loadOverlayPlatformFromConfigSync();
196
+ const platLabel =
197
+ plat === 'mac' ? 'macOS' : plat === 'linux' ? 'Linux' : plat === 'win' ? 'Windows' : '(not set)';
198
+ console.log(`Overlay platform setting: ${platLabel}`);
199
+
200
+ const envAccel = process.env.COPYHUB_OVERLAY_ACCELERATOR?.trim();
201
+ const cfgAccel = loadOverlayAcceleratorFromConfigSync();
202
+ if (envAccel) {
203
+ console.log(`Overlay shortcut (.env): ${envAccel}`);
204
+ } else if (cfgAccel) {
205
+ console.log(`Overlay shortcut (config): ${cfgAccel}`);
206
+ } else {
207
+ console.log(
208
+ 'Overlay shortcut: (default Ctrl+Shift+H — set after copyhub login or COPYHUB_OVERLAY_ACCELERATOR)',
209
+ );
210
+ }
211
+
212
+ const run = readRunState();
213
+ if (run && isPidAlive(run.pid)) {
214
+ console.log(`Background process: yes (PID ${run.pid}) — copyhub list`);
215
+ } else if (run) {
216
+ console.log('Background process: run.json exists but PID is dead — run copyhub stop to clean up.');
217
+ } else {
218
+ console.log('Background process: no — copyhub start to run in background.');
219
+ }
220
+ });
221
+
222
+ program
223
+ .command('start')
224
+ .description(
225
+ 'Run clipboard + Sheet + overlay in background (terminal can close). Blocks if PID already running. Use --foreground to attach to terminal.',
226
+ )
227
+ .option('--no-sheet', 'Local history only, do not write to Sheets')
228
+ .option('--no-overlay', 'Do not launch Electron')
229
+ .option('--foreground', 'Run in foreground (Ctrl+C stops; no background PID file)')
230
+ .action(async (opts) => {
231
+ pruneStaleRunState();
232
+
233
+ const useSheet = opts.sheet !== false;
234
+ const skipOverlay =
235
+ opts.overlay === false || process.env.COPYHUB_START_NO_OVERLAY === '1';
236
+
237
+ const existing = readRunState();
238
+ if (existing && isPidAlive(existing.pid)) {
239
+ console.error(
240
+ `CopyHub already running in background (PID ${existing.pid}). See: copyhub list — Stop: copyhub stop`,
241
+ );
242
+ process.exit(1);
243
+ }
244
+ if (existing && !isPidAlive(existing.pid)) {
245
+ clearRunState();
246
+ }
247
+
248
+ if (opts.foreground) {
249
+ console.log('CopyHub foreground mode. Press Ctrl+C to stop.');
250
+ await ensureDir();
251
+
252
+ const ctrl = await runCopyhubDaemon({ useSheet, skipOverlay });
253
+
254
+ const onStop = () => {
255
+ ctrl.stopSync();
256
+ process.exit(0);
257
+ };
258
+ process.on('SIGINT', onStop);
259
+ process.on('SIGTERM', onStop);
260
+ return;
261
+ }
262
+
263
+ await ensureDir();
264
+ const daemonArgs = [CLI_JS, '_daemon'];
265
+ if (!useSheet) daemonArgs.push('--no-sheet');
266
+ if (skipOverlay) daemonArgs.push('--no-overlay');
267
+
268
+ const child = spawn(process.execPath, daemonArgs, {
269
+ detached: true,
270
+ stdio: 'ignore',
271
+ env: { ...process.env },
272
+ });
273
+ child.unref();
274
+
275
+ if (!child.pid) {
276
+ console.error('Could not spawn background process.');
277
+ process.exit(1);
278
+ }
279
+
280
+ writeRunState({
281
+ pid: child.pid,
282
+ startedAt: new Date().toISOString(),
283
+ foreground: false,
284
+ });
285
+
286
+ console.log(`CopyHub running in background (PID ${child.pid}). You may close this terminal.`);
287
+ console.log('Check: copyhub list | Stop: copyhub stop');
288
+ process.exit(0);
289
+ });
290
+
291
+ program
292
+ .command('_daemon', { hidden: true })
293
+ .option('--no-sheet', 'internal')
294
+ .option('--no-overlay', 'internal')
295
+ .action(async (opts) => {
296
+ const useSheet = opts.sheet !== false;
297
+ const skipOverlay =
298
+ opts.overlay === false || process.env.COPYHUB_START_NO_OVERLAY === '1';
299
+
300
+ function clearMyRunState() {
301
+ try {
302
+ const cur = readRunState();
303
+ if (cur && cur.pid === process.pid) {
304
+ clearRunState();
305
+ }
306
+ } catch {
307
+ /* ignore */
308
+ }
309
+ }
310
+
311
+ const ctrl = await runCopyhubDaemon({ useSheet, skipOverlay });
312
+
313
+ const shutdown = () => {
314
+ ctrl.stopSync();
315
+ clearMyRunState();
316
+ process.exit(0);
317
+ };
318
+
319
+ process.on('SIGINT', shutdown);
320
+ process.on('SIGTERM', shutdown);
321
+ process.on('exit', clearMyRunState);
322
+ });
323
+
324
+ program
325
+ .command('commands')
326
+ .alias('cmds')
327
+ .description('List CLI commands')
328
+ .action(() => {
329
+ console.log(`copyhub config [--client-id ID] [--client-secret SEC] [--redirect-port P] [--sheet-id ID]
330
+ copyhub login | copyhub logout | copyhub status
331
+ copyhub start [--no-sheet] [--no-overlay] [--foreground]
332
+ Default runs in background (terminal can close). Single instance — second start is blocked.
333
+ copyhub list (ls) | copyhub stop
334
+ copyhub overlay | copyhub commands / copyhub --help`);
335
+ });
336
+
337
+ program.parse();
@@ -0,0 +1,45 @@
1
+ import clipboardy from 'clipboardy';
2
+ import { createHash } from 'node:crypto';
3
+ import { clipboardPollIntervalMs } from './platform.js';
4
+
5
+ function hashText(s) {
6
+ return createHash('sha256').update(s, 'utf8').digest('hex');
7
+ }
8
+
9
+ /**
10
+ * @param {(text: string) => void | Promise<void>} onNewCopy
11
+ * @param {{ pollMs?: number }} [options]
12
+ * @returns {{ stop: () => void }}
13
+ */
14
+ export function startClipboardWatcher(onNewCopy, options = {}) {
15
+ const pollMs = typeof options.pollMs === 'number' ? options.pollMs : clipboardPollIntervalMs();
16
+ let lastHash = '';
17
+ let stopped = false;
18
+
19
+ const tick = async () => {
20
+ if (stopped) return;
21
+ try {
22
+ const text = await clipboardy.read();
23
+ if (typeof text !== 'string' || text.length === 0) return;
24
+ const h = hashText(text);
25
+ if (h === lastHash) return;
26
+ lastHash = h;
27
+ await onNewCopy(text);
28
+ } catch {
29
+ // Ignore transient clipboard read errors
30
+ }
31
+ };
32
+
33
+ const id = setInterval(() => {
34
+ void tick();
35
+ }, pollMs);
36
+
37
+ void tick();
38
+
39
+ return {
40
+ stop() {
41
+ stopped = true;
42
+ clearInterval(id);
43
+ },
44
+ };
45
+ }