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 +3 -1
- package/README.md +73 -8
- package/package.json +1 -1
- package/src/cli.js +36 -3
- package/src/config.js +20 -0
- package/src/load-env.js +39 -0
- package/src/oauth.js +270 -3
- package/src/sheet-overlay-history.js +79 -0
- package/src/sheets.js +1 -1
- package/src/start-daemon-logic.js +4 -1
- package/src/storage.js +11 -0
- package/src/wipe-data.js +10 -0
- package/ui/main.mjs +272 -20
- package/ui/preload.cjs +4 -1
- package/ui/renderer/index.html +256 -7
package/.env.example
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
# Copy to
|
|
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.
|
|
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
|
-
|
|
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**
|
|
133
|
+
1. **Login** — opens the browser:
|
|
72
134
|
|
|
73
135
|
```bash
|
|
74
136
|
copyhub login
|
|
75
137
|
```
|
|
76
138
|
|
|
77
|
-
|
|
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
|
|
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
package/src/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import '
|
|
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
|
-
`
|
|
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),
|
|
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();
|
package/src/load-env.js
ADDED
|
@@ -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),
|
|
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, ''');
|
|
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
|
-
|
|
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
|
-
`
|
|
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;
|