coursecode 0.1.33 → 0.1.35

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/lib/cloud.js CHANGED
@@ -941,7 +941,7 @@ export async function listCourses(options = {}) {
941
941
  }
942
942
 
943
943
  /**
944
- * coursecode deploy — build, zip, resolve org, upload
944
+ * coursecode deploy — build, read files, batch presign + upload + finalize
945
945
  */
946
946
  export async function deploy(options = {}) {
947
947
  const { validateProject } = await import('./project-utils.js');
@@ -951,6 +951,9 @@ export async function deploy(options = {}) {
951
951
  const slug = resolveSlug();
952
952
  const log = (...args) => { if (!options.json) console.log(...args); };
953
953
  const logErr = (...args) => { if (!options.json) console.error(...args); };
954
+ const emitProgress = (stage, uploaded, total) => {
955
+ if (options.json) process.stdout.write(JSON.stringify({ type: 'progress', stage, uploaded, total }) + '\n');
956
+ };
954
957
 
955
958
  // Preflight a cached binding so deleted cloud courses can be repaired
956
959
  // before we spend time building and uploading.
@@ -1034,11 +1037,10 @@ export async function deploy(options = {}) {
1034
1037
  // Determine promote_mode and preview_force
1035
1038
  const promoteMode = options.promote ? 'promote' : options.stage ? 'stage' : 'auto';
1036
1039
  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
1040
  const previewOnly = previewForce && promoteMode === 'auto';
1040
1041
 
1041
1042
  log('\n📦 Building...\n');
1043
+ emitProgress('building');
1042
1044
 
1043
1045
  // Step 1: Build
1044
1046
  const { build } = await import('./build.js');
@@ -1052,15 +1054,45 @@ export async function deploy(options = {}) {
1052
1054
  process.exit(1);
1053
1055
  }
1054
1056
 
1055
- // Step 3: Resolve org
1056
- const { orgId, courseId, orgName } = await resolveOrgAndCourse(slug, readCredentials()?.token);
1057
+ // Step 3: Resolve org + ensure course exists
1058
+ const { orgId, courseId: existingCourseId, orgName } = await resolveOrgAndCourse(slug, readCredentials()?.token);
1057
1059
  const displayOrg = orgName ? ` to ${orgName}` : '';
1058
1060
 
1059
- // Step 4: Zip dist/ contents
1060
- const zipPath = path.join(os.tmpdir(), `coursecode-deploy-${Date.now()}.zip`);
1061
- await zipDirectory(distPath, zipPath);
1061
+ // Ensure course exists on Cloud (resolve or create)
1062
+ const ensureRes = await cloudFetch(
1063
+ `/api/cli/courses/${encodeURIComponent(slug)}/ensure`,
1064
+ {
1065
+ method: 'POST',
1066
+ headers: { 'Content-Type': 'application/json' },
1067
+ body: JSON.stringify({ orgId }),
1068
+ },
1069
+ readCredentials()?.token
1070
+ );
1071
+ const ensureData = await handleResponse(ensureRes);
1072
+ const courseId = ensureData.courseId || existingCourseId;
1073
+
1074
+ // Server-side safety net: reject non-preview deploys for GitHub-linked courses
1075
+ if (ensureData.githubRepo && !previewOnly) {
1076
+ logErr('\n❌ This course deploys to production via GitHub, not CLI.');
1077
+ logErr(` Repo: ${ensureData.githubRepo}`);
1078
+ logErr(' Push to your repo to trigger a production deploy.');
1079
+ logErr(' Use --preview to deploy a preview build via CLI.\n');
1080
+ if (options.json) {
1081
+ process.stdout.write(JSON.stringify({
1082
+ success: false,
1083
+ error: 'Production deploy blocked — course is GitHub-linked',
1084
+ errorCode: 'github_source_blocked',
1085
+ githubRepo: ensureData.githubRepo,
1086
+ hint: 'Use --preview for preview deploys, or push to GitHub for production.',
1087
+ }) + '\n');
1088
+ }
1089
+ process.exit(1);
1090
+ }
1091
+
1092
+ // Step 4: Read dist/ files
1093
+ const distFiles = collectDistFiles(distPath);
1094
+ const totalSize = distFiles.reduce((sum, f) => sum + f.size, 0);
1062
1095
 
1063
- // Step 5: Upload
1064
1096
  let modeLabel;
1065
1097
  if (previewOnly) {
1066
1098
  modeLabel = 'preview only';
@@ -1075,58 +1107,132 @@ export async function deploy(options = {}) {
1075
1107
  } else {
1076
1108
  modeLabel = 'production';
1077
1109
  }
1078
- log(`\nDeploying ${slug}${displayOrg} [${modeLabel}]...\n`);
1110
+ log(`\nDeploying ${slug}${displayOrg} [${modeLabel}] — ${distFiles.length} files, ${formatBytes(totalSize)}\n`);
1111
+
1112
+ // Step 5: Batch presign
1113
+ const presignRes = await cloudFetch(
1114
+ `/api/courses/${courseId}/upload/presign/batch`,
1115
+ {
1116
+ method: 'POST',
1117
+ headers: { 'Content-Type': 'application/json' },
1118
+ body: JSON.stringify({
1119
+ files: distFiles.map(f => ({ path: f.relativePath, contentType: guessContentTypeLocal(f.relativePath) })),
1120
+ totalSize,
1121
+ }),
1122
+ },
1123
+ readCredentials()?.token
1124
+ );
1125
+ const presignData = await handleResponse(presignRes);
1079
1126
 
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));
1127
+ // Step 6: Upload files to R2 via presigned URLs
1128
+ emitProgress('uploading', 0, distFiles.length);
1129
+ log(' Uploading files...');
1086
1130
 
1087
- if (options.message) {
1088
- formData.append('message', options.message);
1089
- }
1131
+ const CONCURRENCY = 8;
1132
+ const MAX_RETRIES = 3;
1133
+ let uploaded = 0;
1090
1134
 
1091
- if (options.preview && options.password) {
1092
- const pw = await prompt(' Preview password: ');
1093
- formData.append('password', pw);
1135
+ const uploadQueue = presignData.uploads.map((u, i) => ({
1136
+ uploadUrl: u.uploadUrl,
1137
+ filePath: path.join(distPath, distFiles[i].relativePath),
1138
+ contentType: guessContentTypeLocal(distFiles[i].relativePath),
1139
+ }));
1140
+
1141
+ // Concurrent upload with retry
1142
+ const chunks = [];
1143
+ for (let i = 0; i < uploadQueue.length; i += CONCURRENCY) {
1144
+ chunks.push(uploadQueue.slice(i, i + CONCURRENCY));
1145
+ }
1146
+
1147
+ for (const chunk of chunks) {
1148
+ await Promise.all(chunk.map(async (item) => {
1149
+ const fileData = fs.readFileSync(item.filePath);
1150
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
1151
+ try {
1152
+ const putRes = await fetch(item.uploadUrl, {
1153
+ method: 'PUT',
1154
+ headers: { 'Content-Type': item.contentType },
1155
+ body: fileData,
1156
+ });
1157
+ if (!putRes.ok) {
1158
+ throw new Error(`PUT failed: ${putRes.status}`);
1159
+ }
1160
+ break;
1161
+ } catch (err) {
1162
+ if (attempt === MAX_RETRIES - 1) throw err;
1163
+ await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
1164
+ }
1165
+ }
1166
+ uploaded++;
1167
+ emitProgress('uploading', uploaded, distFiles.length);
1168
+ if (!options.json && process.stdout.isTTY) {
1169
+ process.stdout.write(`\r Uploading ${uploaded}/${distFiles.length} files...`);
1170
+ }
1171
+ }));
1094
1172
  }
1095
1173
 
1096
- const queryString = previewOnly ? '?mode=preview' : '';
1174
+ if (!options.json && process.stdout.isTTY) process.stdout.write('\n');
1175
+ log(` ✓ Uploaded ${uploaded} files`);
1097
1176
 
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 });
1177
+ // Step 7: Finalize
1178
+ emitProgress('finalizing');
1179
+
1180
+ const finalizeBody = {
1181
+ versionTimestamp: presignData.versionTimestamp,
1182
+ filename: slug,
1183
+ fileCount: distFiles.length,
1184
+ totalSize,
1185
+ promoteMode,
1186
+ previewForce,
1187
+ previewOnly,
1106
1188
  };
