coursecode 0.1.10 → 0.1.11

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
@@ -317,10 +317,11 @@ program
317
317
  .command('logout')
318
318
  .description('Log out of CourseCode Cloud')
319
319
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
320
+ .option('--json', 'Emit machine-readable JSON')
320
321
  .action(async (options) => {
321
322
  const { logout, setLocalMode } = await import('../lib/cloud.js');
322
323
  if (options.local) setLocalMode();
323
- await logout();
324
+ await logout({ json: options.json });
324
325
  });
325
326
 
326
327
  program
@@ -338,33 +339,66 @@ program
338
339
  .command('courses')
339
340
  .description('List courses on CourseCode Cloud')
340
341
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
342
+ .option('--json', 'Output raw JSON array')
341
343
  .action(async (options) => {
342
344
  const { listCourses, setLocalMode } = await import('../lib/cloud.js');
343
345
  if (options.local) setLocalMode();
344
- await listCourses();
346
+ await listCourses({ json: options.json });
345
347
  });
346
348
 
347
349
  program
348
350
  .command('deploy')
349
351
  .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)')
352
+ .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.')
353
+ .option('--promote', 'Force-promote: always move production pointer regardless of deploy_mode setting. Mutually exclusive with --stage.')
354
+ .option('--stage', 'Force-stage: never move production pointer regardless of deploy_mode setting. Mutually exclusive with --promote.')
355
+ .option('--password', 'Password-protect preview (interactive prompt, requires --preview)')
352
356
  .option('-m, --message <message>', 'Deploy reason (e.g. "Fixed accessibility issues")')
353
357
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
358
+ .option('--json', 'Emit machine-readable JSON result')
354
359
  .action(async (options) => {
355
360
  const { deploy, setLocalMode } = await import('../lib/cloud.js');
356
361
  if (options.local) setLocalMode();
357
362
  await deploy(options);
358
363
  });
359
364
 
365
+ program
366
+ .command('promote')
367
+ .description('Promote a deployment to production or preview pointer')
368
+ .option('--production', 'Promote to the production pointer')
369
+ .option('--preview', 'Promote to the preview pointer')
370
+ .option('--deployment <id>', 'Deployment ID to promote (skip interactive prompt)')
371
+ .option('-m, --message <message>', 'Reason for promotion')
372
+ .option('--local', 'Use local Cloud instance (http://localhost:3000)')
373
+ .option('--json', 'Emit machine-readable JSON result')
374
+ .action(async (options) => {
375
+ const { promote, setLocalMode } = await import('../lib/cloud.js');
376
+ if (options.local) setLocalMode();
377
+ await promote(options);
378
+ });
379
+
380
+
360
381
  program
361
382
  .command('status')
362
383
  .description('Show deployment status for current course')
363
384
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
385
+ .option('--json', 'Output raw JSON')
364
386
  .action(async (options) => {
365
387
  const { status, setLocalMode } = await import('../lib/cloud.js');
366
388
  if (options.local) setLocalMode();
367
- await status();
389
+ await status({ json: options.json });
390
+ });
391
+
392
+ program
393
+ .command('delete')
394
+ .description('Remove course from CourseCode Cloud (does not delete local files)')
395
+ .option('--force', 'Skip confirmation prompt')
396
+ .option('--local', 'Use local Cloud instance (http://localhost:3000)')
397
+ .option('--json', 'Emit machine-readable JSON result')
398
+ .action(async (options) => {
399
+ const { deleteCourse, setLocalMode } = await import('../lib/cloud.js');
400
+ if (options.local) setLocalMode();
401
+ await deleteCourse({ force: options.force, json: options.json });
368
402
  });
369
403
 
370
404
  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
@@ -31,8 +31,6 @@ const LOCAL_CLOUD_URL = 'http://localhost:3000';
31
31
  let useLocal = false;
32
32
  const CREDENTIALS_DIR = path.join(os.homedir(), '.coursecode');
33
33
  const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, 'credentials.json');
34
- const PROJECT_CONFIG_DIR = '.coursecode';
35
- const PROJECT_CONFIG_PATH = path.join(PROJECT_CONFIG_DIR, 'project.json');
36
34
 
