coursecode 0.1.8 → 0.1.9

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,33 @@ 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 auto-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
+ // Fix: export the OS root cert store to a temp PEM file and re-exec with
31
+ // NODE_EXTRA_CA_CERTS pointing to it. Node picks it up before any TLS
32
+ // handshakes. Transparent to users — completes in < 200ms.
33
+ //
34
+ // The NODE_EXTRA_CA_CERTS guard prevents an infinite re-exec loop.
35
+ // =============================================================================
36
+
37
+ if (!process.env.NODE_EXTRA_CA_CERTS) {
38
+ const { exportSystemCerts } = await import('../lib/cloud-certs.js');
39
+ const certPath = await exportSystemCerts();
40
+ if (certPath) {
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
+ }
49
+
23
50
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
51
  const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
25
52
 
@@ -200,7 +227,6 @@ program
200
227
  .description('Send a test error to verify error reporting is configured correctly')
201
228
  .option('-t, --type <type>', 'Type of test: error or report', 'error')
202
229
  .option('-m, --message <message>', 'Custom message to include in the test')
203
- .option('--insecure', 'Skip TLS certificate verification (for corporate proxies)')
204
230
  .action(async (options) => {
205
231
  const { testErrorReporting } = await import('../lib/test-error-reporting.js');
206
232
  await testErrorReporting(options);
@@ -212,7 +238,6 @@ program
212
238
  .description('Send a test data record to verify data reporting is configured correctly')
213
239
  .option('-t, --type <type>', 'Type of record: assessment, objective, or interaction', 'assessment')
214
240
  .option('-m, --message <message>', 'Custom message to include in the test')
215
- .option('--insecure', 'Skip TLS certificate verification (for corporate proxies)')
216
241
  .action(async (options) => {
217
242
  const { testDataReporting } = await import('../lib/test-data-reporting.js');
218
243
  await testDataReporting(options);
@@ -280,10 +305,11 @@ program
280
305
  .command('login')
281
306
  .description('Log in to CourseCode Cloud')
282
307
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
308
+ .option('--json', 'Emit machine-readable JSON (for GUI/desktop integration)')
283
309
  .action(async (options) => {
284
310
  const { login, setLocalMode } = await import('../lib/cloud.js');
285
311
  if (options.local) setLocalMode();
286
- await login();
312
+ await login({ json: options.json });
287
313
  });
288
314
 
289
315
  program
@@ -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,141 @@
1
+ /**
2
+ * cloud-certs.js — System CA certificate export for corporate network compatibility.
3
+ *
4
+ * Exports the OS trusted root store to a temp PEM file so Node.js can verify
5
+ * TLS connections that pass through SSL-inspecting proxies (e.g. Zscaler).
6
+ *
7
+ * Returns the path to the PEM file on success, null on failure.
8
+ * Never throws — silent fallback for non-corporate machines.
9
+ */
10
+
11
+ import { execFile } from 'child_process';
12
+ import { promisify } from 'util';
13
+ import fs from 'fs';
14
+ import os from 'os';
15
+ import path from 'path';
16
+ import crypto from 'crypto';
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ /** Cached result — only export once per process lifetime. */
21
+ let _cachedCertPath = undefined;
22
+
23
+ /**
24
+ * Export the OS system root certificate store to a temp PEM file.
25
+ *
26
+ * @returns {Promise<string|null>} Absolute path to the PEM file, or null if unavailable.
27
+ */
28
+ export async function exportSystemCerts() {
29
+ if (_cachedCertPath !== undefined) return _cachedCertPath;
30
+
31
+ try {
32
+ const pem = await readSystemCerts();
33
+ if (!pem || !pem.trim()) {
34
+ _cachedCertPath = null;
35
+ return null;
36
+ }
37
+
38
+ // Write to a stable temp path (same content = same hash, avoids accumulation)
39
+ const hash = crypto.createHash('sha1').update(pem).digest('hex').slice(0, 8);
40
+ const certPath = path.join(os.tmpdir(), `coursecode-ca-${hash}.pem`);
41
+
42
+ if (!fs.existsSync(certPath)) {
43
+ fs.writeFileSync(certPath, pem, { mode: 0o600 });
44
+ }
45
+
46
+ _cachedCertPath = certPath;
47
+ return certPath;
48
+ } catch {
49
+ _cachedCertPath = null;
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Read system certificates as a PEM string.
56
+ * Platform-specific — returns null if the platform is unsupported or export fails.
57
+ */
58
+ async function readSystemCerts() {
59
+ const platform = process.platform;
60
+
61
+ if (platform === 'darwin') {
62
+ return readMacosCerts();
63
+ }
64
+
65
+ if (platform === 'win32') {
66
+ return readWindowsCerts();
67
+ }
68
+
69
+ // Linux/other: read well-known bundle paths directly
70
+ return readLinuxCerts();
71
+ }
72
+
73
+ /**
74
+ * macOS: export the system root keychain via the `security` CLI tool.
75
+ * This includes all roots installed via Apple MDM / System Preferences.
76
+ */
77
+ async function readMacosCerts() {
78
+ // Export from all common keychains — ignore errors on missing keychains
79
+ const keychains = [
80
+ '/Library/Keychains/SystemRootCertificates.keychain',
81
+ '/System/Library/Keychains/SystemRootCertificates.keychain',
82
+ '/Library/Keychains/System.keychain',
83
+ ];
84
+
85
+ const pems = [];
86
+ for (const keychain of keychains) {
87
+ try {
88
+ const { stdout } = await execFileAsync('security', [
89
+ 'find-certificate', '-a', '-p', keychain,
90
+ ], { maxBuffer: 16 * 1024 * 1024 });
91
+ if (stdout) pems.push(stdout);
92
+ } catch {
93
+ // Keychain not present on this OS version — skip
94
+ }
95
+ }
96
+
97
+ return pems.join('\n');
98
+ }
99
+
100
+ /**
101
+ * Windows: export LocalMachine\Root via PowerShell.
102
+ * This includes certs deployed via Group Policy / MDM.
103
+ */
104
+ async function readWindowsCerts() {
105
+ const script = [
106
+ '$certs = Get-ChildItem -Path Cert:\\LocalMachine\\Root',
107
+ '$pems = $certs | ForEach-Object {',
108
+ ' $bytes = $_.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)',
109
+ ' $b64 = [System.Convert]::ToBase64String($bytes, "InsertLineBreaks")',
110
+ ' "-----BEGIN CERTIFICATE-----`n" + $b64 + "`n-----END CERTIFICATE-----"',
111
+ '}',
112
+ '$pems -join "`n"',
113
+ ].join('; ');
114
+
115
+ const { stdout } = await execFileAsync('powershell', [
116
+ '-NoProfile', '-NonInteractive', '-Command', script,
117
+ ], { maxBuffer: 16 * 1024 * 1024 });
118
+
119
+ return stdout;
120
+ }
121
+
122
+ /**
123
+ * Linux: read the system CA bundle from well-known locations.
124
+ * No subprocess needed — just read the file directly.
125
+ */
126
+ function readLinuxCerts() {
127
+ const candidatePaths = [
128
+ '/etc/ssl/certs/ca-certificates.crt', // Debian/Ubuntu
129
+ '/etc/pki/tls/certs/ca-bundle.crt', // RHEL/CentOS/Fedora
130
+ '/etc/ssl/ca-bundle.pem', // OpenSUSE
131
+ '/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem', // RHEL 7+
132
+ ];
133
+
134
+ for (const p of candidatePaths) {
135
+ if (fs.existsSync(p)) {
136
+ return fs.readFileSync(p, 'utf-8');
137
+ }
138
+ }
139
+
140
+ return null;
141
+ }
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
  /**
@@ -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.9",
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": {
@@ -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.