copyhub-cli 1.0.5 → 1.0.6

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/README.md CHANGED
@@ -223,6 +223,8 @@ That response comes from Google’s token endpoint when the **Client ID + Client
223
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
224
  4. Run **`copyhub status`** and confirm **Client ID/Secret source** matches what you expect.
225
225
 
226
+ If Google says **“The provided client secret is invalid”**, the secret does not match that Client ID (typo, truncated paste, or an old secret after you clicked **Reset secret** in Console). Download the client JSON again from Google Cloud, paste `client_id` and `client_secret` into the CopyHub wizard, and on **Mac/Safari** clear both fields before pasting so **iCloud Keychain** does not inject a saved password into the secret field.
227
+
226
228
  ## License
227
229
 
228
230
  MIT — see `package.json`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copyhub-cli",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "CopyHub — clipboard, local history, Google Sheets sync (OAuth). Windows, macOS, Linux.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -22,6 +22,34 @@ function parseRedirectPortFromEnv() {
22
22
  return n;
23
23
  }
24
24
 
25
+ /**
26
+ * Strip invisible / stray characters from pasted OAuth credentials (fixes many Mac copy-paste issues).
27
+ * @param {unknown} raw
28
+ * @returns {string}
29
+ */
30
+ export function sanitizeOAuthCredentialInput(raw) {
31
+ if (raw == null) return '';
32
+ let s = String(raw);
33
+ s = s.replace(/^\uFEFF/, '');
34
+ s = s.replace(/[\u200b-\u200d\u2060]/g, '');
35
+ s = s.replace(/\r/g, '');
36
+ s = s.replace(/[\u00a0\u202f\u2007]/g, ' ');
37
+ s = s.trim();
38
+ if (
39
+ (s.startsWith('"') && s.endsWith('"')) ||
40
+ (s.startsWith("'") && s.endsWith("'"))
41
+ ) {
42
+ s = s.slice(1, -1).trim();
43
+ }
44
+ if (
45
+ (s.startsWith('\u201c') && s.endsWith('\u201d')) ||
46
+ (s.startsWith('\u2018') && s.endsWith('\u2019'))
47
+ ) {
48
+ s = s.slice(1, -1).trim();
49
+ }
50
+ return s;
51
+ }
52
+
25
53
  /**
26
54
  * Port for the OAuth HTTP listener (env wins, then saved config, then default).
27
55
  * Does not require Client ID / Secret (used before credential bootstrap).
@@ -44,8 +72,8 @@ export function resolveOAuthListenPort() {
44
72
 
45
73
  /** Both Client ID and Secret come from environment (or .env). */
46
74
  export function hasOAuthCredentialsInEnv() {
47
- const id = process.env[ENV_GOOGLE_CLIENT_ID]?.trim();
48
- const sec = process.env[ENV_GOOGLE_CLIENT_SECRET]?.trim();
75
+ const id = sanitizeOAuthCredentialInput(process.env[ENV_GOOGLE_CLIENT_ID]);
76
+ const sec = sanitizeOAuthCredentialInput(process.env[ENV_GOOGLE_CLIENT_SECRET]);
49
77
  return Boolean(id && sec);
50
78
  }
51
79
 
