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 +2 -0
- package/package.json +1 -1
- package/src/config.js +54 -15
- package/src/oauth.js +14 -7
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
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]
|
|
48
|
-
const sec = process.env[ENV_GOOGLE_CLIENT_SECRET]
|
|
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 =
|
|
62
|
-
|
|
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
|
|
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
|
|
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]
|
|
104
|
-
const envSecret = process.env[ENV_GOOGLE_CLIENT_SECRET]
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
236
|
-
clientSecret: cfg.clientSecret
|
|
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)
|
|
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
|
-
|
|
96
|
-
'
|
|
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="
|
|
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')
|
|
320
|
-
const clientSecret = (params.get('clientSecret')
|
|
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>');
|