coursecode 0.1.33 → 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 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
  }
@@ -941,7 +885,61 @@ export async function listCourses(options = {}) {
941
885
  }
942
886
 
943
887
  /**
944
- * coursecode deploy build, zip, resolve org, upload
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
+
941
+ /**
942
+ * coursecode deploy — build, read files, batch presign + upload + finalize
945
943
  */
946
944
  export async function deploy(options = {}) {
947
945
  const { validateProject } = await import('./project-utils.js');
@@ -951,6 +949,9 @@ export async function deploy(options = {}) {
951
949
  const slug = resolveSlug();
952
950
  const log = (...args) => { if (!options.json) console.log(...args); };
953
951
  const logErr = (...args) => { if (!options.json) console.error(...args); };
952
+ const emitProgress = (stage, uploaded, total) => {
953
+ if (options.json) process.stdout.write(JSON.stringify({ type: 'progress', stage, uploaded, total }) + '\n');
954
+ };
954
955
 
955
956
  // Preflight a cached binding so deleted cloud courses can be repaired
956
957
  // before we spend time building and uploading.
@@ -1034,11 +1035,10 @@ export async function deploy(options = {}) {
1034
1035
  // Determine promote_mode and preview_force
1035
1036
  const promoteMode = options.promote ? 'promote' : options.stage ? 'stage' : 'auto';
1036
1037
  const previewForce = !!options.preview;
1037
- // --preview alone = preview_only deploy (production pointer untouched, preview always moved)
1038
- // --preview + --promote/--stage = full production deploy + always move preview pointer
1039
1038
  const previewOnly = previewForce && promoteMode === 'auto';
1040
1039
 
1041
1040
  log('\n📦 Building...\n');
1041
+ emitProgress('building');
1042
1042
 
1043
1043
  // Step 1: Build
1044
1044
  const { build } = await import('./build.js');
@@ -1052,15 +1052,45 @@ export async function deploy(options = {}) {
1052
1052
  process.exit(1);
1053
1053
  }
1054
1054
 
1055
- // Step 3: Resolve org
1056
- const { orgId, courseId, orgName } = await resolveOrgAndCourse(slug, readCredentials()?.token);
1055
+ // Step 3: Resolve org + ensure course exists
1056
+ const { orgId, courseId: existingCourseId, orgName } = await resolveOrgAndCourse(slug, readCredentials()?.token);
1057
1057
  const displayOrg = orgName ? ` to ${orgName}` : '';
1058
1058
 
1059
- // Step 4: Zip dist/ contents
1060
- const zipPath = path.join(os.tmpdir(), `coursecode-deploy-${Date.now()}.zip`);
1061
- await zipDirectory(distPath, zipPath);
1059
+ // Ensure course exists on Cloud (resolve or create)
1060
+ const ensureRes = await cloudFetch(
1061
+ `/api/cli/courses/${encodeURIComponent(slug)}/ensure`,
1062
+ {
1063
+ method: 'POST',
1064
+ headers: { 'Content-Type': 'application/json' },
1065
+ body: JSON.stringify({ orgId }),
1066
+ },
1067
+ readCredentials()?.token
1068
+ );
1069
+ const ensureData = await handleResponse(ensureRes);
1070
+ const courseId = ensureData.courseId || existingCourseId;
1071
+
1072
+ // Server-side safety net: reject non-preview deploys for GitHub-linked courses
1073
+ if (ensureData.githubRepo && !previewOnly) {
1074
+ logErr('\n❌ This course deploys to production via GitHub, not CLI.');
1075
+ logErr(` Repo: ${ensureData.githubRepo}`);
1076
+ logErr(' Push to your repo to trigger a production deploy.');
1077
+ logErr(' Use --preview to deploy a preview build via CLI.\n');
1078
+ if (options.json) {
1079
+ process.stdout.write(JSON.stringify({
1080
+ success: false,
1081
+ error: 'Production deploy blocked — course is GitHub-linked',
1082
+ errorCode: 'github_source_blocked',
1083
+ githubRepo: ensureData.githubRepo,
1084
+ hint: 'Use --preview for preview deploys, or push to GitHub for production.',
1085
+ }) + '\n');
1086
+ }
1087
+ process.exit(1);
1088
+ }
1089
+
1090
+ // Step 4: Read dist/ files
1091
+ const distFiles = collectDistFiles(distPath);
1092
+ const totalSize = distFiles.reduce((sum, f) => sum + f.size, 0);
1062
1093
 
1063
- // Step 5: Upload
1064
1094
  let modeLabel;
1065
1095
  if (previewOnly) {
1066
1096
  modeLabel = 'preview only';
@@ -1075,58 +1105,144 @@ export async function deploy(options = {}) {
1075
1105
  } else {
1076
1106
  modeLabel = 'production';
1077
1107
  }
1078
- log(`\nDeploying ${slug}${displayOrg} [${modeLabel}]...\n`);
1108
+ log(`\nDeploying ${slug}${displayOrg} [${modeLabel}] — ${distFiles.length} files, ${formatBytes(totalSize)}\n`);
1079
1109
 
1080
- const formData = new FormData();
1081
- const zipBuffer = fs.readFileSync(zipPath);
1082
- formData.append('file', new Blob([zipBuffer], { type: 'application/zip' }), 'deploy.zip');
1083
- formData.append('orgId', orgId);
1084
- formData.append('promote_mode', promoteMode);
1085
- formData.append('preview_force', String(previewForce));
1110
+ // Step 5: Batch presign
1111
+ const presignRes = await cloudFetch(
1112
+ `/api/courses/${courseId}/upload/presign/batch`,
1113
+ {
1114
+ method: 'POST',
1115
+ headers: { 'Content-Type': 'application/json' },
1116
+ body: JSON.stringify({
1117
+ files: distFiles.map(f => ({ path: f.relativePath, contentType: guessContentTypeLocal(f.relativePath) })),
1118
+ totalSize,
1119
+ }),
1120
+ },
1121
+ readCredentials()?.token
1122
+ );
1123
+ const presignData = await handleResponse(presignRes);
1086
1124
 
1087
- if (options.message) {
1088
- formData.append('message', options.message);
1089
- }
1125
+ // Step 6: Upload files to R2 via presigned URLs
1126
+ emitProgress('uploading', 0, distFiles.length);
1127
+ log(' Uploading files...');
1128
+
1129
+ const CONCURRENCY = 8;
1130
+ const MAX_RETRIES = 3;
1131
+ let uploaded = 0;
1090
1132
 
1091
- if (options.preview && options.password) {
1092
- const pw = await prompt(' Preview password: ');
1093
- formData.append('password', pw);
1133
+ const uploadQueue = presignData.uploads.map((u, i) => ({
1134
+ uploadUrl: u.uploadUrl,
1135
+ filePath: path.join(distPath, distFiles[i].relativePath),
1136
+ contentType: guessContentTypeLocal(distFiles[i].relativePath),
1137
+ }));
1138
+
1139
+ // Concurrent upload with retry
1140
+ const chunks = [];
1141
+ for (let i = 0; i < uploadQueue.length; i += CONCURRENCY) {
1142
+ chunks.push(uploadQueue.slice(i, i + CONCURRENCY));
1143
+ }
1144
+
1145
+ for (const chunk of chunks) {
1146
+ await Promise.all(chunk.map(async (item) => {
1147
+ const fileData = fs.readFileSync(item.filePath);
1148
+ let lastErr = null;
1149
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
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.
1153
+ const putRes = await fetch(item.uploadUrl, {
1154
+ method: 'PUT',
1155
+ headers: { 'Content-Type': item.contentType },
1156
+ body: fileData,
1157
+ signal: AbortSignal.timeout(60_000),
1158
+ });
1159
+ if (!putRes.ok) {
1160
+ throw new Error(`R2 responded ${putRes.status} ${putRes.statusText}`);
1161
+ }
1162
+ lastErr = null;
1163
+ break;
1164
+ } catch (err) {
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));
1171
+ }
1172
+ }
1173
+ if (lastErr) {
1174
+ throw wrapUploadError(lastErr, item);
1175
+ }
1176
+ uploaded++;
1177
+ emitProgress('uploading', uploaded, distFiles.length);
1178
+ if (!options.json && process.stdout.isTTY) {
1179
+ process.stdout.write(`\r Uploading ${uploaded}/${distFiles.length} files...`);
1180
+ }
1181
+ }));
1094
1182
  }
1095
1183
 
1096
- const queryString = previewOnly ? '?mode=preview' : '';
1184
+ if (!options.json && process.stdout.isTTY) process.stdout.write('\n');
1185
+ log(` ✓ Uploaded ${uploaded} files`);
1097
1186
 
1098
- const makeRequest = async (_isRetry = false) => {
1099
- const token = readCredentials()?.token;
1100
- const res = await cloudFetch(
1101
- `/api/cli/courses/${encodeURIComponent(slug)}/deploy${queryString}`,
1102
- { method: 'POST', body: formData },
1103
- token
1104
- );
1105
- return handleResponse(res, { retryFn: makeRequest, _isRetry });
1187
+ // Step 7: Finalize
1188
+ emitProgress('finalizing');
1189
+
1190
+ const finalizeBody = {
1191
+ versionTimestamp: presignData.versionTimestamp,
1192
+ filename: slug,
1193
+ fileCount: distFiles.length,
1194
+ totalSize,
1195
+ promoteMode,
1196
+ previewForce,
1197
+ previewOnly,
1106
1198
  };
1199
+ if (options.message) finalizeBody.message = options.message;
1107
1200
 
1108
- const result = await makeRequest();
1201
+ const finalizeRes = await cloudFetch(
1202
+ `/api/courses/${courseId}/upload/finalize`,
1203
+ {
1204
+ method: 'POST',
1205
+ headers: { 'Content-Type': 'application/json' },
1206
+ body: JSON.stringify(finalizeBody),
1207
+ },
1208
+ readCredentials()?.token
1209
+ );
1210
+ const result = await handleResponse(finalizeRes);
1109
1211
 
1110
- // Step 6: Persist per-user binding and stamp cloud identity into .coursecoderc.json
1111
- const finalCourseId = result.courseId || courseId;
1112
- writeProjectConfig({ slug, orgId: result.orgId || orgId, courseId: finalCourseId });
1212
+ // Step 8: Persist per-user binding
1213
+ writeProjectConfig({ slug, orgId: ensureData.orgId || orgId, courseId });
1113
1214
  writeRcConfig({
1114
- cloudId: finalCourseId,
1115
- orgId: result.orgId || orgId,
1215
+ cloudId: courseId,
1216
+ orgId: ensureData.orgId || orgId,
1116
1217
  });
1117
1218
 
1118
- // Step 7: Display result
1219
+ // Step 9: Display result
1220
+ const appUrl = getCloudUrl();
1221
+ const dashboardUrl = `${appUrl}/dashboard/courses/${courseId}`;
1222
+
1119
1223
  if (options.json) {
1120
- process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
1121
- } else if (result.mode === 'preview') {
1122
- // preview_only=true deployment (--preview alone)
1123
- console.log(`✓ Preview deployed (${result.fileCount} files)`);
1124
- console.log(` Preview URL: ${result.url}`);
1125
- console.log(` Dashboard: ${result.dashboardUrl}`);
1224
+ process.stdout.write(JSON.stringify({
1225
+ type: 'result',
1226
+ success: true,
1227
+ courseId,
1228
+ slug,
1229
+ mode: previewOnly ? 'preview' : 'production',
1230
+ fileCount: distFiles.length,
1231
+ size: totalSize,
1232
+ deploymentId: result.deploymentId,
1233
+ promoted: result.promoted,
1234
+ previewPromoted: result.previewPromoted,
1235
+ previewUrl: result.previewUrl,
1236
+ dashboardUrl,
1237
+ }) + '\n');
1238
+ } else if (previewOnly) {
1239
+ console.log(`✓ Preview deployed (${distFiles.length} files)`);
1240
+ if (result.previewUrl) console.log(` Preview URL: ${result.previewUrl}`);
1241
+ console.log(` Dashboard: ${dashboardUrl}`);
1126
1242
  } else {
1127
1243
  const prodTag = result.promoted ? 'live' : 'staged';
1128
1244
  const previewTag = result.previewPromoted ? ' + preview' : '';
1129
- console.log(`✓ Deployed (${result.fileCount} files) — ${prodTag}${previewTag}`);
1245
+ console.log(`✓ Deployed (${distFiles.length} files) — ${prodTag}${previewTag}`);
1130
1246
  if (!result.promoted) {
1131
1247
  console.log(' Production pointer not updated. Promote from Deploy History or run:');
1132
1248
  console.log(' coursecode promote --production');
@@ -1134,12 +1250,46 @@ export async function deploy(options = {}) {
1134
1250
  if (result.previewPromoted) {
1135
1251
  console.log(' Preview pointer updated.');
1136
1252
  }
1137
- console.log(` Dashboard: ${result.dashboardUrl}`);
1253
+ console.log(` Dashboard: ${dashboardUrl}`);
1138
1254
  }
1139
1255
  console.log('');
1256
+ }
1140
1257
 
1141
- // Cleanup temp zip
1142
- try { fs.unlinkSync(zipPath); } catch { /* fine */ }
1258
+ /** Recursively collect all files in a directory with relative paths and sizes. */
1259
+ export function collectDistFiles(dirPath, basePath = '') {
1260
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1261
+ const files = [];
1262
+ for (const entry of entries) {
1263
+ const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
1264
+ const fullPath = path.join(dirPath, entry.name);
1265
+ if (entry.name === '__MACOSX' || entry.name.startsWith('.')) continue;
1266
+ if (entry.isDirectory()) {
1267
+ files.push(...collectDistFiles(fullPath, relativePath));
1268
+ } else {
1269
+ files.push({
1270
+ relativePath,
1271
+ fullPath,
1272
+ size: fs.statSync(fullPath).size,
1273
+ });
1274
+ }
1275
+ }
1276
+ return files;
1277
+ }
1278
+
1279
+ /** Minimal content-type guesser for CLI uploads. */
1280
+ export function guessContentTypeLocal(filePath) {
1281
+ const ext = filePath.split('.').pop()?.toLowerCase();
1282
+ const types = {
1283
+ html: 'text/html', htm: 'text/html', css: 'text/css',
1284
+ js: 'application/javascript', mjs: 'application/javascript',
1285
+ json: 'application/json', xml: 'application/xml',
1286
+ svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg',
1287
+ jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
1288
+ mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg',
1289
+ wav: 'audio/wav', woff: 'font/woff', woff2: 'font/woff2',
1290
+ ttf: 'font/ttf', pdf: 'application/pdf', txt: 'text/plain',
1291
+ };
1292
+ return types[ext] || 'application/octet-stream';
1143
1293
  }
1144
1294
 
1145
1295
  /**
@@ -1645,23 +1795,3 @@ export async function deleteCourse(options = {}) {
1645
1795
  console.log('');
1646
1796
  }
1647
1797
 
1648
- // =============================================================================
1649
- // ZIP HELPER
1650
- // =============================================================================
1651
-
1652
- /**
1653
- * Zip a directory's contents using archiver (cross-platform, no native tools needed).
1654
- */
1655
- async function zipDirectory(sourceDir, outputPath) {
1656
- const archiver = (await import('archiver')).default;
1657
- return new Promise((resolve, reject) => {
1658
- const output = fs.createWriteStream(outputPath);
1659
- const archive = archiver('zip', { zlib: { level: 9 } });
1660
-
1661
- output.on('close', () => resolve());
1662
- archive.on('error', reject);
1663
- archive.pipe(output);
1664
- archive.directory(sourceDir, false);
1665
- archive.finalize();
1666
- });
1667
- }
@@ -72,6 +72,8 @@ class HeadlessBrowser {
72
72
  this._stopped = false;
73
73
  this._consoleLogs = [];
74
74
  this._viewport = { width: 1280, height: 720 };
75
+ /** @type {Promise<void>|null} Tracks an in-flight reload so tool calls wait for it */
76
+ this._reloadPromise = null;
75
77
  }
76
78
 
77
79
  /**
@@ -136,6 +138,11 @@ class HeadlessBrowser {
136
138
  * @private
137
139
  */
138
140
  async _navigateToPreview() {
141
+ // Clear stale console errors from the previous page load.
142
+ // The page.goto() below will trigger teardown console noise from the
143
+ // dying page — we only want post-reload errors.
144
+ this._consoleLogs = [];
145
+
139
146
  const url = `http://localhost:${this.port}?headless`;
140
147
  await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 3000 });
141
148
 
@@ -189,10 +196,16 @@ class HeadlessBrowser {
189
196
  if (this._stopped) return;
190
197
  const data = chunk.toString();
191
198
  if (data.includes('data: reload')) {
199
+ // Track the reload so concurrent tool calls wait for it
200
+ let resolveReload;
201
+ this._reloadPromise = new Promise(r => { resolveReload = r; });
192
202
  try {
193
203
  await this._navigateToPreview();
194
204
  } catch (_e) {
195
205
  // Preview may be mid-rebuild, retry will happen on next SSE
206
+ } finally {
207
+ this._reloadPromise = null;
208
+ resolveReload();
196
209
  }
197
210
  }
198
211
  });
@@ -425,9 +438,24 @@ class HeadlessBrowser {
425
438
  * @private
426
439
  */
427
440
  async _ensureCourseFrame() {
441
+ // If an SSE-triggered reload is in progress, wait for it to finish.
442
+ // This prevents tool calls from hitting a mid-reinitializing framework
443
+ // ("NavigationActions not initialized" errors after file edits).
444
+ if (this._reloadPromise) {
445
+ await this._reloadPromise;
446
+ }
447
+
428
448
  try {
429
- // Quick check that the frame is still attached
430
- await this.courseFrame.evaluate(() => true);
449
+ // Check that the frame is still attached AND the framework is ready.
450
+ // The stub player (in-page JS) may reload the iframe before the Node-side
451
+ // SSE handler sets _reloadPromise, so we must also verify readiness here.
452
+ const ready = await this.courseFrame.evaluate(
453
+ () => window.CourseCodeAutomation?.ready === true
454
+ );
455
+ if (!ready) {
456
+ // Frame is attached but framework is reinitializing — wait for it
457
+ await this._locateCourseFrame();
458
+ }
431
459
  } catch (_e) {
432
460
  // Frame detached (page reloaded), re-locate it
433
461
  await this._locateCourseFrame();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.33",
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": {
@@ -19,10 +19,10 @@
19
19
  "marked-gfm-heading-id": "^4.1.3"
20
20
  },
21
21
  "devDependencies": {
22
- "@vitejs/plugin-legacy": "^7.2.1",
22
+ "@vitejs/plugin-legacy": "~7.2.1",
23
23
  "archiver": "^7.0.1",
24
24
  "eslint": "^9.39.1",
25
- "vite": "^7.2.2",
25
+ "vite": "~7.2.2",
26
26
  "vite-plugin-static-copy": "^3.1.4"
27
27
  }
28
28
  }