coursecode 0.1.7 → 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/README.md CHANGED
@@ -1,21 +1,23 @@
1
1
  # CourseCode
2
2
 
3
- **AI-first course authoring: create interactive e-learning without writing code, then deploy to any LMS format.**
3
+ **Open-source, local-first course authoring with AI help no coding required to start, full code control when you need it.**
4
4
 
5
- Drop in your existing PDFs, Word docs, or PowerPoints AI converts them into complete, interactive courses. No vendor lock-in, no subscriptions. Your content, your code, deployed to any LMS.
5
+ CourseCode creates real project files you can inspect, version, and edit directly with a predictable, file-based workflow instead of a black-box GUI.
6
+
7
+ Bring your own PDFs, Word docs, or PowerPoints, use AI to accelerate authoring, and deploy to any LMS format without vendor lock-in or subscriptions.
6
8
 
7
9
  ## Start Here
8
10
 
9
- If you're creating courses (non-technical or technical), use the **[User Guide](framework/docs/USER_GUIDE.md)** as your primary documentation.
11
+ Start with the workflow that fits you:
10
12
 
11
- - **Course authors (non-technical):** Start with the User Guide for step-by-step workflows
12
- - **Course authors (technical):** The User Guide is still the best starting point, then use advanced docs as needed
13
+ - **Course authors (prefer buttons and guided setup):** Start with **[CourseCode Desktop](https://coursecodedesktop.com)** if you want the easiest path and do not want to deal with Node.js or terminal setup
14
+ - **Course authors (prefer editing files and running commands):** Start with the **[User Guide](framework/docs/USER_GUIDE.md)** in this repo for the framework workflow
13
15
  - **AI assistants:** Use the AI-focused docs in `framework/docs/` (for example `COURSE_AUTHORING_GUIDE.md` and `FRAMEWORK_GUIDE.md`)
14
16
 
15
17
  ## Features
16
18
 
17
19
  - **MCP integration**: AI connects directly to your course — previews, screenshots, linting, and testing without manual file sharing
18
- - **No code writing required**: Describe what you want and let AI build slides, interactions, and structure
20
+ - **No coding required to start**: Describe what you want and let AI help build slides, interactions, and structure
19
21
  - **Full LMS integration**: SCORM 1.2, SCORM 2004, cmi5, and LTI with complete tracking records
20
22
  - **AI-assisted authoring workflow**: Structured guides and MCP tools for faster course development
21
23
  - **Rich UI components**: Images, video, accordions, tabs, and custom sandboxed HTML/JS embeds
@@ -26,13 +28,17 @@ If you're creating courses (non-technical or technical), use the **[User Guide](
26
28
  - **Themeable design**: CSS custom properties for easy brand customization
27
29
  - **Custom endpoints**: Optional webhooks for error reporting and learning record storage
28
30
  - **Live preview**: Visual editing, status dashboard, config panels, catalog browser, and full LMS simulation with debug tools
29
- - **[CourseCode Cloud](https://www.coursecodecloud.com)**: Deploy from CLI, share preview links, download any LMS format on demand — no rebuilds
30
- - **CourseCode Desktop**: Native app for Mac and Windows with AI-assisted editing and built-in preview
31
+ - **[CourseCode Cloud](https://coursecodecloud.com)**: Deploy from CLI, share preview links, download any LMS format on demand — no rebuilds
32
+ - **[CourseCode Desktop](https://coursecodedesktop.com)**: Native app for Mac and Windows with AI-assisted editing and built-in preview
31
33
 
32
34
  ---
33
35
 
34
36
  ## Installation
35
37
 
38
+ This section is for the **CourseCode Framework CLI** (file-based workflow using Node.js and terminal commands).
39
+
40
+ If you want the easiest setup with buttons and guided steps, use **[CourseCode Desktop](https://coursecodedesktop.com)** instead.
41
+
36
42
  ### Required
37
43
 
38
44
  Install [Node.js](https://nodejs.org/) (v18 or later), then run:
@@ -65,7 +71,7 @@ Open `http://localhost:4173` to view and edit your course.
65
71
 
66
72
  The example course included with every new project is a complete guide to using CourseCode.
67
73
 
68
- **New to CourseCode?** Start with the [User Guide](framework/docs/USER_GUIDE.md). It is the primary guide for both non-technical and technical course authors.
74
+ **New to CourseCode?** If you want the easiest setup, start with **[CourseCode Desktop](https://coursecodedesktop.com)**. If you're using the framework CLI, start with the [User Guide](framework/docs/USER_GUIDE.md).
69
75
 
70
76
  ---
71
77
 
@@ -179,7 +185,7 @@ The preview server provides:
179
185
 
180
186
  When ready, deploy:
181
187
 
182
- **With [CourseCode Cloud](https://www.coursecodecloud.com)**: Push your course and get a live link. Cloud handles hosting, generates any LMS format on demand, and gives you sharable preview links with optional password protection. No ZIP files, no manual uploads.
188
+ **With [CourseCode Cloud](https://coursecodecloud.com)**: Push your course and get a live link. Cloud handles hosting, generates any LMS format on demand, and gives you sharable preview links with optional password protection. No ZIP files, no manual uploads.
183
189
 
184
190
  ```bash
185
191
  coursecode deploy
@@ -218,7 +224,7 @@ For the full command list and deployment options, see the [User Guide](framework
218
224
 
219
225
  ## UI Components
220
226
 
221
- Build engaging slides with interactive elements. No coding required your AI assistant handles the implementation.
227
+ Build engaging slides with interactive elements. Start with AI assistance, then edit the generated files directly whenever you want more control.
222
228
 
223
229
  ### Media & Widgets
224
230
 
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
@@ -61,7 +61,7 @@
61
61
  .interactive-image-hotspot[data-color="primary"] { --hotspot-color: var(--color-primary, #14213d); }
62
62
  .interactive-image-hotspot[data-color="secondary"] { --hotspot-color: var(--color-secondary, #f18701); }
63
63
  .interactive-image-hotspot[data-color="success"] { --hotspot-color: var(--color-success, #1d7648); }
64
- .interactive-image-hotspot[data-color="danger"] { --hotspot-color: var(--color-danger, #f35b04); }
64
+ .interactive-image-hotspot[data-color="danger"] { --hotspot-color: var(--color-danger, #c7322b); }
65
65
  .interactive-image-hotspot[data-color="warning"] { --hotspot-color: var(--color-warning, #f7b801); }
66
66
  .interactive-image-hotspot[data-color="info"] { --hotspot-color: var(--color-info, #4a6fa5); }
67
67
  .interactive-image-hotspot[data-color="light"] { --hotspot-color: var(--color-gray-50, #fafafa); }
@@ -71,7 +71,7 @@
71
71
  .interactive-image-hotspot[data-viewed-color="primary"] { --viewed-color: var(--color-primary, #14213d); }
72
72
  .interactive-image-hotspot[data-viewed-color="secondary"] { --viewed-color: var(--color-secondary, #f18701); }
73
73
  .interactive-image-hotspot[data-viewed-color="success"] { --viewed-color: var(--color-success, #1d7648); }
74
- .interactive-image-hotspot[data-viewed-color="danger"] { --viewed-color: var(--color-danger, #f35b04); }
74
+ .interactive-image-hotspot[data-viewed-color="danger"] { --viewed-color: var(--color-danger, #c7322b); }
75
75
  .interactive-image-hotspot[data-viewed-color="warning"] { --viewed-color: var(--color-warning, #f7b801); }
76
76
  .interactive-image-hotspot[data-viewed-color="info"] { --viewed-color: var(--color-info, #4a6fa5); }
77
77
  .interactive-image-hotspot[data-viewed-color="light"] { --viewed-color: var(--color-gray-50, #fafafa); }
@@ -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
 
@@ -1688,6 +1688,8 @@ environment: {
1688
1688
 
1689
1689
  Three optional, config-driven tools for outbound communication. Each activates when its `endpoint` is set — no code changes needed.
1690
1690
 
1691
+ > **CourseCode Cloud (AI note):** These `environment.*` endpoint settings are the **manual/self-hosted fallback**. On cloud-served launches, CourseCode Cloud injects `cc-*` meta tags into `index.html`, and the framework uses those values instead (override). This is intentional zero-config behavior for cloud error/data/channel wiring.
1692
+
1691
1693
  | Tool | What it sends | Transport | Example backend |
1692
1694
  |------|--------------|-----------|-----------------|
1693
1695
  | **Error Reporter** | Framework errors (`*:error` events) | POST per error (60s dedup) | `cloudflare-error-worker.js` |
@@ -1711,6 +1713,8 @@ eventBus.on('channel:message', (data) => { /* any JSON */ });
1711
1713
  All example backends are in `framework/docs/examples/`.
1712
1714
 
1713
1715
  > **Local dev:** Error and data reporters are automatically disabled during watch builds (`coursecode preview`, `coursecode dev`). Production builds (`coursecode build`) send reports normally.
1716
+ >
1717
+ > **Cloud precedence:** Cloud-injected meta tags override `course-config.js` endpoint settings for these tools. If you need custom routing/fanout, prefer doing it server-side from your cloud ingestion endpoint.
1714
1718
 
1715
1719
  ---
1716
1720
 
@@ -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
@@ -605,11 +697,54 @@ coursecode build --format scorm1.2-proxy
605
697
  CourseCode Cloud is the simplest deployment option. Upload your course once and the cloud handles everything:
606
698
 
607
699
  ```bash
700
+ coursecode login # First time only: sign in to CourseCode Cloud
608
701
  coursecode deploy # Build + upload to cloud
609
702
  ```
610
703
 
611
704
  **How it works:** Your course is built once as a universal package. The cloud can generate a format-specific ZIP (SCORM 1.2, SCORM 2004, cmi5, etc.) on demand — no rebuilding required. You never need to set a format in `course-config.js` for cloud-deployed courses.
612
705
 
706
+ Cloud-served launches also auto-configure runtime error reporting, data reporting, and channel relay endpoints (zero-config cloud wiring).
707
+ If you configured manual endpoints in `course-config.js` for self-hosted workflows, Cloud launches override them with cloud-injected runtime config.
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
+
726
+ **What CourseCode Cloud helps with (plain English):**
727
+ - Host your course online so learners/reviewers can access it without you manually hosting files
728
+ - Generate the LMS package you need later (SCORM/cmi5) from the same upload
729
+ - Share preview links for stakeholder review
730
+ - Manage deployment updates without rebuilding separate packages for each LMS format
731
+ - Provide cloud-managed runtime services (reporting/channel) without extra endpoint setup in your course files
732
+
733
+ **Typical Cloud workflow:**
734
+ 1. Run `coursecode login` once, open the URL shown, and enter the code.
735
+ 2. Run `coursecode deploy` from your project folder.
736
+ 3. Open the CourseCode Cloud dashboard link shown after deploy.
737
+ 4. Use Cloud preview links for review.
738
+ 5. Download the LMS format you need from Cloud when you're ready to deliver.
739
+
740
+ **Prefer a GUI instead of the terminal?**
741
+ - Use **CourseCode Desktop** for the same project workflow with buttons for Preview / Export / Deploy.
742
+ - Desktop docs: `coursecode-desktop/USER_GUIDE.md`
743
+
744
+ **When to use Cloud vs local export:**
745
+ - Use **local export** if you just need a ZIP to upload manually and don't need hosted previews or cloud services.
746
+ - Use **Cloud** if you want easier sharing, hosted delivery workflows, or format downloads later without rebuilding.
747
+
613
748
  **Benefits:**
614
749
  - **No format decisions** — download the right ZIP for any LMS directly from the cloud
615
750
  - **Instant updates** — redeploy and all future launches get the new version
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Content-agnostic message fan-out. Receives JSON via POST, broadcasts to all
5
5
  * connected SSE clients on the same channel. Does not parse or interpret messages.
6
+ * Self-hosted/manual relay example: on CourseCode Cloud, runtime channel endpoint/id are injected
7
+ * by the platform and override course-config.js endpoint values.
8
+ * Use this either for self-hosted relay infrastructure, or as a cloud-managed relay implementation/reference.
6
9
  *
7
10
  * SETUP:
8
11
  * 1. Create a Cloudflare account and go to Workers & Pages
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Receives batched learning records (assessments, objectives, interactions)
5
5
  * from courses and stores them for analytics or forwards to your data warehouse.
6
+ * Self-hosted/manual endpoint example: on CourseCode Cloud, runtime endpoints/API key are injected
7
+ * by the platform and override course-config.js endpoint values.
8
+ * Use this either for self-hosted telemetry, or as a downstream target if Cloud fans out records server-side.
6
9
  *
7
10
  * SETUP:
8
11
  * 1. Create a Cloudflare account and go to Workers & Pages
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Deploy this worker to receive error reports and user issue reports from courses
5
5
  * and send email alerts. The API key stays server-side, so it's never exposed in the course.
6
+ * Self-hosted/manual endpoint example: on CourseCode Cloud, runtime endpoints/API key are injected
7
+ * by the platform and override course-config.js endpoint values.
8
+ * Use this either for self-hosted telemetry, or as a downstream target if Cloud fans out reports server-side.
6
9
  *
7
10
  * SETUP:
8
11
  * 1. Create a Cloudflare account and go to Workers & Pages
@@ -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
@@ -22,7 +22,11 @@ const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'packa
22
22
  // CONSTANTS
23
23
  // =============================================================================
24
24
 
25
- const DEFAULT_CLOUD_URL = 'https://www.coursecodecloud.com';
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
 
@@ -25,7 +25,7 @@
25
25
  --color-warning-bright: color-mix(in srgb, var(--color-warning) 70%, var(--color-accent));
26
26
  --color-accent: #f7b801;
27
27
 
28
- --color-danger: #f35b04;
28
+ --color-danger: #c7322b;
29
29
  --color-danger-soft: color-mix(in srgb, var(--color-danger) 55%, var(--color-white));
30
30
  --color-black: #000;
31
31
 
@@ -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.7",
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.
@@ -70,6 +70,8 @@
70
70
  * Sends framework errors to a webhook for email alerts. Disabled if endpoint is missing.
71
71
  * enableUserReports adds "Report Issue" button to settings menu (default: true when endpoint set).
72
72
  * Use with Cloudflare Worker to keep API keys server-side.
73
+ * CourseCode Cloud launches override manual endpoint config via injected <meta name="cc-*"> tags
74
+ * (zero-config cloud wiring). Treat this as a self-hosted/manual fallback.
73
75
  *
74
76
  * LMS COMPATIBILITY PROFILE (environment.lmsCompatibilityMode):
75
77
  * 'auto' (default): choose profile by format (strict-scorm12 / conservative-scorm2004 / modern-http)
@@ -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.