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 +188 -68
- package/lib/headless-browser.js +30 -2
- package/package.json +1 -1
- package/template/package.json +2 -2
package/lib/cloud.js
CHANGED
|
@@ -941,7 +941,7 @@ export async function listCourses(options = {}) {
|
|
|
941
941
|
}
|
|
942
942
|
|
|
943
943
|
/**
|
|
944
|
-
* coursecode deploy — build,
|
|
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
|
-
//
|
|
1060
|
-
const
|
|
1061
|
-
|
|
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}]
|
|
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
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1131
|
+
const CONCURRENCY = 8;
|
|
1132
|
+
const MAX_RETRIES = 3;
|
|
1133
|
+
let uploaded = 0;
|
|
1090
1134
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1174
|
+
if (!options.json && process.stdout.isTTY) process.stdout.write('\n');
|
|
1175
|
+
log(` ✓ Uploaded ${uploaded} files`);
|
|
1097
1176
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
|
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
|
|
1111
|
-
|
|
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:
|
|
1115
|
-
orgId:
|
|
1205
|
+
cloudId: courseId,
|
|
1206
|
+
orgId: ensureData.orgId || orgId,
|
|
1116
1207
|
});
|
|
1117
1208
|
|
|
1118
|
-
// Step
|
|
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({
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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 (${
|
|
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: ${
|
|
1243
|
+
console.log(` Dashboard: ${dashboardUrl}`);
|
|
1138
1244
|
}
|
|
1139
1245
|
console.log('');
|
|
1246
|
+
}
|
|
1140
1247
|
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
}
|
package/lib/headless-browser.js
CHANGED
|
@@ -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
|
-
//
|
|
430
|
-
|
|
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
package/template/package.json
CHANGED
|
@@ -19,10 +19,10 @@
|
|
|
19
19
|
"marked-gfm-heading-id": "^4.1.3"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@vitejs/plugin-legacy": "
|
|
22
|
+
"@vitejs/plugin-legacy": "~7.2.1",
|
|
23
23
|
"archiver": "^7.0.1",
|
|
24
24
|
"eslint": "^9.39.1",
|
|
25
|
-
"vite": "
|
|
25
|
+
"vite": "~7.2.2",
|
|
26
26
|
"vite-plugin-static-copy": "^3.1.4"
|
|
27
27
|
}
|
|
28
28
|
}
|