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.
Files changed (3) hide show
  1. package/bin/cli.js +39 -4
  2. package/lib/cloud.js +96 -86
  3. 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 { execFileSync } = await import('child_process');
42
- execFileSync(process.execPath, process.argv.slice(1), {
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
- process.exit(0);
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
- program.parse();
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, cloudUrl = DEFAULT_CLOUD_URL) {
86
+ function writeCredentials(token) {
92
87
  fs.mkdirSync(CREDENTIALS_DIR, { recursive: true });
93
- const data = JSON.stringify({ token, cloud_url: cloudUrl }, null, 2);
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
- if (useLocal) return LOCAL_CLOUD_URL;
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 attemptFetch = async (baseUrl) => {
320
- const url = `${baseUrl}${urlPath}`;
321
- try {
322
- return await fetch(url, { ...options, headers });
323
- } catch {
324
- return null; // Connection failed
325
- }
326
- };
327
-
328
- const primaryUrl = getCloudUrl();
329
- const res = await attemptFetch(primaryUrl);
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
- // Peek at the body: if it's an HTML block page, silently retry on the fallback.
345
- // We must buffer the text here since Response bodies can only be read once.
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
- // Token may be valid on the alternate cloud origin. Before triggering re-auth,
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('\n❌ coursecodecloud.com is blocked by Zscaler on your network.');
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('\n❌ coursecodecloud.com is blocked by Forcepoint on your network.');
358
+ console.error(`\n❌ ${host} is blocked by Forcepoint on your network.`);
414
359
  } else if (lower.includes('barracuda')) {
415
- console.error('\n❌ coursecodecloud.com is blocked by Barracuda on your network.');
360
+ console.error(`\n❌ ${host} is blocked by Barracuda on your network.`);
416
361
  } else {
417
- console.error(`\n❌ Your network blocked coursecodecloud.com (HTTP ${res.status}).`);
362
+ console.error(`\n❌ Your network blocked ${host} (HTTP ${res.status}).`);
418
363
  }
419
- console.error(' Ask your IT team to whitelist: coursecodecloud.com\n');
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, activeCloudUrl || getCloudUrl());
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 effectiveCloudUrl = activeCloudUrl || initialCloudUrl;
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, activeCloudUrl || initialCloudUrl);
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(`PUT failed: ${putRes.status}`);
1160
+ throw new Error(`R2 responded ${putRes.status} ${putRes.statusText}`);
1159
1161
  }
1162
+ lastErr = null;
1160
1163
  break;
1161
1164
  } catch (err) {
1162
- if (attempt === MAX_RETRIES - 1) throw err;
1163
- await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "Multi-format course authoring framework with CLI tools (SCORM 2004, SCORM 1.2, cmi5, LTI 1.3)",
5
5
  "type": "module",
6
6
  "bin": {