copyhub-cli 1.0.3 → 1.0.5
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 +2 -1
- package/README.md +10 -1
- package/package.json +1 -1
- package/src/cli.js +5 -11
- package/src/config.js +62 -19
- package/src/load-env.js +39 -0
- package/src/oauth.js +59 -2
- package/ui/main.mjs +3 -1
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).
|
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ copyhub start
|
|
|
120
120
|
|
|
121
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`.
|
|
122
122
|
|
|
123
|
-
- Copy `.env.example` to `.env` and set `COPYHUB_GOOGLE_CLIENT_ID`, `COPYHUB_GOOGLE_CLIENT_SECRET`, and optionally `COPYHUB_OAUTH_REDIRECT_PORT` (default **19999**).
|
|
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
124
|
|
|
125
125
|
- Or run **`copyhub config`**:
|
|
126
126
|
|
|
@@ -214,6 +214,15 @@ Everything lives under **`~/.copyhub/`** (or `%USERPROFILE%\.copyhub` on Windows
|
|
|
214
214
|
- **macOS**: you may need to grant **Accessibility** permissions for global shortcuts.
|
|
215
215
|
- Some **`Control+Alt+…`** combinations do not register reliably on Windows; prefer alternatives suggested on the setup page.
|
|
216
216
|
|
|
217
|
+
## Troubleshooting: `invalid_client` after Google sign-in
|
|
218
|
+
|
|
219
|
+
That response comes from Google’s token endpoint when the **Client ID + Client Secret pair** does not match your OAuth client.
|
|
220
|
+
|
|
221
|
+
1. **Use a “Web application” OAuth client** (not iOS/Android/Desktop-only flows meant for different redirect rules). Add **Authorized redirect URI**: `http://127.0.0.1:19999/oauth2callback` (change the port if you use `COPYHUB_OAUTH_REDIRECT_PORT` or saved `redirectPort`).
|
|
222
|
+
2. **Where credentials come from**: If **`~/.copyhub/config.json` contains both Client ID and Secret** (wizard or `copyhub config`), CopyHub uses **that pair** for OAuth — env vars are **ignored** for those two fields until you remove them from config. If config does **not** have both, CopyHub uses **`COPYHUB_GOOGLE_CLIENT_ID` / `COPYHUB_GOOGLE_CLIENT_SECRET`** from the environment / `.env`. CopyHub never mixes env Client ID with file Secret (that mismatch triggers `invalid_client`).
|
|
223
|
+
3. **Paste cleanly**: Re-open the localhost credential wizard or edit `config.json` so ID and secret have **no extra spaces or line breaks**.
|
|
224
|
+
4. Run **`copyhub status`** and confirm **Client ID/Secret source** matches what you expect.
|
|
225
|
+
|
|
217
226
|
## License
|
|
218
227
|
|
|
219
228
|
MIT — see `package.json`.
|
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';
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
saveConfig,
|
|
10
10
|
loadSheetSyncTarget,
|
|
11
11
|
DEFAULT_OAUTH_REDIRECT_PORT,
|
|
12
|
-
|
|
12
|
+
describeEffectiveOAuthCredentialSource,
|
|
13
13
|
ENV_GOOGLE_CLIENT_ID,
|
|
14
14
|
ENV_GOOGLE_CLIENT_SECRET,
|
|
15
15
|
ENV_OAUTH_REDIRECT_PORT,
|
|
@@ -33,6 +33,8 @@ import { ensureDir } from './storage.js';
|
|
|
33
33
|
import { runCopyhubDaemon } from './start-daemon-logic.js';
|
|
34
34
|
import { wipeCopyhubDirectory } from './wipe-data.js';
|
|
35
35
|
|
|
36
|
+
loadCopyhubEnv();
|
|
37
|
+
|
|
36
38
|
const CLI_JS = fileURLToPath(new URL('./cli.js', import.meta.url));
|
|
37
39
|
|
|
38
40
|
program.name('copyhub').description(
|
|
@@ -188,22 +190,14 @@ program
|
|
|
188
190
|
const cfg = await loadConfig();
|
|
189
191
|
const sheet = await loadSheetSyncTarget();
|
|
190
192
|
const tok = await loadTokens();
|
|
191
|
-
const src = describeOAuthCredentialSource();
|
|
192
|
-
|
|
193
193
|
if (!cfg) {
|
|
194
194
|
console.log('OAuth config: missing');
|
|
195
195
|
console.log(
|
|
196
196
|
` Set ${ENV_GOOGLE_CLIENT_ID} and ${ENV_GOOGLE_CLIENT_SECRET} in .env (see .env.example), run copyhub config, or copyhub login (browser wizard)`,
|
|
197
197
|
);
|
|
198
198
|
} else {
|
|
199
|
-
const srcLabel =
|
|
200
|
-
src === 'env'
|
|
201
|
-
? 'environment / .env'
|
|
202
|
-
: src === 'mixed'
|
|
203
|
-
? 'mixed .env + config file'
|
|
204
|
-
: CONFIG_PATH;
|
|
205
199
|
console.log('OAuth config: ok');
|
|
206
|
-
console.log(` Client ID/Secret source: ${
|
|
200
|
+
console.log(` Client ID/Secret source: ${describeEffectiveOAuthCredentialSource()}`);
|
|
207
201
|
console.log(` Callback: http://127.0.0.1:${cfg.redirectPort}/oauth2callback`);
|
|
208
202
|
}
|
|
209
203
|
|
package/src/config.js
CHANGED
|
@@ -49,16 +49,37 @@ export function hasOAuthCredentialsInEnv() {
|
|
|
49
49
|
return Boolean(id && sec);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Matches {@link loadConfig}: saved config.json pair wins over env when both are complete.
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function describeEffectiveOAuthCredentialSource() {
|
|
57
|
+
let filePair = false;
|
|
58
|
+
if (existsSync(CONFIG_PATH)) {
|
|
59
|
+
try {
|
|
60
|
+
const j = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
61
|
+
const id = typeof j.clientId === 'string' ? j.clientId.trim() : '';
|
|
62
|
+
const sec = typeof j.clientSecret === 'string' ? j.clientSecret.trim() : '';
|
|
63
|
+
filePair = Boolean(id && sec);
|
|
64
|
+
} catch {
|
|
65
|
+
/* ignore */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const envPair = hasOAuthCredentialsInEnv();
|
|
69
|
+
|
|
70
|
+
if (filePair) {
|
|
71
|
+
return envPair
|
|
72
|
+
? `${CONFIG_PATH} (env COPYHUB_GOOGLE_* ignored for Client ID/Secret)`
|
|
73
|
+
: CONFIG_PATH;
|
|
74
|
+
}
|
|
75
|
+
if (envPair) return 'environment / .env (COPYHUB_GOOGLE_CLIENT_ID + SECRET)';
|
|
76
|
+
return '(none)';
|
|
59
77
|
}
|
|
60
78
|
|
|
61
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* OAuth credentials: use env pair OR file pair only — never mix ID from one source with secret from another (Google returns invalid_client).
|
|
81
|
+
* @returns {Promise<CopyHubConfig | null>}
|
|
82
|
+
*/
|
|
62
83
|
export async function loadConfig() {
|
|
63
84
|
/** @type {{ clientId?: string, clientSecret?: string, redirectPort?: number }} */
|
|
64
85
|
const fromFile = {};
|
|
@@ -66,29 +87,46 @@ export async function loadConfig() {
|
|
|
66
87
|
if (existsSync(CONFIG_PATH)) {
|
|
67
88
|
const raw = await readFile(CONFIG_PATH, 'utf8');
|
|
68
89
|
const j = JSON.parse(raw);
|
|
69
|
-
if (typeof j.clientId === 'string'
|
|
70
|
-
|
|
71
|
-
fromFile.
|
|
90
|
+
if (typeof j.clientId === 'string') {
|
|
91
|
+
const id = j.clientId.trim();
|
|
92
|
+
if (id) fromFile.clientId = id;
|
|
93
|
+
}
|
|
94
|
+
if (typeof j.clientSecret === 'string') {
|
|
95
|
+
const sec = j.clientSecret.trim();
|
|
96
|
+
if (sec) fromFile.clientSecret = sec;
|
|
72
97
|
}
|
|
73
98
|
if (typeof j.redirectPort === 'number' && Number.isFinite(j.redirectPort)) {
|
|
74
99
|
fromFile.redirectPort = j.redirectPort;
|
|
75
100
|
}
|
|
76
101
|
}
|
|
77
102
|
|
|
78
|
-
const envId = process.env[ENV_GOOGLE_CLIENT_ID]?.trim();
|
|
79
|
-
const envSecret = process.env[ENV_GOOGLE_CLIENT_SECRET]?.trim();
|
|
103
|
+
const envId = process.env[ENV_GOOGLE_CLIENT_ID]?.trim() ?? '';
|
|
104
|
+
const envSecret = process.env[ENV_GOOGLE_CLIENT_SECRET]?.trim() ?? '';
|
|
80
105
|
const envPort = parseRedirectPortFromEnv();
|
|
81
106
|
|
|
82
|
-
const clientId = envId || fromFile.clientId;
|
|
83
|
-
const clientSecret = envSecret || fromFile.clientSecret;
|
|
84
|
-
|
|
85
107
|
const filePort =
|
|
86
108
|
typeof fromFile.redirectPort === 'number'
|
|
87
109
|
? fromFile.redirectPort
|
|
88
110
|
: DEFAULT_OAUTH_REDIRECT_PORT;
|
|
89
111
|
const redirectPort = envPort ?? filePort;
|
|
90
112
|
|
|
91
|
-
|
|
113
|
+
let clientId;
|
|
114
|
+
let clientSecret;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Prefer ~/.copyhub/config.json when it holds a full OAuth pair (wizard / copyhub config).
|
|
118
|
+
* Otherwise many machines still have COPYHUB_GOOGLE_* in shell or ~/.copyhub/.env from a template —
|
|
119
|
+
* those used to override fresh wizard credentials and caused invalid_client on the callback.
|
|
120
|
+
*/
|
|
121
|
+
if (fromFile.clientId && fromFile.clientSecret) {
|
|
122
|
+
clientId = fromFile.clientId;
|
|
123
|
+
clientSecret = fromFile.clientSecret;
|
|
124
|
+
} else if (envId && envSecret) {
|
|
125
|
+
clientId = envId;
|
|
126
|
+
clientSecret = envSecret;
|
|
127
|
+
} else {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
92
130
|
|
|
93
131
|
return { clientId, clientSecret, redirectPort };
|
|
94
132
|
}
|
|
@@ -170,6 +208,11 @@ export async function mergeConfigPartial(partial) {
|
|
|
170
208
|
const out = { ...existing, ...partial };
|
|
171
209
|
delete out.sheetTab;
|
|
172
210
|
delete out.sheetDailyPrefix;
|
|
211
|
+
for (const key of ['clientId', 'clientSecret', 'googleSheetId']) {
|
|
212
|
+
if (typeof out[key] === 'string') {
|
|
213
|
+
out[key] = /** @type {string} */ (out[key]).trim();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
173
216
|
await writeFile(CONFIG_PATH, JSON.stringify(out, null, 2), 'utf8');
|
|
174
217
|
}
|
|
175
218
|
|
|
@@ -189,8 +232,8 @@ export async function saveConfig(cfg) {
|
|
|
189
232
|
}
|
|
190
233
|
const out = {
|
|
191
234
|
...existing,
|
|
192
|
-
clientId: cfg.clientId,
|
|
193
|
-
clientSecret: cfg.clientSecret,
|
|
235
|
+
clientId: cfg.clientId.trim(),
|
|
236
|
+
clientSecret: cfg.clientSecret.trim(),
|
|
194
237
|
redirectPort: cfg.redirectPort ?? DEFAULT_OAUTH_REDIRECT_PORT,
|
|
195
238
|
};
|
|
196
239
|
if (cfg.googleSheetId !== undefined) out.googleSheetId = cfg.googleSheetId;
|
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
|
@@ -77,6 +77,52 @@ function escapeHtml(s) {
|
|
|
77
77
|
.replace(/'/g, ''');
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* User-visible explanation when exchanging auth code for tokens fails (e.g. invalid_client).
|
|
82
|
+
* @param {unknown} err
|
|
83
|
+
*/
|
|
84
|
+
function formatOAuthTokenExchangeMessage(err) {
|
|
85
|
+
const g = /** @type {{ response?: { data?: { error?: string; error_description?: string } } } } */ (
|
|
86
|
+
err
|
|
87
|
+
);
|
|
88
|
+
const code = g.response?.data?.error;
|
|
89
|
+
const desc = (g.response?.data?.error_description || '').trim();
|
|
90
|
+
const fallback = /** @type {Error} */ (err)?.message || String(err);
|
|
91
|
+
|
|
92
|
+
if (code === 'invalid_client') {
|
|
93
|
+
return (
|
|
94
|
+
'OAuth invalid_client: Google rejected the Client ID / Client Secret pair. ' +
|
|
95
|
+
'In Google Cloud Console use OAuth client type "Web application" and add redirect URI http://127.0.0.1:<port>/oauth2callback (port matches CopyHub). Paste fresh credentials into the wizard — no spaces before/after. ' +
|
|
96
|
+
'If COPYHUB_GOOGLE_CLIENT_ID / COPYHUB_GOOGLE_CLIENT_SECRET are set in your shell or ~/.copyhub/.env, set BOTH to match that client or unset both so credentials from ~/.copyhub/config.json are used (CopyHub never mixes env ID with file secret). ' +
|
|
97
|
+
(desc ? `Google says: ${desc}` : `(${fallback})`)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (code === 'invalid_grant') {
|
|
102
|
+
return (
|
|
103
|
+
'OAuth invalid_grant: the authorization code expired or was already used. Close extra browser tabs and run copyhub login again.' +
|
|
104
|
+
(desc ? ` ${desc}` : '')
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return desc ? `${code || 'OAuth'}: ${desc}` : fallback;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @param {string} bodyText */
|
|
112
|
+
function oauthTokenExchangeErrorPage(bodyText) {
|
|
113
|
+
const esc = escapeHtml(bodyText);
|
|
114
|
+
return `<!DOCTYPE html>
|
|
115
|
+
<html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>CopyHub OAuth error</title>
|
|
116
|
+
<style>
|
|
117
|
+
body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f0f4fa;padding:24px;}
|
|
118
|
+
.box{background:#fff;padding:28px 32px;border-radius:14px;max-width:520px;box-shadow:0 4px 24px rgba(20,24,36,.08);line-height:1.55;color:#141824;}
|
|
119
|
+
h1{font-size:1.15rem;margin:0 0 12px;}
|
|
120
|
+
pre{white-space:pre-wrap;word-break:break-word;background:#f8fafc;padding:12px 14px;border-radius:8px;font-size:13px;color:#334155;border:1px solid #e2e8f0;}
|
|
121
|
+
code{background:#eff6ff;padding:2px 8px;border-radius:6px;font-size:13px;}
|
|
122
|
+
</style></head><body><div class="box"><h1>Could not finish sign-in</h1><pre>${esc}</pre>
|
|
123
|
+
<p style="margin-top:16px;color:#64748b;font-size:14px;">Fix the issue, then run <code>copyhub login</code> again.</p></div></body></html>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
80
126
|
/**
|
|
81
127
|
* @param {string} bootstrapToken
|
|
82
128
|
* @param {number} listenPort
|
|
@@ -181,7 +227,7 @@ function credentialSetupPageHtml(bootstrapToken, listenPort) {
|
|
|
181
227
|
<div class="wrap">
|
|
182
228
|
<div class="brand">CopyHub</div>
|
|
183
229
|
<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>
|
|
230
|
+
<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>. After saving here, CopyHub uses this pair for sign-in — not leftover variables from shell or <code>.env</code>.</p>
|
|
185
231
|
|
|
186
232
|
<div class="card">
|
|
187
233
|
<p class="hint" style="margin-top:0;"><strong>Authorized redirect URI</strong> in Google Cloud Console must include:</p>
|
|
@@ -741,7 +787,18 @@ export async function runLoginFlow() {
|
|
|
741
787
|
server.close(() => reject(new Error('Missing authorization code')));
|
|
742
788
|
return;
|
|
743
789
|
}
|
|
744
|
-
|
|
790
|
+
let tokens;
|
|
791
|
+
try {
|
|
792
|
+
const exchanged = await oauth2Client.getToken(code);
|
|
793
|
+
tokens = exchanged.tokens;
|
|
794
|
+
} catch (tokenErr) {
|
|
795
|
+
const msg = formatOAuthTokenExchangeMessage(tokenErr);
|
|
796
|
+
console.error('[CopyHub OAuth]', msg);
|
|
797
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
798
|
+
res.end(oauthTokenExchangeErrorPage(msg));
|
|
799
|
+
server.close(() => reject(new Error(msg)));
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
745
802
|
oauth2Client.setCredentials(tokens);
|
|
746
803
|
await saveTokens(tokens);
|
|
747
804
|
setupToken = randomBytes(24).toString('hex');
|
package/ui/main.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import 'dotenv/config';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
import { fileURLToPath } from 'node:url';
|
|
4
3
|
import {
|
|
@@ -12,6 +11,7 @@ import {
|
|
|
12
11
|
Menu,
|
|
13
12
|
nativeImage,
|
|
14
13
|
} from 'electron';
|
|
14
|
+
import { loadCopyhubEnv } from '../src/load-env.js';
|
|
15
15
|
import { readRecentHistorySync } from '../src/storage.js';
|
|
16
16
|
import {
|
|
17
17
|
loadOverlayAcceleratorFromConfigSync,
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
import { loadTokens } from '../src/tokens.js';
|
|
21
21
|
import { fetchOverlayDailyTabRows } from '../src/sheet-overlay-history.js';
|
|
22
22
|
|
|
23
|
+
loadCopyhubEnv();
|
|
24
|
+
|
|
23
25
|
const gotLock = app.requestSingleInstanceLock();
|
|
24
26
|
if (!gotLock) {
|
|
25
27
|
app.quit();
|