coursecode 0.1.35 → 0.1.36
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/bin/cli.js +39 -4
- package/lib/cloud.js +96 -86
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -38,12 +38,14 @@ if (!process.env.NODE_EXTRA_CA_CERTS) {
|
|
|
38
38
|
const certPath = await injectSystemCerts();
|
|
39
39
|
if (certPath) {
|
|
40
40
|
// macOS/Linux: re-exec with NODE_EXTRA_CA_CERTS pointing to PEM file
|
|
41
|
-
const {
|
|
42
|
-
|
|
41
|
+
const { spawnSync } = await import('child_process');
|
|
42
|
+
const result = spawnSync(process.execPath, process.argv.slice(1), {
|
|
43
43
|
env: { ...process.env, NODE_EXTRA_CA_CERTS: certPath },
|
|
44
44
|
stdio: 'inherit',
|
|
45
45
|
});
|
|
46
|
-
|
|
46
|
+
if (result.error) throw result.error;
|
|
47
|
+
if (result.signal) process.kill(process.pid, result.signal);
|
|
48
|
+
process.exit(result.status ?? 0);
|
|
47
49
|
}
|
|
48
50
|
// Windows: win-ca already injected certs in-process — continue normally
|
|
49
51
|
}
|
|
@@ -425,4 +427,37 @@ program
|
|
|
425
427
|
await deleteCourse({ force: options.force, json: options.json, repairBinding: options.repairBinding });
|
|
426
428
|
});
|
|
427
429
|
|
|
428
|
-
|
|
430
|
+
// Centralized failure handler for async command actions. Friendly errors
|
|
431
|
+
// (anything thrown with a non-Node-internal `.message`) are printed as-is;
|
|
432
|
+
// raw Node/undici errors are reduced to their message + code so users never
|
|
433
|
+
// see deep internal stack frames.
|
|
434
|
+
function reportFatal(err) {
|
|
435
|
+
if (!err) {
|
|
436
|
+
console.error('coursecode: command failed');
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
if (err.isUploadError) {
|
|
440
|
+
console.error('\n✗ ' + err.message + '\n');
|
|
441
|
+
process.exit(1);
|
|
442
|
+
}
|
|
443
|
+
const code = (err.cause && err.cause.code) || err.code;
|
|
444
|
+
const msg = err.message || String(err);
|
|
445
|
+
console.error('\n✗ ' + msg + (code ? ` (${code})` : ''));
|
|
446
|
+
if (process.env.COURSECODE_DEBUG) {
|
|
447
|
+
console.error('\n' + (err.stack || ''));
|
|
448
|
+
if (err.cause) console.error('Caused by:', err.cause);
|
|
449
|
+
} else {
|
|
450
|
+
console.error(' Re-run with COURSECODE_DEBUG=1 for a full stack trace.');
|
|
451
|
+
}
|
|
452
|
+
console.error('');
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
process.on('unhandledRejection', reportFatal);
|
|
457
|
+
process.on('uncaughtException', reportFatal);
|
|
458
|
+
|
|
459
|
+
if (process.argv.length <= 2) {
|
|
460
|
+
program.help();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
program.parseAsync().catch(reportFatal);
|
package/lib/cloud.js
CHANGED
|
@@ -22,14 +22,9 @@ const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'packa
|
|
|
22
22
|
// CONSTANTS
|
|
23
23
|
// =============================================================================
|
|
24
24
|
|
|
25
|
-
const DEFAULT_CLOUD_URL = 'https://coursecodecloud.com';
|
|
26
|
-
// Fallback URL used automatically when the primary domain is blocked by a
|
|
27
|
-
// corporate web filter (e.g. Zscaler URL categorization). *.vercel.app is
|
|
28
|
-
// in a trusted platform category and is unlikely to be categorized as unknown.
|
|
29
|
-
const FALLBACK_CLOUD_URL = 'https://coursecode-cloud-web.vercel.app';
|
|
25
|
+
const DEFAULT_CLOUD_URL = 'https://www.coursecodecloud.com';
|
|
30
26
|
const LOCAL_CLOUD_URL = 'http://localhost:3000';
|
|
31
27
|
let useLocal = false;
|
|
32
|
-
let activeCloudUrl = null;
|
|
33
28
|
const CREDENTIALS_DIR = path.join(os.homedir(), '.coursecode');
|
|
34
29
|
const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, 'credentials.json');
|
|
35
30
|
const PROJECT_CONFIG_DIR = '.coursecode';
|
|
@@ -88,26 +83,18 @@ function readCredentials() {
|
|
|
88
83
|
}
|
|
89
84
|
}
|
|
90
85
|
|
|
91
|
-
function writeCredentials(token
|
|
86
|
+
function writeCredentials(token) {
|
|
92
87
|
fs.mkdirSync(CREDENTIALS_DIR, { recursive: true });
|
|
93
|
-
const data = JSON.stringify({ token
|
|
88
|
+
const data = JSON.stringify({ token }, null, 2);
|
|
94
89
|
fs.writeFileSync(getCredentialsPath(), data, { mode: 0o600 });
|
|
95
90
|
}
|
|
96
91
|
|
|
97
|
-
function updateCredentialsCloudUrl(cloudUrl) {
|
|
98
|
-
const creds = readCredentials();
|
|
99
|
-
if (!creds?.token) return;
|
|
100
|
-
writeCredentials(creds.token, cloudUrl);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
92
|
function deleteCredentials() {
|
|
104
93
|
try { fs.unlinkSync(getCredentialsPath()); } catch { /* already gone */ }
|
|
105
94
|
}
|
|
106
95
|
|
|
107
96
|
function getCloudUrl() {
|
|
108
|
-
|
|
109
|
-
if (activeCloudUrl) return activeCloudUrl;
|
|
110
|
-
return readCredentials()?.cloud_url || DEFAULT_CLOUD_URL;
|
|
97
|
+
return useLocal ? LOCAL_CLOUD_URL : DEFAULT_CLOUD_URL;
|
|
111
98
|
}
|
|
112
99
|
|
|
113
100
|
/**
|
|
@@ -116,7 +103,6 @@ function getCloudUrl() {
|
|
|
116
103
|
*/
|
|
117
104
|
export function setLocalMode() {
|
|
118
105
|
useLocal = true;
|
|
119
|
-
activeCloudUrl = LOCAL_CLOUD_URL;
|
|
120
106
|
}
|
|
121
107
|
|
|
122
108
|
// =============================================================================
|
|
@@ -300,9 +286,6 @@ async function resolveStaleBinding({
|
|
|
300
286
|
* Make an authenticated request to the Cloud API.
|
|
301
287
|
* Handles User-Agent, Bearer token, and error formatting per §7.
|
|
302
288
|
*
|
|
303
|
-
* Automatically retries against FALLBACK_CLOUD_URL when the primary URL
|
|
304
|
-
* returns an HTML block page (corporate web filter / Zscaler URL categorization).
|
|
305
|
-
*
|
|
306
289
|
* @param {string} urlPath - API path (e.g. '/api/cli/whoami')
|
|
307
290
|
* @param {object} options - fetch options (method, body, headers, etc.)
|
|
308
291
|
* @param {string} [token] - Override token (for unauthenticated requests)
|
|
@@ -316,68 +299,29 @@ async function cloudFetch(urlPath, options = {}, token = null) {
|
|
|
316
299
|
};
|
|
317
300
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
318
301
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (!res) {
|
|
332
|
-
// Primary unreachable — try fallback before giving up
|
|
333
|
-
if (!useLocal) {
|
|
334
|
-
const fallback = await attemptFetch(FALLBACK_CLOUD_URL);
|
|
335
|
-
if (fallback) {
|
|
336
|
-
activeCloudUrl = FALLBACK_CLOUD_URL;
|
|
337
|
-
return fallback;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
console.error('\n❌ Could not connect to CourseCode Cloud. Check your internet connection.\n');
|
|
302
|
+
const baseUrl = getCloudUrl();
|
|
303
|
+
const url = `${baseUrl}${urlPath}`;
|
|
304
|
+
let res;
|
|
305
|
+
try {
|
|
306
|
+
res = await fetch(url, { ...options, headers });
|
|
307
|
+
} catch (err) {
|
|
308
|
+
const host = new URL(baseUrl).host;
|
|
309
|
+
const code = (err && err.cause && err.cause.code) || err?.code;
|
|
310
|
+
console.error(`\n❌ Could not connect to ${host}${code ? ` (${code})` : ''}.`);
|
|
311
|
+
console.error(' Check your internet connection. If on a corporate or school network,');
|
|
312
|
+
console.error(` ask IT to whitelist ${host}.\n`);
|
|
341
313
|
process.exit(1);
|
|
342
314
|
}
|
|
343
315
|
|
|
344
|
-
//
|
|
345
|
-
//
|
|
316
|
+
// Buffer the body once so handleResponse can re-read it, and so we can
|
|
317
|
+
// detect HTML block pages from corporate web filters (Zscaler, etc.).
|
|
346
318
|
const text = await res.text();
|
|
347
319
|
|
|
348
|
-
|
|
349
|
-
// retry authenticated 401s once on the other known origin.
|
|
350
|
-
if (res.status === 401 && token && !useLocal) {
|
|
351
|
-
const alternateUrl = primaryUrl === FALLBACK_CLOUD_URL ? DEFAULT_CLOUD_URL : FALLBACK_CLOUD_URL;
|
|
352
|
-
const alternateRes = await attemptFetch(alternateUrl);
|
|
353
|
-
if (alternateRes) {
|
|
354
|
-
const alternateText = await alternateRes.text();
|
|
355
|
-
if (!isBlockPage(alternateText) && alternateRes.status !== 401) {
|
|
356
|
-
activeCloudUrl = alternateUrl;
|
|
357
|
-
updateCredentialsCloudUrl(alternateUrl);
|
|
358
|
-
return syntheticResponse(alternateText, alternateRes.status);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
if (isBlockPage(text) && !useLocal) {
|
|
364
|
-
const fallbackRes = await attemptFetch(FALLBACK_CLOUD_URL);
|
|
365
|
-
if (fallbackRes) {
|
|
366
|
-
const fallbackText = await fallbackRes.text();
|
|
367
|
-
if (!isBlockPage(fallbackText)) {
|
|
368
|
-
activeCloudUrl = FALLBACK_CLOUD_URL;
|
|
369
|
-
updateCredentialsCloudUrl(FALLBACK_CLOUD_URL);
|
|
370
|
-
// Fallback succeeded — return a synthetic Response with the buffered text
|
|
371
|
-
return syntheticResponse(fallbackText, fallbackRes.status);
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
// Both primary and fallback are blocked — surface the error
|
|
320
|
+
if (isBlockPage(text)) {
|
|
375
321
|
reportBlockPage(text, res);
|
|
376
322
|
process.exit(1);
|
|
377
323
|
}
|
|
378
324
|
|
|
379
|
-
// Primary response is fine — return a synthetic Response with the buffered text
|
|
380
|
-
activeCloudUrl = primaryUrl;
|
|
381
325
|
return syntheticResponse(text, res.status);
|
|
382
326
|
}
|
|
383
327
|
|
|
@@ -406,17 +350,18 @@ function syntheticResponse(text, status) {
|
|
|
406
350
|
* @param {Response} res - The fetch Response object
|
|
407
351
|
*/
|
|
408
352
|
function reportBlockPage(body, res) {
|
|
353
|
+
const host = new URL(getCloudUrl()).host;
|
|
409
354
|
const lower = body.toLowerCase();
|
|
410
355
|
if (lower.includes('zscaler')) {
|
|
411
|
-
console.error(
|
|
356
|
+
console.error(`\n❌ ${host} is blocked by Zscaler on your network.`);
|
|
412
357
|
} else if (lower.includes('forcepoint') || lower.includes('websense')) {
|
|
413
|
-
console.error(
|
|
358
|
+
console.error(`\n❌ ${host} is blocked by Forcepoint on your network.`);
|
|
414
359
|
} else if (lower.includes('barracuda')) {
|
|
415
|
-
console.error(
|
|
360
|
+
console.error(`\n❌ ${host} is blocked by Barracuda on your network.`);
|
|
416
361
|
} else {
|
|
417
|
-
console.error(`\n❌ Your network blocked
|
|
362
|
+
console.error(`\n❌ Your network blocked ${host} (HTTP ${res.status}).`);
|
|
418
363
|
}
|
|
419
|
-
console.error(
|
|
364
|
+
console.error(` Ask your IT team to whitelist: ${host}\n`);
|
|
420
365
|
}
|
|
421
366
|
|
|
422
367
|
/**
|
|
@@ -613,7 +558,7 @@ async function runLoginFlow({ jsonMode = false } = {}) {
|
|
|
613
558
|
if (data.pending) continue;
|
|
614
559
|
|
|
615
560
|
if (data.token) {
|
|
616
|
-
writeCredentials(data.token
|
|
561
|
+
writeCredentials(data.token);
|
|
617
562
|
log(' ✓ Logged in successfully\n');
|
|
618
563
|
return data.token;
|
|
619
564
|
}
|
|
@@ -647,8 +592,7 @@ async function runLegacyLoginFlow() {
|
|
|
647
592
|
process.exit(1);
|
|
648
593
|
}
|
|
649
594
|
|
|
650
|
-
const
|
|
651
|
-
const loginUrl = `${effectiveCloudUrl}/auth/connect?session=${nonce}`;
|
|
595
|
+
const loginUrl = `${initialCloudUrl}/auth/connect?session=${nonce}`;
|
|
652
596
|
console.log(' → Opening browser for authentication...');
|
|
653
597
|
openBrowser(loginUrl);
|
|
654
598
|
|
|
@@ -669,7 +613,7 @@ async function runLegacyLoginFlow() {
|
|
|
669
613
|
if (data.pending) continue;
|
|
670
614
|
|
|
671
615
|
if (data.token) {
|
|
672
|
-
writeCredentials(data.token
|
|
616
|
+
writeCredentials(data.token);
|
|
673
617
|
console.log(' ✓ Logged in successfully');
|
|
674
618
|
return data.token;
|
|
675
619
|
}
|
|
@@ -940,6 +884,60 @@ export async function listCourses(options = {}) {
|
|
|
940
884
|
}
|
|
941
885
|
}
|
|
942
886
|
|
|
887
|
+
/**
|
|
888
|
+
* Translate low-level fetch/undici errors from R2 PUTs into a friendly,
|
|
889
|
+
* actionable CLI error. Identifies the failing file and host, and attaches
|
|
890
|
+
* a hint when the cause is a known network condition (connect timeout, DNS,
|
|
891
|
+
* reset, TLS). The original error is preserved on `.cause`.
|
|
892
|
+
*/
|
|
893
|
+
function wrapUploadError(err, item) {
|
|
894
|
+
let host = '';
|
|
895
|
+
try { host = new URL(item.uploadUrl).host; } catch {}
|
|
896
|
+
const cause = err && err.cause;
|
|
897
|
+
const code = (cause && cause.code) || err.code || err.name;
|
|
898
|
+
|
|
899
|
+
let summary = `Failed to upload ${item.filePath}`;
|
|
900
|
+
let hint = '';
|
|
901
|
+
|
|
902
|
+
switch (code) {
|
|
903
|
+
case 'UND_ERR_CONNECT_TIMEOUT':
|
|
904
|
+
case 'ETIMEDOUT':
|
|
905
|
+
case 'TimeoutError':
|
|
906
|
+
summary += ` — could not reach ${host} (connect timeout)`;
|
|
907
|
+
hint = [
|
|
908
|
+
'This is a network problem between your machine and Cloudflare R2, not the Cloud service.',
|
|
909
|
+
'Try:',
|
|
910
|
+
' • If on VPN or a corporate/school network, disconnect or whitelist *.r2.cloudflarestorage.com',
|
|
911
|
+
' • Re-run with IPv4 first: NODE_OPTIONS=--dns-result-order=ipv4first coursecode deploy',
|
|
912
|
+
' • Test connectivity: curl -v https://' + host + '/',
|
|
913
|
+
].join('\n ');
|
|
914
|
+
break;
|
|
915
|
+
case 'ENOTFOUND':
|
|
916
|
+
case 'EAI_AGAIN':
|
|
917
|
+
summary += ` — DNS lookup failed for ${host}`;
|
|
918
|
+
hint = 'Check your internet connection and DNS settings (e.g. switch to 1.1.1.1 or 8.8.8.8).';
|
|
919
|
+
break;
|
|
920
|
+
case 'ECONNRESET':
|
|
921
|
+
case 'UND_ERR_SOCKET':
|
|
922
|
+
summary += ` — connection to ${host} was reset mid-transfer`;
|
|
923
|
+
hint = 'Likely a flaky network or proxy interfering with the upload. Re-run the deploy.';
|
|
924
|
+
break;
|
|
925
|
+
case 'CERT_HAS_EXPIRED':
|
|
926
|
+
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
|
|
927
|
+
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
|
928
|
+
summary += ` — TLS handshake with ${host} failed (${code})`;
|
|
929
|
+
hint = 'A SSL-inspecting proxy (e.g. Zscaler) is intercepting the connection. Trust the corporate root CA or whitelist *.r2.cloudflarestorage.com.';
|
|
930
|
+
break;
|
|
931
|
+
default:
|
|
932
|
+
summary += ` — ${err.message || code || 'unknown error'}`;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const wrapped = new Error(hint ? `${summary}\n ${hint}` : summary);
|
|
936
|
+
wrapped.cause = err;
|
|
937
|
+
wrapped.isUploadError = true;
|
|
938
|
+
return wrapped;
|
|
939
|
+
}
|
|
940
|
+
|
|
943
941
|
/**
|
|
944
942
|
* coursecode deploy — build, read files, batch presign + upload + finalize
|
|
945
943
|
*/
|
|
@@ -1147,22 +1145,34 @@ export async function deploy(options = {}) {
|
|
|
1147
1145
|
for (const chunk of chunks) {
|
|
1148
1146
|
await Promise.all(chunk.map(async (item) => {
|
|
1149
1147
|
const fileData = fs.readFileSync(item.filePath);
|
|
1148
|
+
let lastErr = null;
|
|
1150
1149
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
1151
1150
|
try {
|
|
1151
|
+
// 60s per attempt covers slow links; default undici connect timeout
|
|
1152
|
+
// is only 10s which can fail spuriously on flaky networks/VPNs.
|
|
1152
1153
|
const putRes = await fetch(item.uploadUrl, {
|
|
1153
1154
|
method: 'PUT',
|
|
1154
1155
|
headers: { 'Content-Type': item.contentType },
|
|
1155
1156
|
body: fileData,
|
|
1157
|
+
signal: AbortSignal.timeout(60_000),
|
|
1156
1158
|
});
|
|
1157
1159
|
if (!putRes.ok) {
|
|
1158
|
-
throw new Error(`
|
|
1160
|
+
throw new Error(`R2 responded ${putRes.status} ${putRes.statusText}`);
|
|
1159
1161
|
}
|
|
1162
|
+
lastErr = null;
|
|
1160
1163
|
break;
|
|
1161
1164
|
} catch (err) {
|
|
1162
|
-
|
|
1163
|
-
|
|
1165
|
+
lastErr = err;
|
|
1166
|
+
if (attempt === MAX_RETRIES - 1) break;
|
|
1167
|
+
// Exponential backoff with jitter: ~1s, ~2s, ~4s.
|
|
1168
|
+
const base = 1000 * Math.pow(2, attempt);
|
|
1169
|
+
const jitter = Math.floor(Math.random() * 500);
|
|
1170
|
+
await new Promise(r => setTimeout(r, base + jitter));
|
|
1164
1171
|
}
|
|
1165
1172
|
}
|
|
1173
|
+
if (lastErr) {
|
|
1174
|
+
throw wrapUploadError(lastErr, item);
|
|
1175
|
+
}
|
|
1166
1176
|
uploaded++;
|
|
1167
1177
|
emitProgress('uploading', uploaded, distFiles.length);
|
|
1168
1178
|
if (!options.json && process.stdout.isTTY) {
|