coursecode 0.1.10 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +41 -6
- package/framework/docs/FRAMEWORK_GUIDE.md +1 -1
- package/framework/docs/USER_GUIDE.md +17 -6
- package/lib/cloud.js +309 -59
- package/lib/convert.js +4 -3
- package/lib/stub-player/lms-api.js +1 -1
- package/package.json +2 -2
package/bin/cli.js
CHANGED
|
@@ -166,12 +166,13 @@ program
|
|
|
166
166
|
// Convert command
|
|
167
167
|
program
|
|
168
168
|
.command('convert [source]')
|
|
169
|
-
.description('Convert docx, pptx, and pdf files to markdown for course authoring')
|
|
169
|
+
.description('Convert docx, pptx, and pdf files to markdown for course authoring (PDF JSON sidecars optional)')
|
|
170
170
|
.option('-o, --output <dir>', 'Output directory for converted files', './course/references/converted')
|
|
171
171
|
.option('-f, --format <type>', 'Limit to format: docx, pptx, pdf, or all', 'all')
|
|
172
172
|
.option('--dry-run', 'Show what would be converted without writing files')
|
|
173
173
|
.option('--overwrite', 'Overwrite existing markdown files')
|
|
174
174
|
.option('--flatten', 'Output all files to single directory (no subdirs)')
|
|
175
|
+
.option('--pdf-json', 'Also write PDF structure JSON sidecars (.json) next to converted markdown')
|
|
175
176
|
.action(async (source = './course/references', options) => {
|
|
176
177
|
const { convert } = await import('../lib/convert.js');
|
|
177
178
|
await convert(source, options);
|
|
@@ -317,10 +318,11 @@ program
|
|
|
317
318
|
.command('logout')
|
|
318
319
|
.description('Log out of CourseCode Cloud')
|
|
319
320
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
321
|
+
.option('--json', 'Emit machine-readable JSON')
|
|
320
322
|
.action(async (options) => {
|
|
321
323
|
const { logout, setLocalMode } = await import('../lib/cloud.js');
|
|
322
324
|
if (options.local) setLocalMode();
|
|
323
|
-
await logout();
|
|
325
|
+
await logout({ json: options.json });
|
|
324
326
|
});
|
|
325
327
|
|
|
326
328
|
program
|
|
@@ -338,33 +340,66 @@ program
|
|
|
338
340
|
.command('courses')
|
|
339
341
|
.description('List courses on CourseCode Cloud')
|
|
340
342
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
343
|
+
.option('--json', 'Output raw JSON array')
|
|
341
344
|
.action(async (options) => {
|
|
342
345
|
const { listCourses, setLocalMode } = await import('../lib/cloud.js');
|
|
343
346
|
if (options.local) setLocalMode();
|
|
344
|
-
await listCourses();
|
|
347
|
+
await listCourses({ json: options.json });
|
|
345
348
|
});
|
|
346
349
|
|
|
347
350
|
program
|
|
348
351
|
.command('deploy')
|
|
349
352
|
.description('Build and deploy course to CourseCode Cloud')
|
|
350
|
-
.option('--preview', 'Deploy as preview (
|
|
351
|
-
.option('--
|
|
353
|
+
.option('--preview', 'Deploy as preview-only (production untouched, preview pointer always moved). Combine with --promote or --stage for a full deploy that also moves the preview pointer.')
|
|
354
|
+
.option('--promote', 'Force-promote: always move production pointer regardless of deploy_mode setting. Mutually exclusive with --stage.')
|
|
355
|
+
.option('--stage', 'Force-stage: never move production pointer regardless of deploy_mode setting. Mutually exclusive with --promote.')
|
|
356
|
+
.option('--password', 'Password-protect preview (interactive prompt, requires --preview)')
|
|
352
357
|
.option('-m, --message <message>', 'Deploy reason (e.g. "Fixed accessibility issues")')
|
|
353
358
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
359
|
+
.option('--json', 'Emit machine-readable JSON result')
|
|
354
360
|
.action(async (options) => {
|
|
355
361
|
const { deploy, setLocalMode } = await import('../lib/cloud.js');
|
|
356
362
|
if (options.local) setLocalMode();
|
|
357
363
|
await deploy(options);
|
|
358
364
|
});
|
|
359
365
|
|
|
366
|
+
program
|
|
367
|
+
.command('promote')
|
|
368
|
+
.description('Promote a deployment to production or preview pointer')
|
|
369
|
+
.option('--production', 'Promote to the production pointer')
|
|
370
|
+
.option('--preview', 'Promote to the preview pointer')
|
|
371
|
+
.option('--deployment <id>', 'Deployment ID to promote (skip interactive prompt)')
|
|
372
|
+
.option('-m, --message <message>', 'Reason for promotion')
|
|
373
|
+
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
374
|
+
.option('--json', 'Emit machine-readable JSON result')
|
|
375
|
+
.action(async (options) => {
|
|
376
|
+
const { promote, setLocalMode } = await import('../lib/cloud.js');
|
|
377
|
+
if (options.local) setLocalMode();
|
|
378
|
+
await promote(options);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
|
|
360
382
|
program
|
|
361
383
|
.command('status')
|
|
362
384
|
.description('Show deployment status for current course')
|
|
363
385
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
386
|
+
.option('--json', 'Output raw JSON')
|
|
364
387
|
.action(async (options) => {
|
|
365
388
|
const { status, setLocalMode } = await import('../lib/cloud.js');
|
|
366
389
|
if (options.local) setLocalMode();
|
|
367
|
-
await status();
|
|
390
|
+
await status({ json: options.json });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
program
|
|
394
|
+
.command('delete')
|
|
395
|
+
.description('Remove course from CourseCode Cloud (does not delete local files)')
|
|
396
|
+
.option('--force', 'Skip confirmation prompt')
|
|
397
|
+
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
398
|
+
.option('--json', 'Emit machine-readable JSON result')
|
|
399
|
+
.action(async (options) => {
|
|
400
|
+
const { deleteCourse, setLocalMode } = await import('../lib/cloud.js');
|
|
401
|
+
if (options.local) setLocalMode();
|
|
402
|
+
await deleteCourse({ force: options.force, json: options.json });
|
|
368
403
|
});
|
|
369
404
|
|
|
370
405
|
program.parse();
|
|
@@ -123,7 +123,7 @@ The browser only downloads the one chunk matching the meta tag. Unused driver ch
|
|
|
123
123
|
| `coursecode build` | `dist/` | Universal build + format manifest + meta tag stamped |
|
|
124
124
|
| `coursecode build` (with `PACKAGE=true`) | `dist/` + ZIP | Same + format-specific ZIP for LMS upload |
|
|
125
125
|
| `coursecode preview --export` | `course-preview/` | Copy of `dist/` wrapped in stub player (for Netlify/GitHub Pages) |
|
|
126
|
-
| `coursecode deploy` | Uploads `dist/` | Cloud hosts universal build, assembles format ZIPs on demand |
|
|
126
|
+
| `coursecode deploy` | Uploads `dist/` | Cloud hosts universal build, assembles format ZIPs on demand. Flags: `--promote` (force live), `--stage` (force staged), `--preview` (preview-only: production untouched, preview always moved). `--promote`/`--stage` are mutually exclusive; `--preview` stacks with either. |
|
|
127
127
|
|
|
128
128
|
The ZIP never includes preview/stub player assets. Preview is a separate concern (see below).
|
|
129
129
|
|
|
@@ -723,12 +723,23 @@ Running `coursecode login` displays a URL and a short code in your terminal:
|
|
|
723
723
|
|
|
724
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
725
|
|
|
726
|
-
**
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
726
|
+
**Deploy flags:**
|
|
727
|
+
|
|
728
|
+
`coursecode deploy` accepts flags that control how the production and preview pointers are updated after upload:
|
|
729
|
+
|
|
730
|
+
| Command | Production pointer | Preview pointer |
|
|
731
|
+
|---|---|---|
|
|
732
|
+
| `cc deploy` | Follows your deploy_mode setting | Follows your preview_deploy_mode setting |
|
|
733
|
+
| `cc deploy --promote` | Always moved to new version | Follows your preview_deploy_mode setting |
|
|
734
|
+
| `cc deploy --stage` | Never moved (stays on old version) | Follows your preview_deploy_mode setting |
|
|
735
|
+
| `cc deploy --preview` | **Untouched** (preview-only upload) | Always moved to new version |
|
|
736
|
+
| `cc deploy --promote --preview` | Always moved to new version | Always moved to new version |
|
|
737
|
+
| `cc deploy --stage --preview` | Never moved | Always moved to new version |
|
|
738
|
+
|
|
739
|
+
- **Production pointer** — the version learners see when they launch your course.
|
|
740
|
+
- **Preview pointer** — the version served on the cloud preview link (for stakeholder review).
|
|
741
|
+
- **deploy_mode** — a per-course or org setting in the Cloud dashboard. Default is auto-promote (new uploads immediately go live). Can be set to staged (new uploads require a manual promote step).
|
|
742
|
+
- `--promote` and `--stage` are mutually exclusive.
|
|
732
743
|
|
|
733
744
|
**Typical Cloud workflow:**
|
|
734
745
|
1. Run `coursecode login` once, open the URL shown, and enter the code.
|
package/lib/cloud.js
CHANGED
|
@@ -29,6 +29,7 @@ const DEFAULT_CLOUD_URL = 'https://coursecodecloud.com';
|
|
|
29
29
|
const FALLBACK_CLOUD_URL = 'https://coursecode-cloud-web.vercel.app';
|
|
30
30
|
const LOCAL_CLOUD_URL = 'http://localhost:3000';
|
|
31
31
|
let useLocal = false;
|
|
32
|
+
let activeCloudUrl = null;
|
|
32
33
|
const CREDENTIALS_DIR = path.join(os.homedir(), '.coursecode');
|
|
33
34
|
const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, 'credentials.json');
|
|
34
35
|
const PROJECT_CONFIG_DIR = '.coursecode';
|
|
@@ -93,12 +94,19 @@ function writeCredentials(token, cloudUrl = DEFAULT_CLOUD_URL) {
|
|
|
93
94
|
fs.writeFileSync(getCredentialsPath(), data, { mode: 0o600 });
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
function updateCredentialsCloudUrl(cloudUrl) {
|
|
98
|
+
const creds = readCredentials();
|
|
99
|
+
if (!creds?.token) return;
|
|
100
|
+
writeCredentials(creds.token, cloudUrl);
|
|
101
|
+
}
|
|
102
|
+
|
|
96
103
|
function deleteCredentials() {
|
|
97
104
|
try { fs.unlinkSync(getCredentialsPath()); } catch { /* already gone */ }
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
function getCloudUrl() {
|
|
101
108
|
if (useLocal) return LOCAL_CLOUD_URL;
|
|
109
|
+
if (activeCloudUrl) return activeCloudUrl;
|
|
102
110
|
return readCredentials()?.cloud_url || DEFAULT_CLOUD_URL;
|
|
103
111
|
}
|
|
104
112
|
|
|
@@ -108,6 +116,7 @@ function getCloudUrl() {
|
|
|
108
116
|
*/
|
|
109
117
|
export function setLocalMode() {
|
|
110
118
|
useLocal = true;
|
|
119
|
+
activeCloudUrl = LOCAL_CLOUD_URL;
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
// =============================================================================
|
|
@@ -127,9 +136,11 @@ function readProjectConfig() {
|
|
|
127
136
|
function writeProjectConfig(data) {
|
|
128
137
|
const dir = path.join(process.cwd(), PROJECT_CONFIG_DIR);
|
|
129
138
|
fs.mkdirSync(dir, { recursive: true });
|
|
139
|
+
const existing = readProjectConfig() || {};
|
|
140
|
+
Object.assign(existing, data);
|
|
130
141
|
fs.writeFileSync(
|
|
131
142
|
path.join(process.cwd(), PROJECT_CONFIG_PATH),
|
|
132
|
-
JSON.stringify(
|
|
143
|
+
JSON.stringify(existing, null, 2) + '\n'
|
|
133
144
|
);
|
|
134
145
|
}
|
|
135
146
|
|
|
@@ -151,12 +162,13 @@ function readRcConfig() {
|
|
|
151
162
|
}
|
|
152
163
|
|
|
153
164
|
/**
|
|
154
|
-
*
|
|
165
|
+
* Merge fields into .coursecoderc.json without clobbering unrelated fields.
|
|
166
|
+
* Use this for any cloud binding state (cloudId, orgId, etc.).
|
|
155
167
|
*/
|
|
156
|
-
function
|
|
168
|
+
function writeRcConfig(fields) {
|
|
157
169
|
const rcPath = path.join(process.cwd(), '.coursecoderc.json');
|
|
158
170
|
const existing = readRcConfig() || {};
|
|
159
|
-
existing
|
|
171
|
+
Object.assign(existing, fields);
|
|
160
172
|
fs.writeFileSync(rcPath, JSON.stringify(existing, null, 2) + '\n');
|
|
161
173
|
}
|
|
162
174
|
|
|
@@ -200,7 +212,10 @@ async function cloudFetch(urlPath, options = {}, token = null) {
|
|
|
200
212
|
// Primary unreachable — try fallback before giving up
|
|
201
213
|
if (!useLocal) {
|
|
202
214
|
const fallback = await attemptFetch(FALLBACK_CLOUD_URL);
|
|
203
|
-
if (fallback)
|
|
215
|
+
if (fallback) {
|
|
216
|
+
activeCloudUrl = FALLBACK_CLOUD_URL;
|
|
217
|
+
return fallback;
|
|
218
|
+
}
|
|
204
219
|
}
|
|
205
220
|
console.error('\n❌ Could not connect to CourseCode Cloud. Check your internet connection.\n');
|
|
206
221
|
process.exit(1);
|
|
@@ -209,11 +224,29 @@ async function cloudFetch(urlPath, options = {}, token = null) {
|
|
|
209
224
|
// Peek at the body: if it's an HTML block page, silently retry on the fallback.
|
|
210
225
|
// We must buffer the text here since Response bodies can only be read once.
|
|
211
226
|
const text = await res.text();
|
|
227
|
+
|
|
228
|
+
// Token may be valid on the alternate cloud origin. Before triggering re-auth,
|
|
229
|
+
// retry authenticated 401s once on the other known origin.
|
|
230
|
+
if (res.status === 401 && token && !useLocal) {
|
|
231
|
+
const alternateUrl = primaryUrl === FALLBACK_CLOUD_URL ? DEFAULT_CLOUD_URL : FALLBACK_CLOUD_URL;
|
|
232
|
+
const alternateRes = await attemptFetch(alternateUrl);
|
|
233
|
+
if (alternateRes) {
|
|
234
|
+
const alternateText = await alternateRes.text();
|
|
235
|
+
if (!isBlockPage(alternateText) && alternateRes.status !== 401) {
|
|
236
|
+
activeCloudUrl = alternateUrl;
|
|
237
|
+
updateCredentialsCloudUrl(alternateUrl);
|
|
238
|
+
return syntheticResponse(alternateText, alternateRes.status);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
212
243
|
if (isBlockPage(text) && !useLocal) {
|
|
213
244
|
const fallbackRes = await attemptFetch(FALLBACK_CLOUD_URL);
|
|
214
245
|
if (fallbackRes) {
|
|
215
246
|
const fallbackText = await fallbackRes.text();
|
|
216
247
|
if (!isBlockPage(fallbackText)) {
|
|
248
|
+
activeCloudUrl = FALLBACK_CLOUD_URL;
|
|
249
|
+
updateCredentialsCloudUrl(FALLBACK_CLOUD_URL);
|
|
217
250
|
// Fallback succeeded — return a synthetic Response with the buffered text
|
|
218
251
|
return syntheticResponse(fallbackText, fallbackRes.status);
|
|
219
252
|
}
|
|
@@ -224,6 +257,7 @@ async function cloudFetch(urlPath, options = {}, token = null) {
|
|
|
224
257
|
}
|
|
225
258
|
|
|
226
259
|
// Primary response is fine — return a synthetic Response with the buffered text
|
|
260
|
+
activeCloudUrl = primaryUrl;
|
|
227
261
|
return syntheticResponse(text, res.status);
|
|
228
262
|
}
|
|
229
263
|
|
|
@@ -330,7 +364,7 @@ function openBrowser(url) {
|
|
|
330
364
|
const platform = process.platform;
|
|
331
365
|
const cmd = platform === 'darwin' ? 'open'
|
|
332
366
|
: platform === 'win32' ? 'start'
|
|
333
|
-
|
|
367
|
+
: 'xdg-open';
|
|
334
368
|
exec(`${cmd} "${url}"`);
|
|
335
369
|
}
|
|
336
370
|
|
|
@@ -459,7 +493,7 @@ async function runLoginFlow({ jsonMode = false } = {}) {
|
|
|
459
493
|
if (data.pending) continue;
|
|
460
494
|
|
|
461
495
|
if (data.token) {
|
|
462
|
-
writeCredentials(data.token, getCloudUrl());
|
|
496
|
+
writeCredentials(data.token, activeCloudUrl || getCloudUrl());
|
|
463
497
|
log(' ✓ Logged in successfully\n');
|
|
464
498
|
return data.token;
|
|
465
499
|
}
|
|
@@ -477,7 +511,7 @@ async function runLoginFlow({ jsonMode = false } = {}) {
|
|
|
477
511
|
*/
|
|
478
512
|
async function runLegacyLoginFlow() {
|
|
479
513
|
const nonce = crypto.randomBytes(32).toString('hex');
|
|
480
|
-
const
|
|
514
|
+
const initialCloudUrl = getCloudUrl();
|
|
481
515
|
|
|
482
516
|
console.log(' → Registering session...');
|
|
483
517
|
const createRes = await cloudFetch('/api/auth/connect', {
|
|
@@ -493,7 +527,8 @@ async function runLegacyLoginFlow() {
|
|
|
493
527
|
process.exit(1);
|
|
494
528
|
}
|
|
495
529
|
|
|
496
|
-
const
|
|
530
|
+
const effectiveCloudUrl = activeCloudUrl || initialCloudUrl;
|
|
531
|
+
const loginUrl = `${effectiveCloudUrl}/auth/connect?session=${nonce}`;
|
|
497
532
|
console.log(' → Opening browser for authentication...');
|
|
498
533
|
openBrowser(loginUrl);
|
|
499
534
|
|
|
@@ -514,7 +549,7 @@ async function runLegacyLoginFlow() {
|
|
|
514
549
|
if (data.pending) continue;
|
|
515
550
|
|
|
516
551
|
if (data.token) {
|
|
517
|
-
writeCredentials(data.token,
|
|
552
|
+
writeCredentials(data.token, activeCloudUrl || initialCloudUrl);
|
|
518
553
|
console.log(' ✓ Logged in successfully');
|
|
519
554
|
return data.token;
|
|
520
555
|
}
|
|
@@ -552,22 +587,21 @@ export async function ensureAuthenticated() {
|
|
|
552
587
|
* Returns { orgId, courseId, orgName } or prompts the user.
|
|
553
588
|
*/
|
|
554
589
|
async function resolveOrgAndCourse(slug, token) {
|
|
555
|
-
//
|
|
590
|
+
// Shared binding (committed): cloudId in .coursecoderc.json.
|
|
591
|
+
// Local binding (per-user): orgId/courseId in .coursecode/project.json.
|
|
592
|
+
// This keeps login global while allowing per-course auth context.
|
|
556
593
|
const rcConfig = readRcConfig();
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
// which will match on courseId
|
|
594
|
+
const projectConfig = readProjectConfig();
|
|
595
|
+
const rcCloudId = rcConfig?.cloudId;
|
|
596
|
+
const localOrgId = projectConfig?.orgId;
|
|
597
|
+
const localCourseId = projectConfig?.courseId;
|
|
598
|
+
|
|
599
|
+
if (rcCloudId && localOrgId) {
|
|
600
|
+
return { orgId: localOrgId, courseId: rcCloudId };
|
|
565
601
|
}
|
|
566
602
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if (projectConfig?.orgId && projectConfig?.courseId) {
|
|
570
|
-
return { orgId: projectConfig.orgId, courseId: projectConfig.courseId };
|
|
603
|
+
if (localOrgId && localCourseId) {
|
|
604
|
+
return { orgId: localOrgId, courseId: localCourseId };
|
|
571
605
|
}
|
|
572
606
|
|
|
573
607
|
// Call resolve endpoint
|
|
@@ -576,8 +610,8 @@ async function resolveOrgAndCourse(slug, token) {
|
|
|
576
610
|
|
|
577
611
|
// Found in exactly one org
|
|
578
612
|
if (data.found) {
|
|
579
|
-
|
|
580
|
-
|
|
613
|
+
writeProjectConfig({ slug, orgId: data.orgId, courseId: data.courseId });
|
|
614
|
+
writeRcConfig({ cloudId: data.courseId, orgId: data.orgId });
|
|
581
615
|
return { orgId: data.orgId, courseId: data.courseId, orgName: data.orgName };
|
|
582
616
|
}
|
|
583
617
|
|
|
@@ -594,8 +628,8 @@ async function resolveOrgAndCourse(slug, token) {
|
|
|
594
628
|
process.exit(1);
|
|
595
629
|
}
|
|
596
630
|
const match = data.matches[idx];
|
|
597
|
-
|
|
598
|
-
|
|
631
|
+
writeProjectConfig({ slug, orgId: match.orgId, courseId: match.courseId });
|
|
632
|
+
writeRcConfig({ cloudId: match.courseId, orgId: match.orgId });
|
|
599
633
|
return { orgId: match.orgId, courseId: match.courseId, orgName: match.orgName };
|
|
600
634
|
}
|
|
601
635
|
|
|
@@ -675,16 +709,16 @@ export async function login(options = {}) {
|
|
|
675
709
|
}
|
|
676
710
|
|
|
677
711
|
/**
|
|
678
|
-
* coursecode logout — delete credentials
|
|
712
|
+
* coursecode logout — delete Cloud credentials
|
|
679
713
|
*/
|
|
680
|
-
export async function logout() {
|
|
714
|
+
export async function logout(options = {}) {
|
|
681
715
|
deleteCredentials();
|
|
682
716
|
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
717
|
+
if (options.json) {
|
|
718
|
+
process.stdout.write(JSON.stringify({ success: true }) + '\n');
|
|
719
|
+
} else {
|
|
720
|
+
console.log('\n✓ Logged out of CourseCode Cloud.\n');
|
|
721
|
+
}
|
|
688
722
|
}
|
|
689
723
|
|
|
690
724
|
/**
|
|
@@ -719,7 +753,7 @@ export async function whoami(options = {}) {
|
|
|
719
753
|
/**
|
|
720
754
|
* coursecode courses — list courses across all orgs
|
|
721
755
|
*/
|
|
722
|
-
export async function listCourses() {
|
|
756
|
+
export async function listCourses(options = {}) {
|
|
723
757
|
await ensureAuthenticated();
|
|
724
758
|
|
|
725
759
|
const makeRequest = async (_isRetry = false) => {
|
|
@@ -730,6 +764,11 @@ export async function listCourses() {
|
|
|
730
764
|
|
|
731
765
|
const courses = await makeRequest();
|
|
732
766
|
|
|
767
|
+
if (options.json) {
|
|
768
|
+
process.stdout.write(JSON.stringify(courses) + '\n');
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
733
772
|
if (!courses.length) {
|
|
734
773
|
console.log('\n No courses found. Deploy one with: coursecode deploy\n');
|
|
735
774
|
return;
|
|
@@ -763,8 +802,24 @@ export async function deploy(options = {}) {
|
|
|
763
802
|
|
|
764
803
|
await ensureAuthenticated();
|
|
765
804
|
const slug = resolveSlug();
|
|
805
|
+
const log = (...args) => { if (!options.json) console.log(...args); };
|
|
806
|
+
const logErr = (...args) => { if (!options.json) console.error(...args); };
|
|
766
807
|
|
|
767
|
-
|
|
808
|
+
// Validate mutually exclusive flags
|
|
809
|
+
if (options.promote && options.stage) {
|
|
810
|
+
logErr('\n❌ --promote and --stage are mutually exclusive.\n');
|
|
811
|
+
if (options.json) process.stdout.write(JSON.stringify({ success: false, error: '--promote and --stage are mutually exclusive' }) + '\n');
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Determine promote_mode and preview_force
|
|
816
|
+
const promoteMode = options.promote ? 'promote' : options.stage ? 'stage' : 'auto';
|
|
817
|
+
const previewForce = !!options.preview;
|
|
818
|
+
// --preview alone = preview_only deploy (production pointer untouched, preview always moved)
|
|
819
|
+
// --preview + --promote/--stage = full production deploy + always move preview pointer
|
|
820
|
+
const previewOnly = previewForce && promoteMode === 'auto';
|
|
821
|
+
|
|
822
|
+
log('\n📦 Building...\n');
|
|
768
823
|
|
|
769
824
|
// Step 1: Build
|
|
770
825
|
const { build } = await import('./build.js');
|
|
@@ -773,7 +828,8 @@ export async function deploy(options = {}) {
|
|
|
773
828
|
// Step 2: Verify dist/ exists
|
|
774
829
|
const distPath = path.join(process.cwd(), 'dist');
|
|
775
830
|
if (!fs.existsSync(distPath)) {
|
|
776
|
-
|
|
831
|
+
logErr('\n❌ Build did not produce a dist/ directory.\n');
|
|
832
|
+
if (options.json) process.stdout.write(JSON.stringify({ success: false, error: 'Build did not produce a dist/ directory' }) + '\n');
|
|
777
833
|
process.exit(1);
|
|
778
834
|
}
|
|
779
835
|
|
|
@@ -786,13 +842,28 @@ export async function deploy(options = {}) {
|
|
|
786
842
|
await zipDirectory(distPath, zipPath);
|
|
787
843
|
|
|
788
844
|
// Step 5: Upload
|
|
789
|
-
|
|
790
|
-
|
|
845
|
+
let modeLabel;
|
|
846
|
+
if (previewOnly) {
|
|
847
|
+
modeLabel = 'preview only';
|
|
848
|
+
} else if (options.promote && options.preview) {
|
|
849
|
+
modeLabel = 'force-promote + preview';
|
|
850
|
+
} else if (options.stage && options.preview) {
|
|
851
|
+
modeLabel = 'staged + preview';
|
|
852
|
+
} else if (options.promote) {
|
|
853
|
+
modeLabel = 'force-promote';
|
|
854
|
+
} else if (options.stage) {
|
|
855
|
+
modeLabel = 'staged';
|
|
856
|
+
} else {
|
|
857
|
+
modeLabel = 'production';
|
|
858
|
+
}
|
|
859
|
+
log(`\nDeploying ${slug}${displayOrg} [${modeLabel}]...\n`);
|
|
791
860
|
|
|
792
861
|
const formData = new FormData();
|
|
793
862
|
const zipBuffer = fs.readFileSync(zipPath);
|
|
794
863
|
formData.append('file', new Blob([zipBuffer], { type: 'application/zip' }), 'deploy.zip');
|
|
795
864
|
formData.append('orgId', orgId);
|
|
865
|
+
formData.append('promote_mode', promoteMode);
|
|
866
|
+
formData.append('preview_force', String(previewForce));
|
|
796
867
|
|
|
797
868
|
if (options.message) {
|
|
798
869
|
formData.append('message', options.message);
|
|
@@ -803,7 +874,7 @@ export async function deploy(options = {}) {
|
|
|
803
874
|
formData.append('password', pw);
|
|
804
875
|
}
|
|
805
876
|
|
|
806
|
-
const queryString =
|
|
877
|
+
const queryString = previewOnly ? '?mode=preview' : '';
|
|
807
878
|
|
|
808
879
|
const makeRequest = async (_isRetry = false) => {
|
|
809
880
|
const token = readCredentials()?.token;
|
|
@@ -817,29 +888,34 @@ export async function deploy(options = {}) {
|
|
|
817
888
|
|
|
818
889
|
const result = await makeRequest();
|
|
819
890
|
|
|
820
|
-
// Step 6:
|
|
891
|
+
// Step 6: Persist per-user binding and stamp cloud identity into .coursecoderc.json
|
|
821
892
|
const finalCourseId = result.courseId || courseId;
|
|
822
|
-
writeProjectConfig({
|
|
893
|
+
writeProjectConfig({ slug, orgId: result.orgId || orgId, courseId: finalCourseId });
|
|
894
|
+
writeRcConfig({
|
|
895
|
+
cloudId: finalCourseId,
|
|
823
896
|
orgId: result.orgId || orgId,
|
|
824
|
-
courseId: finalCourseId,
|
|
825
|
-
slug,
|
|
826
897
|
});
|
|
827
898
|
|
|
828
|
-
// Stamp cloudId into .coursecoderc.json (committed, shared with team)
|
|
829
|
-
const rc = readRcConfig();
|
|
830
|
-
if (finalCourseId && (!rc || rc.cloudId !== finalCourseId)) {
|
|
831
|
-
writeRcCloudId(finalCourseId);
|
|
832
|
-
}
|
|
833
|
-
|
|
834
899
|
// Step 7: Display result
|
|
835
|
-
if (
|
|
900
|
+
if (options.json) {
|
|
901
|
+
process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
|
|
902
|
+
} else if (result.mode === 'preview') {
|
|
903
|
+
// preview_only=true deployment (--preview alone)
|
|
836
904
|
console.log(`✓ Preview deployed (${result.fileCount} files)`);
|
|
837
|
-
console.log(` URL: ${result.url}`);
|
|
838
|
-
|
|
905
|
+
console.log(` Preview URL: ${result.url}`);
|
|
906
|
+
console.log(` Dashboard: ${result.dashboardUrl}`);
|
|
839
907
|
} else {
|
|
840
|
-
|
|
841
|
-
const
|
|
842
|
-
console.log(
|
|
908
|
+
const prodTag = result.promoted ? 'live' : 'staged';
|
|
909
|
+
const previewTag = result.previewPromoted ? ' + preview' : '';
|
|
910
|
+
console.log(`✓ Deployed (${result.fileCount} files) — ${prodTag}${previewTag}`);
|
|
911
|
+
if (!result.promoted) {
|
|
912
|
+
console.log(` Production pointer not updated. Promote from Deploy History or run:`);
|
|
913
|
+
console.log(` coursecode promote --production`);
|
|
914
|
+
}
|
|
915
|
+
if (result.previewPromoted) {
|
|
916
|
+
console.log(` Preview pointer updated.`);
|
|
917
|
+
}
|
|
918
|
+
console.log(` Dashboard: ${result.dashboardUrl}`);
|
|
843
919
|
}
|
|
844
920
|
console.log('');
|
|
845
921
|
|
|
@@ -847,15 +923,109 @@ export async function deploy(options = {}) {
|
|
|
847
923
|
try { fs.unlinkSync(zipPath); } catch { /* fine */ }
|
|
848
924
|
}
|
|
849
925
|
|
|
926
|
+
/**
|
|
927
|
+
* coursecode promote — promote a deployment to production or preview
|
|
928
|
+
*/
|
|
929
|
+
export async function promote(options = {}) {
|
|
930
|
+
await ensureAuthenticated();
|
|
931
|
+
const slug = resolveSlug();
|
|
932
|
+
const rcConfig = readRcConfig();
|
|
933
|
+
|
|
934
|
+
// Validate target flag
|
|
935
|
+
if (!options.production && !options.preview) {
|
|
936
|
+
console.error('\n❌ Specify a target: --production or --preview\n');
|
|
937
|
+
process.exit(1);
|
|
938
|
+
}
|
|
939
|
+
if (options.production && options.preview) {
|
|
940
|
+
console.error('\n❌ Specify only one target: --production or --preview\n');
|
|
941
|
+
process.exit(1);
|
|
942
|
+
}
|
|
943
|
+
const target = options.production ? 'production' : 'preview';
|
|
944
|
+
|
|
945
|
+
// Resolve deployment ID interactively if not provided
|
|
946
|
+
let deploymentId = options.deployment;
|
|
947
|
+
if (!deploymentId) {
|
|
948
|
+
const orgQuery = rcConfig?.orgId ? `?orgId=${rcConfig.orgId}` : '';
|
|
949
|
+
const makeVersionsRequest = async (_isRetry = false) => {
|
|
950
|
+
const token = readCredentials()?.token;
|
|
951
|
+
const res = await cloudFetch(
|
|
952
|
+
`/api/cli/courses/${encodeURIComponent(slug)}/versions${orgQuery}`,
|
|
953
|
+
{},
|
|
954
|
+
token
|
|
955
|
+
);
|
|
956
|
+
return handleResponse(res, { retryFn: makeVersionsRequest, _isRetry });
|
|
957
|
+
};
|
|
958
|
+
const data = await makeVersionsRequest();
|
|
959
|
+
const deployments = data.deployments ?? [];
|
|
960
|
+
|
|
961
|
+
if (deployments.length === 0) {
|
|
962
|
+
console.error('\n❌ No deployments found for this course.\n');
|
|
963
|
+
process.exit(1);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
console.log(`\n Deployments for ${slug}:\n`);
|
|
967
|
+
deployments.slice(0, 10).forEach((d, i) => {
|
|
968
|
+
const marker = d.id === data.production_deployment_id
|
|
969
|
+
? ' [production]'
|
|
970
|
+
: d.id === data.preview_deployment_id
|
|
971
|
+
? ' [preview]'
|
|
972
|
+
: '';
|
|
973
|
+
console.log(` ${i + 1}. ${new Date(d.created_at).toLocaleString()} — ${d.file_count} files${marker}`);
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
const answer = await prompt('\n Which deployment to promote? ');
|
|
977
|
+
const idx = parseInt(answer, 10) - 1;
|
|
978
|
+
if (idx < 0 || idx >= deployments.length) {
|
|
979
|
+
console.error('\n❌ Invalid selection.\n');
|
|
980
|
+
process.exit(1);
|
|
981
|
+
}
|
|
982
|
+
deploymentId = deployments[idx].id;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const reason = options.message || `Promoted to ${target} via CLI`;
|
|
986
|
+
|
|
987
|
+
const makeRequest = async (_isRetry = false) => {
|
|
988
|
+
const token = readCredentials()?.token;
|
|
989
|
+
const res = await cloudFetch(
|
|
990
|
+
`/api/cli/courses/${encodeURIComponent(slug)}/promote`,
|
|
991
|
+
{
|
|
992
|
+
method: 'POST',
|
|
993
|
+
headers: { 'Content-Type': 'application/json' },
|
|
994
|
+
body: JSON.stringify({ deployment_id: deploymentId, target, reason }),
|
|
995
|
+
},
|
|
996
|
+
token
|
|
997
|
+
);
|
|
998
|
+
return handleResponse(res, { retryFn: makeRequest, _isRetry });
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
const result = await makeRequest();
|
|
1002
|
+
|
|
1003
|
+
if (options.json) {
|
|
1004
|
+
process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (result.already_promoted) {
|
|
1009
|
+
console.log(`\n Already the active ${target} deployment. Nothing to do.\n`);
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
console.log(`\n✓ Promoted to ${target}`);
|
|
1014
|
+
if (result.url) {
|
|
1015
|
+
console.log(` Preview URL: ${result.url}`);
|
|
1016
|
+
}
|
|
1017
|
+
console.log('');
|
|
1018
|
+
}
|
|
1019
|
+
|
|
850
1020
|
/**
|
|
851
1021
|
* coursecode status — show deployment status for current course
|
|
852
1022
|
*/
|
|
853
|
-
export async function status() {
|
|
1023
|
+
export async function status(options = {}) {
|
|
854
1024
|
await ensureAuthenticated();
|
|
855
1025
|
const slug = resolveSlug();
|
|
856
1026
|
|
|
857
|
-
const
|
|
858
|
-
const orgQuery =
|
|
1027
|
+
const rcConfig = readRcConfig();
|
|
1028
|
+
const orgQuery = rcConfig?.orgId ? `?orgId=${rcConfig.orgId}` : '';
|
|
859
1029
|
|
|
860
1030
|
const makeRequest = async (_isRetry = false) => {
|
|
861
1031
|
const token = readCredentials()?.token;
|
|
@@ -869,8 +1039,20 @@ export async function status() {
|
|
|
869
1039
|
|
|
870
1040
|
const data = await makeRequest();
|
|
871
1041
|
|
|
1042
|
+
if (options.json) {
|
|
1043
|
+
process.stdout.write(JSON.stringify(data) + '\n');
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
872
1047
|
console.log(`\n${data.slug} — ${data.name} (${data.orgName})\n`);
|
|
873
1048
|
|
|
1049
|
+
if (data.source_type === 'github' && data.github_repo) {
|
|
1050
|
+
console.log(`Source: GitHub — ${data.github_repo}`);
|
|
1051
|
+
console.log(` (changes deploy via GitHub, not direct upload)`);
|
|
1052
|
+
} else if (data.source_type) {
|
|
1053
|
+
console.log(`Source: ${data.source_type}`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
874
1056
|
if (data.lastDeploy) {
|
|
875
1057
|
console.log(`Last deploy: ${formatDate(data.lastDeploy)} (${data.lastDeployFileCount} files, ${formatBytes(data.lastDeploySize)})`);
|
|
876
1058
|
} else {
|
|
@@ -888,6 +1070,74 @@ export async function status() {
|
|
|
888
1070
|
console.log('');
|
|
889
1071
|
}
|
|
890
1072
|
|
|
1073
|
+
/**
|
|
1074
|
+
* coursecode delete — remove course record from CourseCode Cloud.
|
|
1075
|
+
*
|
|
1076
|
+
* Cloud-only: this command does not delete local files. CLI users can remove
|
|
1077
|
+
* their project directory themselves; the Desktop handles local deletion via
|
|
1078
|
+
* shell.trashItem after calling this command.
|
|
1079
|
+
*
|
|
1080
|
+
* Response includes source_type + github_repo so callers can warn the user
|
|
1081
|
+
* when the deleted course was GitHub-linked (repo is unaffected, integration
|
|
1082
|
+
* is only disconnected on the Cloud side).
|
|
1083
|
+
*/
|
|
1084
|
+
export async function deleteCourse(options = {}) {
|
|
1085
|
+
await ensureAuthenticated();
|
|
1086
|
+
const slug = resolveSlug();
|
|
1087
|
+
const log = (...args) => { if (!options.json) console.log(...args); };
|
|
1088
|
+
|
|
1089
|
+
const rcConfig = readRcConfig();
|
|
1090
|
+
if (!rcConfig?.cloudId) {
|
|
1091
|
+
if (options.json) {
|
|
1092
|
+
process.stdout.write(JSON.stringify({ success: false, error: 'Course has not been deployed to Cloud. Nothing to delete.' }) + '\n');
|
|
1093
|
+
} else {
|
|
1094
|
+
console.error('\n❌ Course has not been deployed to Cloud. Nothing to delete.\n');
|
|
1095
|
+
}
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const orgQuery = rcConfig?.orgId ? `?orgId=${rcConfig.orgId}` : '';
|
|
1100
|
+
|
|
1101
|
+
if (!options.force && !options.json) {
|
|
1102
|
+
const answer = await prompt(`\n Delete "${slug}" from CourseCode Cloud? This cannot be undone. [y/N] `);
|
|
1103
|
+
if (answer.toLowerCase() !== 'y') {
|
|
1104
|
+
console.log(' Cancelled.\n');
|
|
1105
|
+
process.exit(0);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
log(`\nDeleting ${slug} from Cloud...\n`);
|
|
1110
|
+
|
|
1111
|
+
const makeRequest = async (_isRetry = false) => {
|
|
1112
|
+
const token = readCredentials()?.token;
|
|
1113
|
+
const res = await cloudFetch(
|
|
1114
|
+
`/api/cli/courses/${encodeURIComponent(slug)}${orgQuery}`,
|
|
1115
|
+
{
|
|
1116
|
+
method: 'DELETE',
|
|
1117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1118
|
+
body: JSON.stringify({ cloudId: rcConfig.cloudId }),
|
|
1119
|
+
},
|
|
1120
|
+
token
|
|
1121
|
+
);
|
|
1122
|
+
return handleResponse(res, { retryFn: makeRequest, _isRetry });
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
const result = await makeRequest();
|
|
1126
|
+
|
|
1127
|
+
if (options.json) {
|
|
1128
|
+
process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
console.log(`✓ "${slug}" deleted from CourseCode Cloud.`);
|
|
1133
|
+
if (result.source_type === 'github' && result.github_repo) {
|
|
1134
|
+
console.log(`\n ⚠️ This course was linked to GitHub (${result.github_repo}).`);
|
|
1135
|
+
console.log(` The GitHub integration has been disconnected.`);
|
|
1136
|
+
console.log(` Your repository and its files are unaffected.`);
|
|
1137
|
+
}
|
|
1138
|
+
console.log('');
|
|
1139
|
+
}
|
|
1140
|
+
|
|
891
1141
|
// =============================================================================
|
|
892
1142
|
// ZIP HELPER
|
|
893
1143
|
// =============================================================================
|
package/lib/convert.js
CHANGED
|
@@ -18,7 +18,8 @@ export async function convert(source, options) {
|
|
|
18
18
|
format = 'all',
|
|
19
19
|
dryRun = false,
|
|
20
20
|
overwrite = false,
|
|
21
|
-
flatten = false
|
|
21
|
+
flatten = false,
|
|
22
|
+
pdfJson = false
|
|
22
23
|
} = options;
|
|
23
24
|
|
|
24
25
|
console.log('\n📄 Converting documents to markdown...\n');
|
|
@@ -111,8 +112,8 @@ export async function convert(source, options) {
|
|
|
111
112
|
// Write output
|
|
112
113
|
await fs.writeFile(outputFile, result.markdown, 'utf-8');
|
|
113
114
|
|
|
114
|
-
//
|
|
115
|
-
if (result.data) {
|
|
115
|
+
// PDF structure sidecar is optional because it is much larger/token-heavier than markdown.
|
|
116
|
+
if (result.data && pdfJson) {
|
|
116
117
|
const jsonPath = outputFile.replace(/\.md$/, '.json');
|
|
117
118
|
await fs.writeFile(jsonPath, JSON.stringify(result.data, null, 2), 'utf-8');
|
|
118
119
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Enforces real LMS behavior — lifecycle violations return 'false' with proper
|
|
10
10
|
* error codes, read-only elements reject SetValue, and format-specific rules
|
|
11
11
|
* are enforced. This catches bugs that silently pass in dev but explode in
|
|
12
|
-
* production LMSs like
|
|
12
|
+
* production SCORM/cmi5/LTI environments and LMSs like Moodle or Cornerstone.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const isBrowser = typeof window !== 'undefined';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coursecode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
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": {
|
|
@@ -125,4 +125,4 @@
|
|
|
125
125
|
"vite-plugin-static-copy": "^3.1.4",
|
|
126
126
|
"vitest": "^4.0.18"
|
|
127
127
|
}
|
|
128
|
-
}
|
|
128
|
+
}
|