1189
+ if (options.message) finalizeBody.message = options.message;
1107
1190
 
1108
- const result = await makeRequest();
1191
+ const finalizeRes = await cloudFetch(
1192
+ `/api/courses/${courseId}/upload/finalize`,
1193
+ {
1194
+ method: 'POST',
1195
+ headers: { 'Content-Type': 'application/json' },
1196
+ body: JSON.stringify(finalizeBody),
1197
+ },
1198
+ readCredentials()?.token
1199
+ );
1200
+ const result = await handleResponse(finalizeRes);
1109
1201
 
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 });
1202
+ // Step 8: Persist per-user binding
1203
+ writeProjectConfig({ slug, orgId: ensureData.orgId || orgId, courseId });
1113
1204
  writeRcConfig({
1114
- cloudId: finalCourseId,
1115
- orgId: result.orgId || orgId,
1205
+ cloudId: courseId,
1206
+ orgId: ensureData.orgId || orgId,
1116
1207
  });
1117
1208
 
1118
- // Step 7: Display result
1209
+ // Step 9: Display result
1210
+ const appUrl = getCloudUrl();
1211
+ const dashboardUrl = `${appUrl}/dashboard/courses/${courseId}`;
1212
+
1119
1213
  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}`);
1214
+ process.stdout.write(JSON.stringify({
1215
+ type: 'result',
1216
+ success: true,
1217
+ courseId,
1218
+ slug,
1219
+ mode: previewOnly ? 'preview' : 'production',
1220
+ fileCount: distFiles.length,
1221
+ size: totalSize,
1222
+ deploymentId: result.deploymentId,
1223
+ promoted: result.promoted,
1224
+ previewPromoted: result.previewPromoted,
1225
+ previewUrl: result.previewUrl,
1226
+ dashboardUrl,
1227
+ }) + '\n');
1228
+ } else if (previewOnly) {
1229
+ console.log(`✓ Preview deployed (${distFiles.length} files)`);
1230
+ if (result.previewUrl) console.log(` Preview URL: ${result.previewUrl}`);
1231
+ console.log(` Dashboard: ${dashboardUrl}`);
1126
1232
  } else {
1127
1233
  const prodTag = result.promoted ? 'live' : 'staged';
1128
1234
  const previewTag = result.previewPromoted ? ' + preview' : '';
1129
- console.log(`✓ Deployed (${result.fileCount} files) — ${prodTag}${previewTag}`);
1235
+ console.log(`✓ Deployed (${distFiles.length} files) — ${prodTag}${previewTag}`);
1130
1236
  if (!result.promoted) {
1131
1237
  console.log(' Production pointer not updated. Promote from Deploy History or run:');
1132
1238
  console.log(' coursecode promote --production');
@@ -1134,12 +1240,46 @@ export async function deploy(options = {}) {
1134
1240
  if (result.previewPromoted) {
1135
1241
  console.log(' Preview pointer updated.');
1136
1242
  }
1137
- console.log(` Dashboard: ${result.dashboardUrl}`);
1243
+ console.log(` Dashboard: ${dashboardUrl}`);
1138
1244
  }
1139
1245
  console.log('');
1246
+ }
1140
1247
 
1141
- // Cleanup temp zip
1142
- try { fs.unlinkSync(zipPath); } catch { /* fine */ }
1248
+ /** Recursively collect all files in a directory with relative paths and sizes. */
1249
+ export function collectDistFiles(dirPath, basePath = '') {
1250
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
1251
+ const files = [];
1252
+ for (const entry of entries) {
1253
+ const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
1254
+ const fullPath = path.join(dirPath, entry.name);
1255
+ if (entry.name === '__MACOSX' || entry.name.startsWith('.')) continue;
1256
+ if (entry.isDirectory()) {
1257
+ files.push(...collectDistFiles(fullPath, relativePath));
1258
+ } else {
1259
+ files.push({
1260
+ relativePath,
1261
+ fullPath,
1262
+ size: fs.statSync(fullPath).size,
1263
+ });
1264
+ }
1265
+ }
1266
+ return files;
1267
+ }
1268
+
1269
+ /** Minimal content-type guesser for CLI uploads. */
1270
+ export function guessContentTypeLocal(filePath) {
1271
+ const ext = filePath.split('.').pop()?.toLowerCase();
1272
+ const types = {
1273
+ html: 'text/html', htm: 'text/html', css: 'text/css',
1274
+ js: 'application/javascript', mjs: 'application/javascript',
1275
+ json: 'application/json', xml: 'application/xml',
1276
+ svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg',
1277
+ jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp',
1278
+ mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg',
1279
+ wav: 'audio/wav', woff: 'font/woff', woff2: 'font/woff2',
1280
+ ttf: 'font/ttf', pdf: 'application/pdf', txt: 'text/plain',
1281
+ };
1282
+ return types[ext] || 'application/octet-stream';
1143
1283
  }
1144
1284
 
1145
1285
  /**
@@ -1645,23 +1785,3 @@ export async function deleteCourse(options = {}) {
1645
1785
  console.log('');
1646
1786
  }
1647
1787
 
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.35",
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
  }