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 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 (expires in 7 days)')
351
- .option('--password', 'Password-protect preview (interactive prompt)')
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
- **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
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(data, null, 2) + '\n'
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
- * Stamp cloudId into .coursecoderc.json without clobbering other fields.
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 writeRcCloudId(cloudId) {
168
+ function writeRcConfig(fields) {
157
169
  const rcPath = path.join(process.cwd(), '.coursecoderc.json');
158
170
  const existing = readRcConfig() || {};
159
- existing.cloudId = cloudId;
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) return 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
- : 'xdg-open';
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 cloudUrl = getCloudUrl();
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 loginUrl = `${cloudUrl}/auth/connect?session=${nonce}`;
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, cloudUrl);
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
- // 1. Check .coursecoderc.json for cloudId (committed, shared across team)
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
- if (rcConfig?.cloudId) {
558
- // Still need orgId from local project.json if available
559
- const projectConfig = readProjectConfig();
560
- if (projectConfig?.orgId) {
561
- return { orgId: projectConfig.orgId, courseId: rcConfig.cloudId };
562
- }
563
- // Have cloudId but no orgId fall through to API resolution
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
- // 2. Check cached project config (gitignored, per-developer)
568
- const projectConfig = readProjectConfig();
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
- const binding = { orgId: data.orgId, courseId: data.courseId, slug };
580
- writeProjectConfig(binding);
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
- const binding = { orgId: match.orgId, courseId: match.courseId, slug };
598
- writeProjectConfig(binding);
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 and local project.json
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
- // Also delete local project.json if it exists
684
- const localConfig = path.join(process.cwd(), PROJECT_CONFIG_PATH);
685
- try { fs.unlinkSync(localConfig); } catch { /* not there */ }
686
-
687
- console.log('\n✓ Logged out of CourseCode Cloud.\n');
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
- console.log('\n📦 Building...\n');
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
- console.error('\n❌ Build did not produce a dist/ directory.\n');
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
- const mode = options.preview ? 'preview' : 'production';
790
- console.log(`\nDeploying ${slug}${displayOrg} as ${mode}...\n`);
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 = options.preview ? '?mode=preview' : '';
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: Write project.json + stamp cloudId
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 (result.mode === 'preview') {
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
- if (result.expiresAt) console.log(` Expires: ${formatDate(result.expiresAt)}`);
905
+ console.log(` Preview URL: ${result.url}`);
906
+ console.log(` Dashboard: ${result.dashboardUrl}`);
839
907
  } else {
840
- console.log(`✓ Deployed to production (${result.fileCount} files, ${formatBytes(result.size)})`);
841
- const cloudUrl = getCloudUrl();
842
- console.log(` ${cloudUrl}/dashboard/courses/${result.courseId}`);
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 projectConfig = readProjectConfig();
858
- const orgQuery = projectConfig?.orgId ? `?orgId=${projectConfig.orgId}` : '';
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
- // Write structured data if available (for AI analysis)
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 SCORM Cloud, Moodle, Cornerstone, etc.
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.10",
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
+ }