@@ -58,8 +86,10 @@ export function describeEffectiveOAuthCredentialSource() {
58
86
  if (existsSync(CONFIG_PATH)) {
59
87
  try {
60
88
  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() : '';
89
+ const id =
90
+ typeof j.clientId === 'string' ? sanitizeOAuthCredentialInput(j.clientId) : '';
91
+ const sec =
92
+ typeof j.clientSecret === 'string' ? sanitizeOAuthCredentialInput(j.clientSecret) : '';
63
93
  filePair = Boolean(id && sec);
64
94
  } catch {
65
95
  /* ignore */
@@ -88,11 +118,11 @@ export async function loadConfig() {
88
118
  const raw = await readFile(CONFIG_PATH, 'utf8');
89
119
  const j = JSON.parse(raw);
90
120
  if (typeof j.clientId === 'string') {
91
- const id = j.clientId.trim();
121
+ const id = sanitizeOAuthCredentialInput(j.clientId);
92
122
  if (id) fromFile.clientId = id;
93
123
  }
94
124
  if (typeof j.clientSecret === 'string') {
95
- const sec = j.clientSecret.trim();
125
+ const sec = sanitizeOAuthCredentialInput(j.clientSecret);
96
126
  if (sec) fromFile.clientSecret = sec;
97
127
  }
98
128
  if (typeof j.redirectPort === 'number' && Number.isFinite(j.redirectPort)) {
@@ -100,8 +130,8 @@ export async function loadConfig() {
100
130
  }
101
131
  }
102
132
 
103
- const envId = process.env[ENV_GOOGLE_CLIENT_ID]?.trim() ?? '';
104
- const envSecret = process.env[ENV_GOOGLE_CLIENT_SECRET]?.trim() ?? '';
133
+ const envId = sanitizeOAuthCredentialInput(process.env[ENV_GOOGLE_CLIENT_ID]);
134
+ const envSecret = sanitizeOAuthCredentialInput(process.env[ENV_GOOGLE_CLIENT_SECRET]);
105
135
  const envPort = parseRedirectPortFromEnv();
106
136
 
107
137
  const filePort =
@@ -208,10 +238,14 @@ export async function mergeConfigPartial(partial) {
208
238
  const out = { ...existing, ...partial };
209
239
  delete out.sheetTab;
210
240
  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
- }
241
+ if (typeof out.clientId === 'string') {
242
+ out.clientId = sanitizeOAuthCredentialInput(out.clientId);
243
+ }
244
+ if (typeof out.clientSecret === 'string') {
245
+ out.clientSecret = sanitizeOAuthCredentialInput(out.clientSecret);
246
+ }
247
+ if (typeof out.googleSheetId === 'string') {
248
+ out.googleSheetId = sanitizeOAuthCredentialInput(out.googleSheetId);
215
249
  }
216
250
  await writeFile(CONFIG_PATH, JSON.stringify(out, null, 2), 'utf8');
217
251
  }
@@ -232,11 +266,16 @@ export async function saveConfig(cfg) {
232
266
  }
233
267
  const out = {
234
268
  ...existing,
235
- clientId: cfg.clientId.trim(),
236
- clientSecret: cfg.clientSecret.trim(),
269
+ clientId: sanitizeOAuthCredentialInput(cfg.clientId),
270
+ clientSecret: sanitizeOAuthCredentialInput(cfg.clientSecret),
237
271
  redirectPort: cfg.redirectPort ?? DEFAULT_OAUTH_REDIRECT_PORT,
238
272
  };
239
- if (cfg.googleSheetId !== undefined) out.googleSheetId = cfg.googleSheetId;
273
+ if (cfg.googleSheetId !== undefined) {
274
+ out.googleSheetId =
275
+ typeof cfg.googleSheetId === 'string'
276
+ ? sanitizeOAuthCredentialInput(cfg.googleSheetId)
277
+ : cfg.googleSheetId;
278
+ }
240
279
  delete out.sheetTab;
241
280
  delete out.sheetDailyPrefix;
242
281
  await writeFile(CONFIG_PATH, JSON.stringify(out, null, 2), 'utf8');
package/src/oauth.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  loadSheetSyncTarget,
15
15
  loadOverlayAcceleratorFromConfigSync,
16
16
  loadOverlayPlatformFromConfigSync,
17
+ sanitizeOAuthCredentialInput,
17
18
  } from './config.js';
18
19
  import { saveTokens, loadTokens } from './tokens.js';
19
20
  import { TOKENS_PATH } from './paths.js';
@@ -90,10 +91,15 @@ function formatOAuthTokenExchangeMessage(err) {
90
91
  const fallback = /** @type {Error} */ (err)?.message || String(err);
91
92
 
92
93
  if (code === 'invalid_client') {
94
+ const secretInvalid = /client secret is invalid|invalid_client_secret/i.test(desc);
95
+ const secretHint = secretInvalid
96
+ ? 'Google says the Client Secret is wrong for this Client ID. Open Cloud Console → APIs & Services → Credentials → your Web client → reset secret or download JSON again; paste client_id and client_secret from that JSON into the CopyHub wizard (Safari/Chrome may autofill an old secret — clear fields first). '
97
+ : '';
93
98
  return (
94
99
  '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). ' +
100
+ secretHint +
101
+ 'Use OAuth client type "Web application" and add redirect URI http://127.0.0.1:<port>/oauth2callback (port matches CopyHub). Prefer pasting values from the client\'s Download JSON file to avoid typos. ' +
102
+ 'If COPYHUB_GOOGLE_CLIENT_ID / COPYHUB_GOOGLE_CLIENT_SECRET exist in shell or ~/.copyhub/.env, they must match this client or remove both so ~/.copyhub/config.json is used. ' +
97
103
  (desc ? `Google says: ${desc}` : `(${fallback})`)
98
104
  );
99
105
  }
@@ -228,6 +234,7 @@ function credentialSetupPageHtml(bootstrapToken, listenPort) {
228
234
  <div class="brand">CopyHub</div>
229
235
  <h1>Google Cloud OAuth</h1>
230
236
  <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>
237
+ <p class="hint" style="margin-bottom:20px;"><strong>Mac / Safari:</strong> copy from Google Cloud → Credentials → your <strong>Web client</strong> → <strong>Download JSON</strong> and paste <code>client_id</code> / <code>client_secret</code> exactly. Clear both fields if the browser autofills an old secret.</p>
231
238
 
232
239
  <div class="card">
233
240
  <p class="hint" style="margin-top:0;"><strong>Authorized redirect URI</strong> in Google Cloud Console must include:</p>
@@ -235,13 +242,13 @@ function credentialSetupPageHtml(bootstrapToken, listenPort) {
235
242
  <p class="hint">Port comes from <code>${ENV_OAUTH_REDIRECT_PORT}</code> (currently <strong>${listenPort}</strong>) or your saved config.</p>
236
243
  </div>
237
244
 
238
- <form method="POST" action="/credentials">
245
+ <form method="POST" action="/credentials" autocomplete="off">
239
246
  <input type="hidden" name="t" value="${tVal}" />
240
247
  <div class="card">
241
248
  <label class="field-label" for="cid">Client ID</label>
242
- <input id="cid" type="text" name="clientId" autocomplete="off" spellcheck="false" required />
249
+ <input id="cid" type="text" name="clientId" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
243
250
  <label class="field-label" for="csec" style="margin-top:16px;">Client secret</label>
244
- <input id="csec" type="password" name="clientSecret" autocomplete="new-password" spellcheck="false" required />
251
+ <input id="csec" type="text" name="clientSecret" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required placeholder="Usually starts with GOCSPX-" />
245
252
  </div>
246
253
  <button type="submit" class="submit">Save and continue to Google sign-in</button>
247
254
  </form>
@@ -316,8 +323,8 @@ async function runCredentialBootstrap() {
316
323
  res.end('Forbidden');
317
324
  return;
318
325
  }
319
- const clientId = (params.get('clientId') || '').trim();
320
- const clientSecret = (params.get('clientSecret') || '').trim();
326
+ const clientId = sanitizeOAuthCredentialInput(params.get('clientId'));
327
+ const clientSecret = sanitizeOAuthCredentialInput(params.get('clientSecret'));
321
328
  if (!clientId || !clientSecret) {
322
329
  res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
323
330
  res.end('<p>Client ID and Client secret are required.</p>');