coursecode 0.1.8 → 0.1.10

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
@@ -20,6 +20,34 @@ import { fileURLToPath } from 'url';
20
20
  import path from 'path';
21
21
  import fs from 'fs';
22
22
 
23
+ // =============================================================================
24
+ // CORPORATE NETWORK: System CA cert injection
25
+ //
26
+ // On corporate machines with SSL-inspecting proxies (e.g. Zscaler), the proxy
27
+ // presents its own CA certificate. Node.js ships its own CA bundle and ignores
28
+ // the OS trust store, so TLS verification fails.
29
+ //
30
+ // Windows: win-ca injects certs directly into Node's TLS context (in-process,
31
+ // no subprocess, no re-exec). Works regardless of PowerShell policy.
32
+ // macOS/Linux: exports OS certs to a temp PEM file and re-execs with
33
+ // NODE_EXTRA_CA_CERTS. The guard prevents an infinite loop.
34
+ // =============================================================================
35
+
36
+ if (!process.env.NODE_EXTRA_CA_CERTS) {
37
+ const { injectSystemCerts } = await import('../lib/cloud-certs.js');
38
+ const certPath = await injectSystemCerts();
39
+ if (certPath) {
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), {
43
+ env: { ...process.env, NODE_EXTRA_CA_CERTS: certPath },
44
+ stdio: 'inherit',
45
+ });
46
+ process.exit(0);
47
+ }
48
+ // Windows: win-ca already injected certs in-process — continue normally
49
+ }
50
+
23
51
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
52
  const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
25
53
 
@@ -200,7 +228,6 @@ program
200
228
  .description('Send a test error to verify error reporting is configured correctly')
201
229
  .option('-t, --type <type>', 'Type of test: error or report', 'error')
202
230
  .option('-m, --message <message>', 'Custom message to include in the test')
