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 +39 -4
- package/lib/cloud.js +281 -151
- package/lib/headless-browser.js +30 -2
- package/package.json +1 -1
- package/template/package.json +2 -2
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
|
}
|
|
@@ -941,7 +885,61 @@ export async function listCourses(options = {}) {
|
|
|
941
885
|
}
|
|
942
886
|
|
|
943
887
|
/**
|
|
944
|
-
*
|
|
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
|
-
//
|
|
1060
|
-
const
|
|
1061
|
-
|
|
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}]
|
|
1108
|
+
log(`\nDeploying ${slug}${displayOrg} [${modeLabel}] — ${distFiles.length} files, ${formatBytes(totalSize)}\n`);
|
|
1079
1109
|
|
|
1080
|
-
|
|
1081
|
-
const
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1184
|
+
if (!options.json && process.stdout.isTTY) process.stdout.write('\n');
|
|
1185
|
+
log(` ✓ Uploaded ${uploaded} files`);
|
|
1097
1186
|
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
|
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
|
|
1111
|
-
|
|
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:
|
|
1115
|
-
orgId:
|
|
1215
|
+
cloudId: courseId,
|
|
1216
|
+
orgId: ensureData.orgId || orgId,
|
|
1116
1217
|
});
|
|
1117
1218
|
|
|
1118
|
-
// Step
|
|
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({
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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 (${
|
|
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: ${
|
|
1253
|
+
console.log(` Dashboard: ${dashboardUrl}`);
|
|
1138
1254
|
}
|
|
1139
1255
|
console.log('');
|
|
1256
|
+
}
|
|
1140
1257
|
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
}
|
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
|
}
|