37
35
  const POLL_INTERVAL_MS = 2000;
38
36
  const POLL_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes (device code expiry)
@@ -110,29 +108,6 @@ export function setLocalMode() {
110
108
  useLocal = true;
111
109
  }
112
110
 
113
- // =============================================================================
114
- // PROJECT BINDING (local: .coursecode/project.json)
115
- // =============================================================================
116
-
117
- function readProjectConfig() {
118
- try {
119
- const fullPath = path.join(process.cwd(), PROJECT_CONFIG_PATH);
120
- if (!fs.existsSync(fullPath)) return null;
121
- return JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
122
- } catch {
123
- return null;
124
- }
125
- }
126
-
127
- function writeProjectConfig(data) {
128
- const dir = path.join(process.cwd(), PROJECT_CONFIG_DIR);
129
- fs.mkdirSync(dir, { recursive: true });
130
- fs.writeFileSync(
131
- path.join(process.cwd(), PROJECT_CONFIG_PATH),
132
- JSON.stringify(data, null, 2) + '\n'
133
- );
134
- }
135
-
136
111
  // =============================================================================
137
112
  // COURSE IDENTITY (committed: .coursecoderc.json → cloudId)
138
113
  // =============================================================================
@@ -151,12 +126,13 @@ function readRcConfig() {
151
126
  }
152
127
 
153
128
  /**
154
- * Stamp cloudId into .coursecoderc.json without clobbering other fields.
129
+ * Merge fields into .coursecoderc.json without clobbering unrelated fields.
130
+ * Use this for any cloud binding state (cloudId, orgId, etc.).
155
131
  */