203
- .option('--insecure', 'Skip TLS certificate verification (for corporate proxies)')
204
231
  .action(async (options) => {
205
232
  const { testErrorReporting } = await import('../lib/test-error-reporting.js');
206
233
  await testErrorReporting(options);
@@ -212,7 +239,6 @@ program
212
239
  .description('Send a test data record to verify data reporting is configured correctly')
213
240
  .option('-t, --type <type>', 'Type of record: assessment, objective, or interaction', 'assessment')
214
241
  .option('-m, --message <message>', 'Custom message to include in the test')
215
- .option('--insecure', 'Skip TLS certificate verification (for corporate proxies)')
216
242
  .action(async (options) => {
217
243
  const { testDataReporting } = await import('../lib/test-data-reporting.js');
218
244
  await testDataReporting(options);
@@ -280,10 +306,11 @@ program
280
306
  .command('login')
281
307
  .description('Log in to CourseCode Cloud')
282
308
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
309
+ .option('--json', 'Emit machine-readable JSON (for GUI/desktop integration)')
283
310
  .action(async (options) => {
284
311
  const { login, setLocalMode } = await import('../lib/cloud.js');
285
312
  if (options.local) setLocalMode();
286
- await login();
313
+ await login({ json: options.json });
287
314
  });
288
315
 
289
316
  program
@@ -136,6 +136,30 @@ em, i {
136
136
  color: var(--color-gray-800);
137
137
  }
138
138
 
139
+ /* Styled Scrollbars (cross-platform) */
140
+ html {
141
+ scrollbar-width: thin;
142
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
143
+ }
144
+
145
+ ::-webkit-scrollbar {
146
+ width: 8px;
147
+ height: 8px;
148
+ }
149
+
150
+ ::-webkit-scrollbar-track {
151
+ background: var(--scrollbar-track);
152
+ }
153
+
154
+ ::-webkit-scrollbar-thumb {
155
+ background: var(--scrollbar-thumb);
156
+ border-radius: 4px;
157
+ }
158
+
159
+ ::-webkit-scrollbar-thumb:hover {
160
+ background: var(--scrollbar-thumb-hover);
161
+ }
162
+
139
163
  /* Link Styles */
140
164
  a {
141
165
  color: var(--color-primary);
@@ -441,6 +441,11 @@
441
441
  --sidebar-scrollbar-thumb-hover: var(--color-gray-400); /* Sidebar scrollbar thumb (hover) */
442
442
  --sidebar-backdrop: var(--bg-overlay); /* Sidebar overlay backdrop color */
443
443
 
444
+ /* Scrollbar Tokens (global, content area) */
445
+ --scrollbar-thumb: var(--color-gray-300); /* Scrollbar thumb */
446
+ --scrollbar-thumb-hover: var(--color-gray-400); /* Scrollbar thumb (hover) */
447
+ --scrollbar-track: transparent; /* Scrollbar track background */
448
+
444
449
  /* Footer Tokens */
445
450
  --footer-bg: var(--bg-surface); /* Footer background */
446
451
  --footer-text: var(--text-primary); /* Footer text color */
@@ -678,6 +683,9 @@
678
683
  --sidebar-scrollbar-thumb: var(--color-gray-500);
679
684
  --sidebar-scrollbar-thumb-hover: var(--color-gray-400);
680
685
  --sidebar-backdrop: color-mix(in srgb, var(--palette-black) 38%, transparent);
686
+ --scrollbar-thumb: var(--color-gray-500);
687
+ --scrollbar-thumb-hover: var(--color-gray-400);
688
+ --scrollbar-track: transparent;
681
689
  --footer-bg: var(--bg-surface);
682
690
  --footer-text: var(--text-primary);
683
691
  --footer-border: var(--border-default);
@@ -660,7 +660,7 @@ createLikertQuestion({
660
660
 
661
661
  ### Custom Interactions
662
662
 
663
- Add a `.js` file to `course/interactions/`. File `rating-scale.js` → factory `CourseCode.createRatingScaleQuestion()`. See `course/interactions/PLUGIN_GUIDE.md`.
663
+ Add a `.js` file to `course/interactions/`. File `rating-scale.js` → factory `CourseCode.createRatingScaleQuestion()`. See "Extending with Plugins" in `framework/docs/USER_GUIDE.md`.
664
664
 
665
665
  ### Interaction Methods
666
666
 
@@ -1122,7 +1122,7 @@ Exposed globally via `CourseCode.breakpointManager`.
1122
1122
 
1123
1123
  ## Adding New Interaction Types
1124
1124
 
1125
- > **Course Authors:** See `course/interactions/PLUGIN_GUIDE.md`. Steps below are for framework developers.
1125
+ > **Course Authors:** See "Extending with Plugins" in `framework/docs/USER_GUIDE.md`. Steps below are for framework developers.
1126
1126
 
1127
1127
  1. Create file in `framework/js/components/interactions/`
1128
1128
  2. Export `create(container, config)`, `metadata`, and `schema`
@@ -43,7 +43,12 @@ A complete guide to creating interactive e-learning courses with AI assistance.
43
43
  - [Learning Objectives](#learning-objectives)
44
44
  - [Course Completion Feedback](#course-completion-feedback)
45
45
  - [Updating Live Courses Safely](#updating-live-courses-safely)
46
- 8. [Sharing and Deploying](#sharing-and-deploying)
46
+ 8. [Extending with Plugins](#extending-with-plugins)
47
+ - [Custom Interactions](#custom-interactions)
48
+ - [Custom UI Components](#custom-ui-components)
49
+ - [Custom Icons](#custom-icons)
50
+ - [Custom Styles](#custom-styles)
51
+ 9. [Sharing and Deploying](#sharing-and-deploying)
47
52
  - [Sharing Previews](#sharing-previews)
48
53
  - [Preview Export Options](#preview-export-options)
49
54
  - [Understanding LMS Formats](#understanding-lms-formats)
@@ -51,8 +56,8 @@ A complete guide to creating interactive e-learning courses with AI assistance.
51
56
  - [CDN Deployment (Advanced)](#cdn-deployment-advanced)
52
57
  - [Cloud Deployment](#cloud-deployment)
53
58
  - [Exporting Content for Review](#exporting-content-for-review)
54
- 9. [Generating Audio Narration](#generating-audio-narration)
55
- 10. [Troubleshooting](#troubleshooting)
59
+ 10. [Generating Audio Narration](#generating-audio-narration)
60
+ 11. [Troubleshooting](#troubleshooting)
56
61
 
57
62
  ---
58
63
 
@@ -514,6 +519,93 @@ Best practice: set and increment `metadata.version` in `course/course-config.js`
514
519
 
515
520
  ---
516
521
 
522
+ ## Extending with Plugins
523
+
524
+ CourseCode has a built-in plugin system. You can extend it with your own interaction types, UI components, icons, and styles — all auto-discovered from your `course/` folder without any framework changes.
525
+
526
+ | Extension Point | Where to Put It | What It Adds |
527
+ |-----------------|-----------------|-------------|
528
+ | Custom interactions | `course/interactions/*.js` | New question/activity types |
529
+ | Custom UI components | `course/components/*.js` | New reusable HTML components |
530
+ | Custom icons | `course/icons.js` | New icons available everywhere |
531
+ | Custom styles | `course/theme.css` | Global CSS for your plugins and brand |
532
+
533
+ Plugins are just JavaScript files that follow a simple contract. Your AI assistant can write them — describe what you want and share `framework/docs/USER_GUIDE.md` (see "Extending with Plugins") as context.
534
+
535
+ ### Custom Interactions
536
+
537
+ Create a new question or activity type by dropping a `.js` file in `course/interactions/`. It registers automatically.
538
+
539
+ A minimal plugin exports one function:
540
+
541
+ ```javascript
542
+ // course/interactions/rating-scale.js
543
+ export function create(container, config) {
544
+ let response = null;
545
+ container.innerHTML = `<div data-interaction-id="${config.id}">...</div>`;
546
+ return {
547
+ getResponse: () => response,
548
+ setResponse: (val) => { response = val; },
549
+ checkAnswer: () => ({ correct: response === config.correctAnswer, score: 1 }),
550
+ reset: () => { response = null; }
551
+ };
552
+ }
553
+ ```
554
+
555
+ Then use it in a slide:
556
+
557
+ ```javascript
558
+ const rating = CourseCode.createRatingScaleQuestion(container, {
559
+ id: 'my-rating',
560
+ prompt: 'How would you rate this?',
561
+ options: ['Poor', 'Fair', 'Good', 'Excellent']
562
+ });
563
+ ```
564
+
565
+ The factory name is derived from the filename: `rating-scale.js` → `createRatingScaleQuestion`.
566
+
567
+ For a complete example with schema and metadata (which enable linting and AI tooling), see the "Extending with Plugins" section in `framework/docs/USER_GUIDE.md`.
568
+
569
+ ### Custom UI Components
570
+
571
+ Add reusable HTML components (info boxes, custom cards, branded banners) by dropping a `.js` file in `course/components/`. Use them in slides via `data-component`:
572
+
573
+ ```html
574
+ <div data-component="info-box" data-icon="warning">
575
+ Important note here
576
+ </div>
577
+ ```
578
+
579
+ See the "Extending with Plugins" section in `framework/docs/USER_GUIDE.md` for the component contract.
580
+
581
+ ### Custom Icons
582
+
583
+ Add icons to `course/icons.js` and they're available throughout the course:
584
+
585
+ ```javascript
586
+ // course/icons.js
587
+ export const customIcons = {
588
+ 'rocket': '<path d="M12 2L8 8H4l8 14 8-14h-4L12 2z" />'
589
+ };
590
+ ```
591
+
592
+ ### Custom Styles
593
+
594
+ `course/theme.css` is always loaded. It's the right place for plugin-specific CSS as well as brand colors and fonts:
595
+
596
+ ```css
597
+ /* course/theme.css */
598
+ :root {
599
+ --primary: #0066cc;
600
+ }
601
+
602
+ .info-box { border-left: 4px solid var(--primary); padding: 1rem; }
603
+ ```
604
+
605
+ Use CSS variables from the design system (`--primary`, `--border`, `--radius`, etc.) so your plugins automatically respect the course theme.
606
+
607
+ ---
608
+
517
609
  ## Sharing and Deploying
518
610
 
519
611
  ### Sharing Previews
@@ -614,6 +706,23 @@ coursecode deploy # Build + upload to cloud
614
706
  Cloud-served launches also auto-configure runtime error reporting, data reporting, and channel relay endpoints (zero-config cloud wiring).
615
707
  If you configured manual endpoints in `course-config.js` for self-hosted workflows, Cloud launches override them with cloud-injected runtime config.
616
708
 
709
+ **Signing in (`coursecode login`):**
710
+
711
+ Running `coursecode login` displays a URL and a short code in your terminal:
712
+
713
+ ```
714
+ ┌─────────────────────────────────────────────────────┐
715
+ │ Open this URL in your browser: │
716
+ │ https://coursecodecloud.com/activate │
717
+ │ │
718
+ │ Enter your code: ABCD-1234 │
719
+ │ │
720
+ │ Expires in 15 minutes │
721
+ └─────────────────────────────────────────────────────┘
722
+ ```
723
+
724
+ Open the URL in any browser, log in with your CourseCode account, and enter the code. The terminal confirms login automatically — no redirect back required. The code is valid for 15 minutes and works from any device or browser.
725
+
617
726
  **What CourseCode Cloud helps with (plain English):**
618
727
  - Host your course online so learners/reviewers can access it without you manually hosting files
619
728
  - Generate the LMS package you need later (SCORM/cmi5) from the same upload
@@ -621,10 +730,10 @@ If you configured manual endpoints in `course-config.js` for self-hosted workflo
621
730
  - Manage deployment updates without rebuilding separate packages for each LMS format
622
731
  - Provide cloud-managed runtime services (reporting/channel) without extra endpoint setup in your course files
623
732
 
624
- **Typical Cloud workflow (CLI):**
625
- 1. Run `coursecode login` once and sign in.
733
+ **Typical Cloud workflow:**
734
+ 1. Run `coursecode login` once, open the URL shown, and enter the code.
626
735
  2. Run `coursecode deploy` from your project folder.
627
- 3. Open the CourseCode Cloud course page/dashboard link shown after deploy.
736
+ 3. Open the CourseCode Cloud dashboard link shown after deploy.
628
737
  4. Use Cloud preview links for review.
629
738
  5. Download the LMS format you need from Cloud when you're ready to deliver.
630
739
 
@@ -0,0 +1,130 @@
1
+ /**
2
+ * cloud-certs.js — System CA certificate injection for corporate network compatibility.
3
+ *
4
+ * On corporate machines with SSL-inspecting proxies (e.g. Zscaler), the proxy
5
+ * presents its own CA certificate. Node.js ships its own CA bundle and ignores
6
+ * the OS trust store, causing TLS verification failures.
7
+ *
8
+ * Platform strategy:
9
+ * - Windows: `win-ca` (native N-API addon, calls Windows CryptoAPI directly)
10
+ * - macOS: `security` CLI exports system keychains to PEM
11
+ * - Linux: reads well-known CA bundle file paths
12
+ *
13
+ * On macOS/Linux, returns a PEM file path for NODE_EXTRA_CA_CERTS.
14
+ * On Windows, injects certs directly into Node's TLS context (no file needed).
15
+ * Never throws — silent no-op on non-corporate machines.
16
+ */
17
+
18
+ import { execFile } from 'child_process';
19
+ import { promisify } from 'util';
20
+ import { createRequire } from 'module';
21
+ import fs from 'fs';
22
+ import os from 'os';
23
+ import path from 'path';
24
+ import crypto from 'crypto';
25
+
26
+ const execFileAsync = promisify(execFile);
27
+
28
+ /** Cached result — only run once per process lifetime. */
29
+ let _applied = false;
30
+
31
+ /**
32
+ * Inject the OS system root certificates into Node's TLS context.
33
+ *
34
+ * On Windows, win-ca patches Node's TLS in-process (no file needed, no re-exec).
35
+ * On macOS/Linux, returns a PEM file path for NODE_EXTRA_CA_CERTS re-exec.
36
+ *
37
+ * @returns {Promise<string|null>} PEM file path (macOS/Linux) or null (Windows/unavailable).
38
+ */
39
+ export async function injectSystemCerts() {
40
+ if (_applied) return null;
41
+ _applied = true;
42
+
43
+ try {
44
+ if (process.platform === 'win32') {
45
+ injectWindowsCerts();
46
+ return null;
47
+ }
48
+
49
+ // macOS/Linux: export to PEM file for NODE_EXTRA_CA_CERTS
50
+ const pem = process.platform === 'darwin'
51
+ ? await readMacosCerts()
52
+ : readLinuxCerts();
53
+
54
+ if (!pem || !pem.trim()) return null;
55
+
56
+ const hash = crypto.createHash('sha1').update(pem).digest('hex').slice(0, 8);
57
+ const certPath = path.join(os.tmpdir(), `coursecode-ca-${hash}.pem`);
58
+
59
+ if (!fs.existsSync(certPath)) {
60
+ fs.writeFileSync(certPath, pem, { mode: 0o600 });
61
+ }
62
+
63
+ return certPath;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Windows: inject system root certs via win-ca.
71
+ *
72
+ * win-ca is a native N-API addon that calls Windows CryptoAPI directly.
73
+ * No PowerShell, no subprocesses, no temp files. Works regardless of
74
+ * execution policy, AppLocker, or PowerShell availability.
75
+ *
76
+ * The { inject: '+' } mode patches tls.createSecureContext() so system
77
+ * certs are used *in addition to* Node's built-in CA bundle.
78
+ */
79
+ function injectWindowsCerts() {
80
+ const require = createRequire(import.meta.url);
81
+ const winCa = require('win-ca/api');
82
+ winCa({ inject: '+' });
83
+ }
84
+
85
+ /**
86
+ * macOS: export the system root keychain via the `security` CLI tool.
87
+ * This includes all roots installed via Apple MDM / System Preferences.
88
+ */
89
+ async function readMacosCerts() {
90
+ const keychains = [
91
+ '/Library/Keychains/SystemRootCertificates.keychain',
92
+ '/System/Library/Keychains/SystemRootCertificates.keychain',
93
+ '/Library/Keychains/System.keychain',
94
+ ];
95
+
96
+ const pems = [];
97
+ for (const keychain of keychains) {
98
+ try {
99
+ const { stdout } = await execFileAsync('security', [
100
+ 'find-certificate', '-a', '-p', keychain,
101
+ ], { maxBuffer: 16 * 1024 * 1024 });
102
+ if (stdout) pems.push(stdout);
103
+ } catch {
104
+ // Keychain not present on this OS version — skip
105
+ }
106
+ }
107
+
108
+ return pems.join('\n');
109
+ }
110
+
111
+ /**
112
+ * Linux: read the system CA bundle from well-known locations.
113
+ * No subprocess needed — just read the file directly.
114
+ */
115
+ function readLinuxCerts() {
116
+ const candidatePaths = [
117
+ '/etc/ssl/certs/ca-certificates.crt', // Debian/Ubuntu
118
+ '/etc/pki/tls/certs/ca-bundle.crt', // RHEL/CentOS/Fedora
119
+ '/etc/ssl/ca-bundle.pem', // OpenSUSE
120
+ '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem', // RHEL 7+
121
+ ];
122
+
123
+ for (const p of candidatePaths) {
124
+ if (fs.existsSync(p)) {
125
+ return fs.readFileSync(p, 'utf-8');
126
+ }
127
+ }
128
+
129
+ return null;
130
+ }
package/lib/cloud.js CHANGED
@@ -23,6 +23,10 @@ const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'packa
23
23
  // =============================================================================
24
24
 
25
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';
26
30
  const LOCAL_CLOUD_URL = 'http://localhost:3000';
27
31
  let useLocal = false;
28
32
  const CREDENTIALS_DIR = path.join(os.homedir(), '.coursecode');
@@ -31,8 +35,9 @@ const PROJECT_CONFIG_DIR = '.coursecode';
31
35
  const PROJECT_CONFIG_PATH = path.join(PROJECT_CONFIG_DIR, 'project.json');
32
36
 
33
37
  const POLL_INTERVAL_MS = 2000;
34
- const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
38
+ const POLL_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes (device code expiry)
35
39
  const USER_AGENT = `coursecode-cli/${packageJson.version}`;
40
+ const ACTIVATION_PATH = '/activate';
36
41
 
37
42
  // =============================================================================
38
43
  // SLUG UTILITIES
@@ -163,38 +168,123 @@ function writeRcCloudId(cloudId) {
163
168
  * Make an authenticated request to the Cloud API.
164
169
  * Handles User-Agent, Bearer token, and error formatting per §7.
165
170
  *
171
+ * Automatically retries against FALLBACK_CLOUD_URL when the primary URL
172
+ * returns an HTML block page (corporate web filter / Zscaler URL categorization).
173
+ *
166
174
  * @param {string} urlPath - API path (e.g. '/api/cli/whoami')
167
175
  * @param {object} options - fetch options (method, body, headers, etc.)
168
176
  * @param {string} [token] - Override token (for unauthenticated requests)
169
- * @returns {Promise<Response>}
177
+ * @returns {Promise<Response>} A Response whose body has been replaced with
178
+ * the raw text so handleResponse can always call res.text() safely.
170
179
  */
171
180
  async function cloudFetch(urlPath, options = {}, token = null) {
172
- const cloudUrl = getCloudUrl();
173
- const url = `${cloudUrl}${urlPath}`;
174
-
175
181
  const headers = {
176
182
  'User-Agent': USER_AGENT,
177
183
  ...options.headers,
178
184
  };
185
+ if (token) headers['Authorization'] = `Bearer ${token}`;
186
+
187
+ const attemptFetch = async (baseUrl) => {
188
+ const url = `${baseUrl}${urlPath}`;
189
+ try {
190
+ return await fetch(url, { ...options, headers });
191
+ } catch {
192
+ return null; // Connection failed
193
+ }
194
+ };
179
195
 
180
- if (token) {
181
- headers['Authorization'] = `Bearer ${token}`;
182
- }
196
+ const primaryUrl = getCloudUrl();
197
+ const res = await attemptFetch(primaryUrl);
183
198
 
184
- try {
185
- return await fetch(url, { ...options, headers });
186
- } catch (_error) {
199
+ if (!res) {
200
+ // Primary unreachable try fallback before giving up
201
+ if (!useLocal) {
202
+ const fallback = await attemptFetch(FALLBACK_CLOUD_URL);
203
+ if (fallback) return fallback;
204
+ }
187
205
  console.error('\n❌ Could not connect to CourseCode Cloud. Check your internet connection.\n');
188
206
  process.exit(1);
189
207
  }
208
+
209
+ // Peek at the body: if it's an HTML block page, silently retry on the fallback.
210
+ // We must buffer the text here since Response bodies can only be read once.
211
+ const text = await res.text();
212
+ if (isBlockPage(text) && !useLocal) {
213
+ const fallbackRes = await attemptFetch(FALLBACK_CLOUD_URL);
214
+ if (fallbackRes) {
215
+ const fallbackText = await fallbackRes.text();
216
+ if (!isBlockPage(fallbackText)) {
217
+ // Fallback succeeded — return a synthetic Response with the buffered text
218
+ return syntheticResponse(fallbackText, fallbackRes.status);
219
+ }
220
+ }
221
+ // Both primary and fallback are blocked — surface the error
222
+ reportBlockPage(text, res);
223
+ process.exit(1);
224
+ }
225
+
226
+ // Primary response is fine — return a synthetic Response with the buffered text
227
+ return syntheticResponse(text, res.status);
228
+ }
229
+
230
+ /** Quick check whether a response body looks like an HTML block page. */
231
+ function isBlockPage(text) {
232
+ const lower = text.toLowerCase();
233
+ return lower.includes('<!doctype') || lower.startsWith('<html');
234
+ }
235
+
236
+ /**
237
+ * Create a minimal synthetic Response that wraps already-buffered text.
238
+ * handleResponse always calls res.text() — this keeps the interface uniform.
239
+ */
240
+ function syntheticResponse(text, status) {
241
+ return {
242
+ ok: status >= 200 && status < 300,
243
+ status,
244
+ text: () => Promise.resolve(text),
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Print a vendor-specific block page error message.
250
+ *
251
+ * @param {string} body - Raw response body text
252
+ * @param {Response} res - The fetch Response object
253
+ */
254
+ function reportBlockPage(body, res) {
255
+ const lower = body.toLowerCase();
256
+ if (lower.includes('zscaler')) {
257
+ console.error('\n❌ coursecodecloud.com is blocked by Zscaler on your network.');
258
+ } else if (lower.includes('forcepoint') || lower.includes('websense')) {
259
+ console.error('\n❌ coursecodecloud.com is blocked by Forcepoint on your network.');
260
+ } else if (lower.includes('barracuda')) {
261
+ console.error('\n❌ coursecodecloud.com is blocked by Barracuda on your network.');
262
+ } else {
263
+ console.error(`\n❌ Your network blocked coursecodecloud.com (HTTP ${res.status}).`);
264
+ }
265
+ console.error(' Ask your IT team to whitelist: coursecodecloud.com\n');
190
266
  }
191
267
 
192
268
  /**
193
269
  * Handle HTTP error responses per §7.
194
270
  * Returns the parsed JSON body, or exits on error.
271
+ *
272
+ * Reads the body as text first so we can detect non-JSON responses. By the
273
+ * time this is called, cloudFetch has already handled block page detection
274
+ * and fallback retry — so text here should always be valid JSON.
195
275
  */
196
276
  async function handleResponse(res, { retryFn, _isRetry = false } = {}) {
197
- if (res.ok) return res.json();
277
+ const text = await res.text();
278
+ let body;
279
+ try {
280
+ body = JSON.parse(text);
281
+ } catch {
282
+ // Should not happen after cloudFetch filtering — treat as server error
283
+ console.error(`\n❌ Unexpected response from Cloud (HTTP ${res.status}). Try again later.\n`);
284
+ process.exit(1);
285
+ }
286
+
287
+ if (res.ok) return body;
198
288
 
199
289
  const status = res.status;
200
290
 
@@ -206,10 +296,15 @@ async function handleResponse(res, { retryFn, _isRetry = false } = {}) {
206
296
  return retryFn(true);
207
297
  }
208
298
 
209
- // Parse error body
210
- let body;
211
- try { body = await res.json(); } catch { body = {}; }
212
- const message = body.error || `HTTP ${status}`;
299
+ return handleResponseError(status, body);
300
+ }
301
+
302
+ /**
303
+ * Handle a known HTTP error status code with a parsed body.
304
+ * Exits the process with an appropriate message.
305
+ */
306
+ function handleResponseError(status, body) {
307
+ const message = body?.error || `HTTP ${status}`;
213
308
 
214
309
  if (status === 403 || status === 409) {
215
310
  console.error(`\n❌ ${message}\n`);
@@ -260,18 +355,130 @@ function sleep(ms) {
260
355
  }
261
356
 
262
357
  /**
263
- * Run the nonce exchange login flow.
264
- * 1. Generate nonce
265
- * 2. POST /api/auth/connect to create session
266
- * 3. Open browser
267
- * 4. Poll until token received or timeout
268
- * 5. Store credentials
358
+ * Run the device code login flow (primary).
359
+ *
360
+ * Flow:
361
+ * 1. POST /api/auth/device → get { deviceCode, userCode, verificationUri, expiresIn, interval }
362
+ * 2. Display userCode + activation URLs prominently
363
+ * 3. Open browser as a convenience (user can ignore if ZBI isolates it)
364
+ * 4. Poll GET /api/auth/device?code={deviceCode} until token or expiry
365
+ * 5. Store credentials
366
+ *
367
+ * Resilient to Zscaler Browser Isolation: the browser session is fully decoupled
368
+ * from the CLI. The user can open the activation URL in any browser, on any device.
369
+ *
370
+ * Falls back to the legacy nonce flow if the cloud returns 404 (not yet deployed).
371
+ */
372
+ async function runLoginFlow({ jsonMode = false } = {}) {
373
+ // Helper: emit a JSON event line (JSON mode) or nothing (normal mode)
374
+ const emit = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
375
+ const log = (...args) => { if (!jsonMode) console.log(...args); };
376
+
377
+ // Step 1: Request device code
378
+ const deviceRes = await cloudFetch('/api/auth/device', {
379
+ method: 'POST',
380
+ headers: { 'Content-Type': 'application/json' },
381
+ });
382
+
383
+ // Graceful fallback: cloud not yet updated to support device flow
384
+ if (deviceRes.status === 404) {
385
+ return runLegacyLoginFlow();
386
+ }
387
+
388
+ if (!deviceRes.ok) {
389
+ let body = {};
390
+ try { body = JSON.parse(await deviceRes.text()); } catch { /* ignore */ }
391
+ const msg = body.error || `HTTP ${deviceRes.status}`;
392
+ if (jsonMode) { emit({ type: 'error', error: msg }); } else { console.error(`\n❌ Failed to start login: ${msg}\n`); }
393
+ process.exit(1);
394
+ }
395
+
396
+ let devicePayload;
397
+ try {
398
+ devicePayload = JSON.parse(await deviceRes.text());
399
+ } catch {
400
+ console.error('\n❌ Unexpected response from Cloud during login. Try again.\n');
401
+ process.exit(1);
402
+ }
403
+ const { deviceCode, userCode, verificationUri, expiresIn, interval } = devicePayload;
404
+
405
+ const pollIntervalMs = (interval || 5) * 1000;
406
+ const expiryMs = (expiresIn || 900) * 1000;
407
+
408
+ // Derive the activation URL from the server response or fall back to the primary domain
409
+ const primaryActivationUrl = verificationUri || `${getCloudUrl()}${ACTIVATION_PATH}`;
410
+
411
+ if (jsonMode) {
412
+ // Emit structured event for GUI to display its own device code UI
413
+ emit({
414
+ type: 'device_code',
415
+ userCode,
416
+ verificationUri: primaryActivationUrl,
417
+ deviceCode,
418
+ expiresIn: expiresIn || 900,
419
+ interval: interval || 5,
420
+ });
421
+ } else {
422
+ // Step 2: Display code prominently
423
+ const line = '─'.repeat(51);
424
+ log(`\n ┌${line}┐`);
425
+ log(' │ Open this URL in your browser: │');
426
+ log(` │ ${primaryActivationUrl.padEnd(49)}│`);
427
+ log(' │ │');
428
+ log(` │ Enter your code: ${userCode.padEnd(31)}│`);
429
+ log(' │ │');
430
+ const expiryMins = Math.round(expiryMs / 60000);
431
+ log(` │ Expires in ${String(expiryMins + ' minutes').padEnd(37)}│`);
432
+ log(` └${line}┘\n`);
433
+ }
434
+
435
+ // Step 3: Poll for token
436
+ log(' Waiting for authorization...');
437
+ const startTime = Date.now();
438
+ while (Date.now() - startTime < expiryMs) {
439
+ await sleep(pollIntervalMs);
440
+
441
+ const pollRes = await cloudFetch(`/api/auth/device?code=${encodeURIComponent(deviceCode)}`);
442
+
443
+ if (pollRes.status === 410) {
444
+ const msg = 'Login code expired. Run `coursecode login` to try again.';
445
+ if (jsonMode) { emit({ type: 'error', error: 'expired' }); } else { console.error(`\n❌ ${msg}\n`); }
446
+ process.exit(1);
447
+ }
448
+
449
+ if (pollRes.status === 400) {
450
+ let body = {};
451
+ try { body = JSON.parse(await pollRes.text()); } catch { /* ignore */ }
452
+ if (jsonMode) { emit({ type: 'error', error: body.error || 'denied' }); } else { console.error(`\n❌ Login ${body.error || 'failed'}. Run \`coursecode login\` to try again.\n`); }
453
+ process.exit(1);
454
+ }
455
+
456
+ if (!pollRes.ok) continue;
457
+
458
+ const data = JSON.parse(await pollRes.text());
459
+ if (data.pending) continue;
460
+
461
+ if (data.token) {
462
+ writeCredentials(data.token, getCloudUrl());
463
+ log(' ✓ Logged in successfully\n');
464
+ return data.token;
465
+ }
466
+ }
467
+
468
+ const timeoutMsg = 'Login timed out. Run `coursecode login` to try again.';
469
+ if (jsonMode) { emit({ type: 'error', error: 'timeout' }); } else { console.error(`\n❌ ${timeoutMsg}\n`); }
470
+ process.exit(1);
471
+ }
472
+
473
+ /**
474
+ * Legacy nonce-exchange login flow.
475
+ * Used as a fallback when the cloud has not yet deployed the device code endpoint.
476
+ * Can be removed once the device code flow is fully rolled out.
269
477
  */
270
- async function runLoginFlow() {
478
+ async function runLegacyLoginFlow() {
271
479
  const nonce = crypto.randomBytes(32).toString('hex');
272
480
  const cloudUrl = getCloudUrl();
273
481
 
274
- // Step 1: Create CLI session
275
482
  console.log(' → Registering session...');
276
483
  const createRes = await cloudFetch('/api/auth/connect', {
277
484
  method: 'POST',
@@ -280,17 +487,16 @@ async function runLoginFlow() {
280
487
  });
281
488
 
282
489
  if (!createRes.ok) {
283
- const body = await createRes.json().catch(() => ({}));
490
+ let body = {};
491
+ try { body = JSON.parse(await createRes.text()); } catch { /* ignore */ }
284
492
  console.error(`\n❌ Failed to start login: ${body.error || `HTTP ${createRes.status}`}\n`);
285
493
  process.exit(1);
286
494
  }
287
495
 
288
- // Step 2: Open browser
289
496
  const loginUrl = `${cloudUrl}/auth/connect?session=${nonce}`;
290
497
  console.log(' → Opening browser for authentication...');
291
498
  openBrowser(loginUrl);
292
499
 
293
- // Step 3: Poll for token
294
500
  const startTime = Date.now();
295
501
  while (Date.now() - startTime < POLL_TIMEOUT_MS) {
296
502
  await sleep(POLL_INTERVAL_MS);
@@ -304,7 +510,7 @@ async function runLoginFlow() {
304
510
 
305
511
  if (!pollRes.ok) continue;
306
512
 
307
- const data = await pollRes.json();
513
+ const data = JSON.parse(await pollRes.text());
308
514
  if (data.pending) continue;
309
515
 
310
516
  if (data.token) {
@@ -446,21 +652,26 @@ function formatDate(isoString) {
446
652
  /**
447
653
  * coursecode login — explicit (re-)authentication
448
654
  */
449
- export async function login() {
450
- console.log('\n🔑 Logging in to CourseCode Cloud...\n');
451
- await runLoginFlow();
655
+ export async function login(options = {}) {
656
+ const jsonMode = Boolean(options.json);
657
+ if (!jsonMode) console.log('\n🔑 Logging in to CourseCode Cloud...\n');
658
+ await runLoginFlow({ jsonMode });
452
659
 
453
660
  // Show who they are
454
661
  const token = readCredentials()?.token;
455
662
  if (token) {
456
663
  const res = await cloudFetch('/api/cli/whoami', {}, token);
457
664
  if (res.ok) {
458
- const data = await res.json();
459
- console.log(` ✓ Logged in as ${data.full_name} (${data.email})\n`);
665
+ const data = JSON.parse(await res.text());
666
+ if (jsonMode) {
667
+ process.stdout.write(JSON.stringify({ type: 'success', email: data.email, name: data.full_name }) + '\n');
668
+ } else {
669
+ console.log(` ✓ Logged in as ${data.full_name} (${data.email})\n`);
670
+ }
460
671
  return;
461
672
  }
462
673
  }
463
- console.log('');
674
+ if (!jsonMode) console.log('');
464
675
  }
465
676
 
466
677
  /**
@@ -682,21 +893,18 @@ export async function status() {
682
893
  // =============================================================================
683
894
 
684
895
  /**
685
- * Zip a directory's contents using the system `zip` command.
686
- * Falls back to a tar+gzip approach if zip isn't available.
896
+ * Zip a directory's contents using archiver (cross-platform, no native tools needed).
687
897
  */
688
- function zipDirectory(sourceDir, outputPath) {
898
+ async function zipDirectory(sourceDir, outputPath) {
899
+ const archiver = (await import('archiver')).default;
689
900
  return new Promise((resolve, reject) => {
690
- // Use system zip: cd into dir so paths are relative
691
- exec(
692
- `cd "${sourceDir}" && zip -r -q "${outputPath}" .`,
693
- (error) => {
694
- if (error) {
695
- reject(new Error(`Failed to create zip: ${error.message}. Ensure 'zip' is installed.`));
696
- } else {
697
- resolve();
698
- }
699
- }
700
- );
901
+ const output = fs.createWriteStream(outputPath);
902
+ const archive = archiver('zip', { zlib: { level: 9 } });
903
+
904
+ output.on('close', () => resolve());
905
+ archive.on('error', reject);
906
+ archive.pipe(output);
907
+ archive.directory(sourceDir, false);
908
+ archive.finalize();
701
909
  });
702
910
  }
@@ -715,7 +715,7 @@ All runtime tools (state, navigate, interact, screenshot, viewport, reset) execu
715
715
  ### Customization (all in course/, never in framework/)
716
716
  - **CSS overrides**: Edit \`course/theme.css\` — override palette tokens to rebrand (all colors cascade via color-mix). Use framework utility classes first (\`coursecode_css_catalog\`), \`theme.css\` only for brand-specific overrides.
717
717
  - **Custom components**: Add \`.js\` files to \`course/components/\` — auto-discovered at build time. Use \`coursecode_component_catalog\` for built-in options first.
718
- - **Custom interactions**: Add \`.js\` files to \`course/interactions/\` — auto-discovered. See \`course/interactions/PLUGIN_GUIDE.md\` for the template.
718
+ - **Custom interactions**: Add \`.js\` files to \`course/interactions/\` — auto-discovered. See "Extending with Plugins" in \`framework/docs/USER_GUIDE.md\` for the contract.
719
719
  - **Custom icons**: Add SVG definitions to \`course/icons.js\` — merged with built-in icons. Use icon_catalog to check existing icons first.`;
720
720
  }
721
721
 
@@ -15,11 +15,6 @@ import { pathToFileURL } from 'url';
15
15
  * @param {string} options.message - Custom message to include
16
16
  */
17
17
  export async function testDataReporting(options = {}) {
18
- // Handle TLS certificate issues (corporate proxies)
19
- if (options.insecure) {
20
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
21
- }
22
-
23
18
  const cwd = process.cwd();
24
19
  const configPath = path.join(cwd, 'course', 'course-config.js');
25
20
 
@@ -15,11 +15,6 @@ import { pathToFileURL } from 'url';
15
15
  * @param {string} options.message - Custom message to include
16
16
  */
17
17
  export async function testErrorReporting(options = {}) {
18
- // Handle TLS certificate issues (corporate proxies)
19
- if (options.insecure) {
20
- process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
21
- }
22
-
23
18
  const cwd = process.cwd();
24
19
  const configPath = path.join(cwd, 'course', 'course-config.js');
25
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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": {
@@ -99,6 +99,7 @@
99
99
  },
100
100
  "dependencies": {
101
101
  "@modelcontextprotocol/sdk": "^1.0.0",
102
+ "archiver": "^7.0.1",
102
103
  "commander": "^14.0.3",
103
104
  "lz-string": "^1.5.0",
104
105
  "mammoth": "^1.8.0",
@@ -108,6 +109,7 @@
108
109
  "pdf2json": "^4.0.2",
109
110
  "pdf2md": "^1.0.2",
110
111
  "puppeteer-core": "^24.37.2",
112
+ "win-ca": "^3.5.1",
111
113
  "ws": "^8.18.0"
112
114
  },
113
115
  "devDependencies": {
@@ -115,7 +117,6 @@
115
117
  "@vitest/coverage-v8": "^4.0.18",
116
118
  "@xapi/cmi5": "^1.4.0",
117
119
  "acorn": "^8.15.0",
118
- "archiver": "^7.0.1",
119
120
  "eslint": "^10.0.0",
120
121
  "globals": "^17.3.0",
121
122
  "jose": "^6.1.3",
@@ -0,0 +1,5 @@
1
+ # Custom Components
2
+
3
+ Drop `.js` files here to add new UI components. They are auto-discovered and registered at build time.
4
+
5
+ See **"Extending with Plugins"** in `framework/docs/USER_GUIDE.md` for the plugin contract and examples.
@@ -0,0 +1,5 @@
1
+ # Custom Interactions
2
+
3
+ Drop `.js` files here to add new interaction types. They are auto-discovered and registered at build time.
4
+
5
+ See **"Extending with Plugins"** in `framework/docs/USER_GUIDE.md` for the plugin contract and examples.
@@ -1,97 +0,0 @@
1
- # Custom Interactions
2
-
3
- Drop a `.js` file here to auto-register a custom interaction type.
4
-
5
- ## Quick Start
6
-
7
- **File:** `rating-scale.js` → **Factory:** `CourseCode.createRatingScaleQuestion()`
8
-
9
- ```javascript
10
- // Optional: Schema for linting/AI assistance
11
- export const schema = {
12
- type: 'rating-scale',
13
- properties: {
14
- options: { type: 'array', required: true, description: 'Rating options' },
15
- correctAnswer: { type: 'string', description: 'Correct option index' }
16
- }
17
- };
18
-
19
- // Optional: Metadata for UI tools
20
- export const metadata = {
21
- label: 'Rating Scale',
22
- category: 'interactive',
23
- scormType: 'choice'
24
- };
25
-
26
- // Required: Creator function
27
- export function create(container, config) {
28
- let response = null;
29
-
30
- // Inject styles once
31
- if (!document.getElementById('rating-scale-styles')) {
32
- const el = document.createElement('style');
33
- el.id = 'rating-scale-styles';
34
- el.textContent = `
35
- .rating-scale { display: flex; gap: 0.5rem; }
36
- .rating-scale-option { cursor: pointer; padding: 0.5rem 1rem; border: 1px solid var(--border); border-radius: var(--radius); }
37
- .rating-scale-option.selected { background: var(--primary); color: white; }
38
- `;
39
- document.head.appendChild(el);
40
- }
41
-
42
- container.innerHTML = `
43
- <div class="interaction" data-interaction-id="${config.id}">
44
- <p class="prompt">${config.prompt}</p>
45
- <div class="rating-scale">
46
- ${config.options.map((opt, i) =>
47
- `<button type="button" class="rating-scale-option" data-value="${i}">${opt}</button>`
48
- ).join('')}
49
- </div>
50
- </div>
51
- `;
52
-
53
- container.querySelectorAll('.rating-scale-option').forEach(btn => {
54
- btn.addEventListener('click', () => {
55
- container.querySelectorAll('.rating-scale-option').forEach(b => b.classList.remove('selected'));
56
- btn.classList.add('selected');
57
- response = btn.dataset.value;
58
- });
59
- });
60
-
61
- return {
62
- getResponse: () => response,
63
- setResponse: (val) => {
64
- response = val;
65
- container.querySelectorAll('.rating-scale-option').forEach(btn => {
66
- btn.classList.toggle('selected', btn.dataset.value === String(val));
67
- });
68
- },
69
- checkAnswer: () => ({ correct: response === config.correctAnswer, score: response === config.correctAnswer ? 1 : 0 }),
70
- reset: () => {
71
- response = null;
72
- container.querySelectorAll('.rating-scale-option').forEach(b => b.classList.remove('selected'));
73
- }
74
- };
75
- }
76
- ```
77
-
78
- ## Exports
79
-
80
- | Export | Required | Purpose |
81
- |--------|----------|---------|
82
- | `create(container, config)` | ✅ | Factory function |
83
- | `schema` | Optional | Enables linting, AI assistance, preview editor |
84
- | `metadata` | Optional | UI labels, categories, SCORM interaction type |
85
-
86
- ## Usage in Slides
87
-
88
- ```javascript
89
- const rating = CourseCode.createRatingScaleQuestion(container, {
90
- id: 'my-rating',
91
- prompt: 'How would you rate this?',
92
- options: ['Poor', 'Fair', 'Good', 'Excellent'],
93
- correctAnswer: '3'
94
- });
95
- ```
96
-
97
- See `COURSE_AUTHORING_GUIDE.md` for full interaction API.