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 +17 -11
- package/bin/cli.js +29 -3
- package/framework/css/interactions/interactive-image.css +2 -2
- package/framework/docs/COURSE_AUTHORING_GUIDE.md +5 -1
- package/framework/docs/FRAMEWORK_GUIDE.md +1 -1
- package/framework/docs/USER_GUIDE.md +138 -3
- package/framework/docs/examples/cloudflare-channel-relay.js +3 -0
- package/framework/docs/examples/cloudflare-data-worker.js +3 -0
- package/framework/docs/examples/cloudflare-error-worker.js +3 -0
- package/lib/cloud-certs.js +141 -0
- package/lib/cloud.js +246 -35
- package/lib/mcp-prompts.js +1 -1
- package/lib/stub-player/styles/_base.css +1 -1
- package/lib/test-data-reporting.js +0 -5
- package/lib/test-error-reporting.js +0 -5
- package/package.json +1 -1
- package/template/course/components/components-readme.md +5 -0
- package/template/course/course-config.js +2 -0
- package/template/course/interactions/interactions-readme.md +5 -0
- package/template/course/interactions/PLUGIN_GUIDE.md +0 -97
package/README.md
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
# CourseCode
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
11
|
+
Start with the workflow that fits you:
|
|
10
12
|
|
|
11
|
-
- **Course authors (
|
|
12
|
-
- **Course authors (
|
|
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
|
|
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://
|
|
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?**
|
|
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://
|
|
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.
|
|
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, #
|
|
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, #
|
|
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 `
|
|
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 `
|
|
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. [
|
|
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
|
-
|
|
55
|
-
|
|
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://
|
|
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 =
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
196
|
+
const primaryUrl = getCloudUrl();
|
|
197
|
+
const res = await attemptFetch(primaryUrl);
|
|
183
198
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
451
|
-
|
|
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.
|
|
459
|
-
|
|
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
|
/**
|
package/lib/mcp-prompts.js
CHANGED
|
@@ -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 \`
|
|
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: #
|
|
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
|
@@ -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)
|
|
@@ -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.
|