156
- function writeRcCloudId(cloudId) {
132
+ function writeRcConfig(fields) {
157
133
  const rcPath = path.join(process.cwd(), '.coursecoderc.json');
158
134
  const existing = readRcConfig() || {};
159
- existing.cloudId = cloudId;
135
+ Object.assign(existing, fields);
160
136
  fs.writeFileSync(rcPath, JSON.stringify(existing, null, 2) + '\n');
161
137
  }
162
138
 
@@ -330,7 +306,7 @@ function openBrowser(url) {
330
306
  const platform = process.platform;
331
307
  const cmd = platform === 'darwin' ? 'open'
332
308
  : platform === 'win32' ? 'start'
333
- : 'xdg-open';
309
+ : 'xdg-open';
334
310
  exec(`${cmd} "${url}"`);
335
311
  }
336
312
 
@@ -552,22 +528,11 @@ export async function ensureAuthenticated() {
552
528
  * Returns { orgId, courseId, orgName } or prompts the user.
553
529
  */
554
530
  async function resolveOrgAndCourse(slug, token) {
555
- // 1. Check .coursecoderc.json for cloudId (committed, shared across team)
531
+ // .coursecoderc.json is committed and shared it is the single source of truth
532
+ // for the cloud binding. If both cloudId and orgId are present, short-circuit.
556
533
  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
565
- }
566
-
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 };
534
+ if (rcConfig?.cloudId && rcConfig?.orgId) {
535
+ return { orgId: rcConfig.orgId, courseId: rcConfig.cloudId };
571
536
  }
572
537
 
573
538
  // Call resolve endpoint
@@ -576,8 +541,7 @@ async function resolveOrgAndCourse(slug, token) {
576
541
 
577
542
  // Found in exactly one org
578
543
  if (data.found) {
579
- const binding = { orgId: data.orgId, courseId: data.courseId, slug };
580
- writeProjectConfig(binding);
544
+ writeRcConfig({ orgId: data.orgId, cloudId: data.courseId });
581
545
  return { orgId: data.orgId, courseId: data.courseId, orgName: data.orgName };
582
546
  }
583
547
 
@@ -594,8 +558,7 @@ async function resolveOrgAndCourse(slug, token) {
594
558
  process.exit(1);
595
559
  }
596
560
  const match = data.matches[idx];
597
- const binding = { orgId: match.orgId, courseId: match.courseId, slug };
598
- writeProjectConfig(binding);
561
+ writeRcConfig({ orgId: match.orgId, cloudId: match.courseId });
599
562
  return { orgId: match.orgId, courseId: match.courseId, orgName: match.orgName };
600
563
  }
601
564
 
@@ -677,14 +640,14 @@ export async function login(options = {}) {
677
640
  /**
678
641
  * coursecode logout — delete credentials and local project.json
679
642
  */
680
- export async function logout() {
643
+ export async function logout(options = {}) {
681
644
  deleteCredentials();
682
645
 
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');
646
+ if (options.json) {
647
+ process.stdout.write(JSON.stringify({ success: true }) + '\n');
648
+ } else {
649
+ console.log('\n✓ Logged out of CourseCode Cloud.\n');
650
+ }
688
651
  }
689
652
 
690
653
  /**
@@ -719,7 +682,7 @@ export async function whoami(options = {}) {
719
682
  /**
720
683
  * coursecode courses — list courses across all orgs
721
684
  */
722
- export async function listCourses() {
685
+ export async function listCourses(options = {}) {
723
686
  await ensureAuthenticated();
724
687
 
725
688
  const makeRequest = async (_isRetry = false) => {
@@ -730,6 +693,11 @@ export async function listCourses() {
730
693
 
731
694
  const courses = await makeRequest();
732
695
 
696
+ if (options.json) {
697
+ process.stdout.write(JSON.stringify(courses) + '\n');
698
+ return;
699
+ }
700
+
733
701
  if (!courses.length) {
734
702
  console.log('\n No courses found. Deploy one with: coursecode deploy\n');
735
703
  return;
@@ -763,8 +731,24 @@ export async function deploy(options = {}) {
763
731
 
764
732
  await ensureAuthenticated();
765
733
  const slug = resolveSlug();
734
+ const log = (...args) => { if (!options.json) console.log(...args); };
735
+ const logErr = (...args) => { if (!options.json) console.error(...args); };
766
736
 
767
- console.log('\n📦 Building...\n');
737
+ // Validate mutually exclusive flags
738
+ if (options.promote && options.stage) {
739
+ logErr('\n❌ --promote and --stage are mutually exclusive.\n');
740
+ if (options.json) process.stdout.write(JSON.stringify({ success: false, error: '--promote and --stage are mutually exclusive' }) + '\n');
741
+ process.exit(1);
742
+ }
743
+
744
+ // Determine promote_mode and preview_force
745
+ const promoteMode = options.promote ? 'promote' : options.stage ? 'stage' : 'auto';
746
+ const previewForce = !!options.preview;
747
+ // --preview alone = preview_only deploy (production pointer untouched, preview always moved)
748
+ // --preview + --promote/--stage = full production deploy + always move preview pointer
749
+ const previewOnly = previewForce && promoteMode === 'auto';
750
+
751
+ log('\n📦 Building...\n');
768
752
 
769
753
  // Step 1: Build
770
754
  const { build } = await import('./build.js');
@@ -773,7 +757,8 @@ export async function deploy(options = {}) {
773
757
  // Step 2: Verify dist/ exists
774
758
  const distPath = path.join(process.cwd(), 'dist');
775
759
  if (!fs.existsSync(distPath)) {
776
- console.error('\n❌ Build did not produce a dist/ directory.\n');
760
+ logErr('\n❌ Build did not produce a dist/ directory.\n');
761
+ if (options.json) process.stdout.write(JSON.stringify({ success: false, error: 'Build did not produce a dist/ directory' }) + '\n');
777
762
  process.exit(1);
778
763
  }
779
764
 
@@ -786,13 +771,28 @@ export async function deploy(options = {}) {
786
771
  await zipDirectory(distPath, zipPath);
787
772
 
788
773
  // Step 5: Upload
789
- const mode = options.preview ? 'preview' : 'production';
790
- console.log(`\nDeploying ${slug}${displayOrg} as ${mode}...\n`);
774
+ let modeLabel;
775
+ if (previewOnly) {
776
+ modeLabel = 'preview only';
777
+ } else if (options.promote && options.preview) {
778
+ modeLabel = 'force-promote + preview';
779
+ } else if (options.stage && options.preview) {
780
+ modeLabel = 'staged + preview';
781
+ } else if (options.promote) {
782
+ modeLabel = 'force-promote';
783
+ } else if (options.stage) {
784
+ modeLabel = 'staged';
785
+ } else {
786
+ modeLabel = 'production';
787
+ }
788
+ log(`\nDeploying ${slug}${displayOrg} [${modeLabel}]...\n`);
791
789
 
792
790
  const formData = new FormData();
793
791
  const zipBuffer = fs.readFileSync(zipPath);
794
792
  formData.append('file', new Blob([zipBuffer], { type: 'application/zip' }), 'deploy.zip');
795
793
  formData.append('orgId', orgId);
794
+ formData.append('promote_mode', promoteMode);
795
+ formData.append('preview_force', String(previewForce));
796
796
 
797
797
  if (options.message) {
798
798
  formData.append('message', options.message);
@@ -803,7 +803,7 @@ export async function deploy(options = {}) {
803
803
  formData.append('password', pw);
804
804
  }
805
805
 
806
- const queryString = options.preview ? '?mode=preview' : '';
806
+ const queryString = previewOnly ? '?mode=preview' : '';
807
807
 
808
808
  const makeRequest = async (_isRetry = false) => {
809
809
  const token = readCredentials()?.token;
@@ -817,29 +817,33 @@ export async function deploy(options = {}) {
817
817
 
818
818
  const result = await makeRequest();
819
819
 
820
- // Step 6: Write project.json + stamp cloudId
820
+ // Step 6: Stamp cloudId + orgId into .coursecoderc.json (committed, shared with team)
821
821
  const finalCourseId = result.courseId || courseId;
822
- writeProjectConfig({
822
+ writeRcConfig({
823
+ cloudId: finalCourseId,
823
824
  orgId: result.orgId || orgId,
824
- courseId: finalCourseId,
825
- slug,
826
825
  });
827
826
 
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
827
  // Step 7: Display result
835
- if (result.mode === 'preview') {
828
+ if (options.json) {
829
+ process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
830
+ } else if (result.mode === 'preview') {
831
+ // preview_only=true deployment (--preview alone)
836
832
  console.log(`✓ Preview deployed (${result.fileCount} files)`);
837
- console.log(` URL: ${result.url}`);
838
- if (result.expiresAt) console.log(` Expires: ${formatDate(result.expiresAt)}`);
833
+ console.log(` Preview URL: ${result.url}`);
834
+ console.log(` Dashboard: ${result.dashboardUrl}`);
839
835
  } 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}`);
836
+ const prodTag = result.promoted ? 'live' : 'staged';
837
+ const previewTag = result.previewPromoted ? ' + preview' : '';
838
+ console.log(`✓ Deployed (${result.fileCount} files) — ${prodTag}${previewTag}`);
839
+ if (!result.promoted) {
840
+ console.log(` Production pointer not updated. Promote from Deploy History or run:`);
841
+ console.log(` coursecode promote --production`);
842
+ }
843
+ if (result.previewPromoted) {
844
+ console.log(` Preview pointer updated.`);
845
+ }
846
+ console.log(` Dashboard: ${result.dashboardUrl}`);
843
847
  }
844
848
  console.log('');
845
849
 
@@ -847,15 +851,109 @@ export async function deploy(options = {}) {
847
851
  try { fs.unlinkSync(zipPath); } catch { /* fine */ }
848
852
  }
849
853
 
854
+ /**
855
+ * coursecode promote — promote a deployment to production or preview
856
+ */
857
+ export async function promote(options = {}) {
858
+ await ensureAuthenticated();
859
+ const slug = resolveSlug();
860
+ const rcConfig = readRcConfig();
861
+
862
+ // Validate target flag
863
+ if (!options.production && !options.preview) {
864
+ console.error('\n❌ Specify a target: --production or --preview\n');
865
+ process.exit(1);
866
+ }
867
+ if (options.production && options.preview) {
868
+ console.error('\n❌ Specify only one target: --production or --preview\n');
869
+ process.exit(1);
870
+ }
871
+ const target = options.production ? 'production' : 'preview';
872
+
873
+ // Resolve deployment ID interactively if not provided
874
+ let deploymentId = options.deployment;
875
+ if (!deploymentId) {
876
+ const orgQuery = rcConfig?.orgId ? `?orgId=${rcConfig.orgId}` : '';
877
+ const makeVersionsRequest = async (_isRetry = false) => {
878
+ const token = readCredentials()?.token;
879
+ const res = await cloudFetch(
880
+ `/api/cli/courses/${encodeURIComponent(slug)}/versions${orgQuery}`,
881
+ {},
882
+ token
883
+ );
884
+ return handleResponse(res, { retryFn: makeVersionsRequest, _isRetry });
885
+ };
886
+ const data = await makeVersionsRequest();
887
+ const deployments = data.deployments ?? [];
888
+
889
+ if (deployments.length === 0) {
890
+ console.error('\n❌ No deployments found for this course.\n');
891
+ process.exit(1);
892
+ }
893
+
894
+ console.log(`\n Deployments for ${slug}:\n`);
895
+ deployments.slice(0, 10).forEach((d, i) => {
896
+ const marker = d.id === data.production_deployment_id
897
+ ? ' [production]'
898
+ : d.id === data.preview_deployment_id
899
+ ? ' [preview]'
900
+ : '';
901
+ console.log(` ${i + 1}. ${new Date(d.created_at).toLocaleString()} — ${d.file_count} files${marker}`);
902
+ });
903
+
904
+ const answer = await prompt('\n Which deployment to promote? ');
905
+ const idx = parseInt(answer, 10) - 1;
906
+ if (idx < 0 || idx >= deployments.length) {
907
+ console.error('\n❌ Invalid selection.\n');
908
+ process.exit(1);
909
+ }
910
+ deploymentId = deployments[idx].id;
911
+ }
912
+
913
+ const reason = options.message || `Promoted to ${target} via CLI`;
914
+
915
+ const makeRequest = async (_isRetry = false) => {
916
+ const token = readCredentials()?.token;
917
+ const res = await cloudFetch(
918
+ `/api/cli/courses/${encodeURIComponent(slug)}/promote`,
919
+ {
920
+ method: 'POST',
921
+ headers: { 'Content-Type': 'application/json' },
922
+ body: JSON.stringify({ deployment_id: deploymentId, target, reason }),
923
+ },
924
+ token
925
+ );
926
+ return handleResponse(res, { retryFn: makeRequest, _isRetry });
927
+ };
928
+
929
+ const result = await makeRequest();
930
+
931
+ if (options.json) {
932
+ process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
933
+ return;
934
+ }
935
+
936
+ if (result.already_promoted) {
937
+ console.log(`\n Already the active ${target} deployment. Nothing to do.\n`);
938
+ return;
939
+ }
940
+
941
+ console.log(`\n✓ Promoted to ${target}`);
942
+ if (result.url) {
943
+ console.log(` Preview URL: ${result.url}`);
944
+ }
945
+ console.log('');
946
+ }
947
+
850
948
  /**
851
949
  * coursecode status — show deployment status for current course
852
950
  */
853
- export async function status() {
951
+ export async function status(options = {}) {
854
952
  await ensureAuthenticated();
855
953
  const slug = resolveSlug();
856
954
 
857
- const projectConfig = readProjectConfig();
858
- const orgQuery = projectConfig?.orgId ? `?orgId=${projectConfig.orgId}` : '';
955
+ const rcConfig = readRcConfig();
956
+ const orgQuery = rcConfig?.orgId ? `?orgId=${rcConfig.orgId}` : '';
859
957
 
860
958
  const makeRequest = async (_isRetry = false) => {
861
959
  const token = readCredentials()?.token;
@@ -869,8 +967,20 @@ export async function status() {
869
967
 
870
968
  const data = await makeRequest();
871
969
 
970
+ if (options.json) {
971
+ process.stdout.write(JSON.stringify(data) + '\n');
972
+ return;
973
+ }
974
+
872
975
  console.log(`\n${data.slug} — ${data.name} (${data.orgName})\n`);
873
976
 
977
+ if (data.source_type === 'github' && data.github_repo) {
978
+ console.log(`Source: GitHub — ${data.github_repo}`);
979
+ console.log(` (changes deploy via GitHub, not direct upload)`);
980
+ } else if (data.source_type) {
981
+ console.log(`Source: ${data.source_type}`);
982
+ }
983
+
874
984
  if (data.lastDeploy) {
875
985
  console.log(`Last deploy: ${formatDate(data.lastDeploy)} (${data.lastDeployFileCount} files, ${formatBytes(data.lastDeploySize)})`);
876
986
  } else {
@@ -888,6 +998,74 @@ export async function status() {
888
998
  console.log('');
889
999
  }
890
1000
 
1001
+ /**
1002
+ * coursecode delete — remove course record from CourseCode Cloud.
1003
+ *
1004
+ * Cloud-only: this command does not delete local files. CLI users can remove
1005
+ * their project directory themselves; the Desktop handles local deletion via
1006
+ * shell.trashItem after calling this command.
1007
+ *
1008
+ * Response includes source_type + github_repo so callers can warn the user
1009
+ * when the deleted course was GitHub-linked (repo is unaffected, integration
1010
+ * is only disconnected on the Cloud side).
1011
+ */
1012
+ export async function deleteCourse(options = {}) {
1013
+ await ensureAuthenticated();
1014
+ const slug = resolveSlug();
1015
+ const log = (...args) => { if (!options.json) console.log(...args); };
1016
+
1017
+ const rcConfig = readRcConfig();
1018
+ if (!rcConfig?.cloudId) {
1019
+ if (options.json) {
1020
+ process.stdout.write(JSON.stringify({ success: false, error: 'Course has not been deployed to Cloud. Nothing to delete.' }) + '\n');
1021
+ } else {
1022
+ console.error('\n❌ Course has not been deployed to Cloud. Nothing to delete.\n');
1023
+ }
1024
+ process.exit(1);
1025
+ }
1026
+
1027
+ const orgQuery = rcConfig?.orgId ? `?orgId=${rcConfig.orgId}` : '';
1028
+
1029
+ if (!options.force && !options.json) {
1030
+ const answer = await prompt(`\n Delete "${slug}" from CourseCode Cloud? This cannot be undone. [y/N] `);
1031
+ if (answer.toLowerCase() !== 'y') {
1032
+ console.log(' Cancelled.\n');
1033
+ process.exit(0);
1034
+ }
1035
+ }
1036
+
1037
+ log(`\nDeleting ${slug} from Cloud...\n`);
1038
+
1039
+ const makeRequest = async (_isRetry = false) => {
1040
+ const token = readCredentials()?.token;
1041
+ const res = await cloudFetch(
1042
+ `/api/cli/courses/${encodeURIComponent(slug)}${orgQuery}`,
1043
+ {
1044
+ method: 'DELETE',
1045
+ headers: { 'Content-Type': 'application/json' },
1046
+ body: JSON.stringify({ cloudId: rcConfig.cloudId }),
1047
+ },
1048
+ token
1049
+ );
1050
+ return handleResponse(res, { retryFn: makeRequest, _isRetry });
1051
+ };
1052
+
1053
+ const result = await makeRequest();
1054
+
1055
+ if (options.json) {
1056
+ process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
1057
+ return;
1058
+ }
1059
+
1060
+ console.log(`✓ "${slug}" deleted from CourseCode Cloud.`);
1061
+ if (result.source_type === 'github' && result.github_repo) {
1062
+ console.log(`\n ⚠️ This course was linked to GitHub (${result.github_repo}).`);
1063
+ console.log(` The GitHub integration has been disconnected.`);
1064
+ console.log(` Your repository and its files are unaffected.`);
1065
+ }
1066
+ console.log('');
1067
+ }
1068
+
891
1069
  // =============================================================================
892
1070
  // ZIP HELPER
893
1071
  // =============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
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
+ }