coursecode 0.1.9 → 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
@@ -21,23 +21,23 @@ import path from 'path';
21
21
  import fs from 'fs';
22
22
 
23
23
  // =============================================================================
24
- // CORPORATE NETWORK: System CA cert auto-injection
24
+ // CORPORATE NETWORK: System CA cert injection
25
25
  //
26
26
  // On corporate machines with SSL-inspecting proxies (e.g. Zscaler), the proxy
27
27
  // presents its own CA certificate. Node.js ships its own CA bundle and ignores
28
28
  // the OS trust store, so TLS verification fails.
29
29
  //
30
- // Fix: export the OS root cert store to a temp PEM file and re-exec with
31
- // NODE_EXTRA_CA_CERTS pointing to it. Node picks it up before any TLS
32
- // handshakes. Transparent to users completes in < 200ms.
33
- //
34
- // The NODE_EXTRA_CA_CERTS guard prevents an infinite re-exec loop.
30
+ // Windows: win-ca injects certs directly into Node's TLS context (in-process,
31
+ // no subprocess, no re-exec). Works regardless of PowerShell policy.
32
+ // macOS/Linux: exports OS certs to a temp PEM file and re-execs with
33
+ // NODE_EXTRA_CA_CERTS. The guard prevents an infinite loop.
35
34
  // =============================================================================
36
35
 
37
36
  if (!process.env.NODE_EXTRA_CA_CERTS) {
38
- const { exportSystemCerts } = await import('../lib/cloud-certs.js');
39
- const certPath = await exportSystemCerts();
37
+ const { injectSystemCerts } = await import('../lib/cloud-certs.js');
38
+ const certPath = await injectSystemCerts();
40
39
  if (certPath) {
40
+ // macOS/Linux: re-exec with NODE_EXTRA_CA_CERTS pointing to PEM file
41
41
  const { execFileSync } = await import('child_process');
42
42
  execFileSync(process.execPath, process.argv.slice(1), {
43
43
  env: { ...process.env, NODE_EXTRA_CA_CERTS: certPath },
@@ -45,6 +45,7 @@ if (!process.env.NODE_EXTRA_CA_CERTS) {
45
45
  });
46
46
  process.exit(0);
47
47
  }
48
+ // Windows: win-ca already injected certs in-process — continue normally
48
49
  }
49
50
 
50
51
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -316,10 +317,11 @@ program
316
317
  .command('logout')
317
318
  .description('Log out of CourseCode Cloud')
318
319
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
320
+ .option('--json', 'Emit machine-readable JSON')
319
321
  .action(async (options) => {
320
322
  const { logout, setLocalMode } = await import('../lib/cloud.js');
321
323
  if (options.local) setLocalMode();
322
- await logout();
324
+ await logout({ json: options.json });
323
325
  });
324
326
 
325
327
  program
@@ -337,33 +339,66 @@ program
337
339
  .command('courses')
338
340
  .description('List courses on CourseCode Cloud')
339
341
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
342
+ .option('--json', 'Output raw JSON array')
340
343
  .action(async (options) => {
341
344
  const { listCourses, setLocalMode } = await import('../lib/cloud.js');
342
345
  if (options.local) setLocalMode();
343
- await listCourses();
346
+ await listCourses({ json: options.json });
344
347
  });
345
348
 
346
349
  program
347
350
  .command('deploy')
348
351
  .description('Build and deploy course to CourseCode Cloud')
349
- .option('--preview', 'Deploy as preview (expires in 7 days)')
350
- .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)')
351
356
  .option('-m, --message <message>', 'Deploy reason (e.g. "Fixed accessibility issues")')
352
357
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
358
+ .option('--json', 'Emit machine-readable JSON result')
353
359
  .action(async (options) => {
354
360
  const { deploy, setLocalMode } = await import('../lib/cloud.js');
355
361
  if (options.local) setLocalMode();
356
362
  await deploy(options);
357
363
  });
358
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
+
359
381
  program
360
382
  .command('status')
361
383
  .description('Show deployment status for current course')
362
384
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
385
+ .option('--json', 'Output raw JSON')
363
386
  .action(async (options) => {
364
387
  const { status, setLocalMode } = await import('../lib/cloud.js');
365
388
  if (options.local) setLocalMode();
366
- 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 });
367
402
  });
368
403
 
369
404
  program.parse();
@@ -136,6 +136,30 @@ em, i {
136
136
  color: var(--color-gray-800);
137
137
  }
138
138
 
139
+ /* Styled Scrollbars (cross-platform) */
140
+ html {
141
+ scrollbar-width: thin;
142
+ scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
143
+ }
144
+
145
+ ::-webkit-scrollbar {
146
+ width: 8px;
147
+ height: 8px;
148
+ }
149
+
150
+ ::-webkit-scrollbar-track {
151
+ background: var(--scrollbar-track);
152
+ }
153
+
154
+ ::-webkit-scrollbar-thumb {
155
+ background: var(--scrollbar-thumb);
156
+ border-radius: 4px;
157
+ }
158
+
159
+ ::-webkit-scrollbar-thumb:hover {
160
+ background: var(--scrollbar-thumb-hover);
161
+ }
162
+
139
163
  /* Link Styles */
140
164
  a {
141
165
  color: var(--color-primary);
@@ -441,6 +441,11 @@
441
441
  --sidebar-scrollbar-thumb-hover: var(--color-gray-400); /* Sidebar scrollbar thumb (hover) */
442
442
  --sidebar-backdrop: var(--bg-overlay); /* Sidebar overlay backdrop color */
443
443
 
444
+ /* Scrollbar Tokens (global, content area) */
445
+ --scrollbar-thumb: var(--color-gray-300); /* Scrollbar thumb */
446
+ --scrollbar-thumb-hover: var(--color-gray-400); /* Scrollbar thumb (hover) */
447
+ --scrollbar-track: transparent; /* Scrollbar track background */
448
+
444
449
  /* Footer Tokens */
445
450
  --footer-bg: var(--bg-surface); /* Footer background */
446
451
  --footer-text: var(--text-primary); /* Footer text color */
@@ -678,6 +683,9 @@
678
683
  --sidebar-scrollbar-thumb: var(--color-gray-500);
679
684
  --sidebar-scrollbar-thumb-hover: var(--color-gray-400);
680
685
  --sidebar-backdrop: color-mix(in srgb, var(--palette-black) 38%, transparent);
686
+ --scrollbar-thumb: var(--color-gray-500);
687
+ --scrollbar-thumb-hover: var(--color-gray-400);
688
+ --scrollbar-track: transparent;
681
689
  --footer-bg: var(--bg-surface);
682
690
  --footer-text: var(--text-primary);
683
691
  --footer-border: var(--border-default);
@@ -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.
@@ -1,15 +1,23 @@
1
1
  /**
2
- * cloud-certs.js — System CA certificate export for corporate network compatibility.
2
+ * cloud-certs.js — System CA certificate injection for corporate network compatibility.
3
3
  *
4
- * Exports the OS trusted root store to a temp PEM file so Node.js can verify
5
- * TLS connections that pass through SSL-inspecting proxies (e.g. Zscaler).
4
+ * On corporate machines with SSL-inspecting proxies (e.g. Zscaler), the proxy
5
+ * presents its own CA certificate. Node.js ships its own CA bundle and ignores
6
+ * the OS trust store, causing TLS verification failures.
6
7
  *
7
- * Returns the path to the PEM file on success, null on failure.
8
- * Never throws silent fallback for non-corporate machines.
8
+ * Platform strategy:
9
+ * - Windows: `win-ca` (native N-API addon, calls Windows CryptoAPI directly)
10
+ * - macOS: `security` CLI exports system keychains to PEM
11
+ * - Linux: reads well-known CA bundle file paths
12
+ *
13
+ * On macOS/Linux, returns a PEM file path for NODE_EXTRA_CA_CERTS.
14
+ * On Windows, injects certs directly into Node's TLS context (no file needed).
15
+ * Never throws — silent no-op on non-corporate machines.
9
16
  */
10
17
 
11
18
  import { execFile } from 'child_process';
12
19
  import { promisify } from 'util';
20
+ import { createRequire } from 'module';
13
21
  import fs from 'fs';
14
22
  import os from 'os';
15
23
  import path from 'path';
@@ -17,25 +25,34 @@ import crypto from 'crypto';
17
25
 
18
26
  const execFileAsync = promisify(execFile);
19
27
 
20
- /** Cached result — only export once per process lifetime. */
21
- let _cachedCertPath = undefined;
28
+ /** Cached result — only run once per process lifetime. */
29
+ let _applied = false;
22
30
 
23
31
  /**
24
- * Export the OS system root certificate store to a temp PEM file.
32
+ * Inject the OS system root certificates into Node's TLS context.
33
+ *
34
+ * On Windows, win-ca patches Node's TLS in-process (no file needed, no re-exec).
35
+ * On macOS/Linux, returns a PEM file path for NODE_EXTRA_CA_CERTS re-exec.
25
36
  *
26
- * @returns {Promise<string|null>} Absolute path to the PEM file, or null if unavailable.
37
+ * @returns {Promise<string|null>} PEM file path (macOS/Linux) or null (Windows/unavailable).
27
38
  */
28
- export async function exportSystemCerts() {
29
- if (_cachedCertPath !== undefined) return _cachedCertPath;
39
+ export async function injectSystemCerts() {
40
+ if (_applied) return null;
41
+ _applied = true;
30
42
 
31
43
  try {
32
- const pem = await readSystemCerts();
33
- if (!pem || !pem.trim()) {
34
- _cachedCertPath = null;
44
+ if (process.platform === 'win32') {
45
+ injectWindowsCerts();
35
46
  return null;
36
47
  }
37
48
 
38
- // Write to a stable temp path (same content = same hash, avoids accumulation)
49
+ // macOS/Linux: export to PEM file for NODE_EXTRA_CA_CERTS
50
+ const pem = process.platform === 'darwin'
51
+ ? await readMacosCerts()
52
+ : readLinuxCerts();
53
+
54
+ if (!pem || !pem.trim()) return null;
55
+
39
56
  const hash = crypto.createHash('sha1').update(pem).digest('hex').slice(0, 8);
40
57
  const certPath = path.join(os.tmpdir(), `coursecode-ca-${hash}.pem`);
41
58
 
@@ -43,31 +60,26 @@ export async function exportSystemCerts() {
43
60
  fs.writeFileSync(certPath, pem, { mode: 0o600 });
44
61
  }
45
62
 
46
- _cachedCertPath = certPath;
47
63
  return certPath;
48
64
  } catch {
49
- _cachedCertPath = null;
50
65
  return null;
51
66
  }
52
67
  }
53
68
 
54
69
  /**
55
- * Read system certificates as a PEM string.
56
- * Platform-specific — returns null if the platform is unsupported or export fails.
70
+ * Windows: inject system root certs via win-ca.
71
+ *
72
+ * win-ca is a native N-API addon that calls Windows CryptoAPI directly.
73
+ * No PowerShell, no subprocesses, no temp files. Works regardless of
74
+ * execution policy, AppLocker, or PowerShell availability.
75
+ *
76
+ * The { inject: '+' } mode patches tls.createSecureContext() so system
77
+ * certs are used *in addition to* Node's built-in CA bundle.
57
78
  */
58
- async function readSystemCerts() {
59
- const platform = process.platform;
60
-
61
- if (platform === 'darwin') {
62
- return readMacosCerts();
63
- }
64
-
65
- if (platform === 'win32') {
66
- return readWindowsCerts();
67
- }
68
-
69
- // Linux/other: read well-known bundle paths directly
70
- return readLinuxCerts();
79
+ function injectWindowsCerts() {
80
+ const require = createRequire(import.meta.url);
81
+ const winCa = require('win-ca/api');
82
+ winCa({ inject: '+' });
71
83
  }
72
84
 
73
85
  /**
@@ -75,7 +87,6 @@ async function readSystemCerts() {
75
87
  * This includes all roots installed via Apple MDM / System Preferences.
76
88
  */
77
89
  async function readMacosCerts() {
78
- // Export from all common keychains — ignore errors on missing keychains
79
90
  const keychains = [
80
91
  '/Library/Keychains/SystemRootCertificates.keychain',
81
92
  '/System/Library/Keychains/SystemRootCertificates.keychain',
@@ -97,28 +108,6 @@ async function readMacosCerts() {
97
108
  return pems.join('\n');
98
109
  }
99
110
 
100
- /**
101
- * Windows: export LocalMachine\Root via PowerShell.
102
- * This includes certs deployed via Group Policy / MDM.
103
- */
104
- async function readWindowsCerts() {
105
- const script = [
106
- '$certs = Get-ChildItem -Path Cert:\\LocalMachine\\Root',
107
- '$pems = $certs | ForEach-Object {',
108
- ' $bytes = $_.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)',
109
- ' $b64 = [System.Convert]::ToBase64String($bytes, "InsertLineBreaks")',
110
- ' "-----BEGIN CERTIFICATE-----`n" + $b64 + "`n-----END CERTIFICATE-----"',
111
- '}',
112
- '$pems -join "`n"',
113
- ].join('; ');
114
-
115
- const { stdout } = await execFileAsync('powershell', [
116
- '-NoProfile', '-NonInteractive', '-Command', script,
117
- ], { maxBuffer: 16 * 1024 * 1024 });
118
-
119
- return stdout;
120
- }
121
-
122
111
  /**
123
112
  * Linux: read the system CA bundle from well-known locations.
124
113
  * No subprocess needed — just read the file directly.
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,26 +998,91 @@ 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
  // =============================================================================
894
1072
 
895
1073
  /**
896
- * Zip a directory's contents using the system `zip` command.
897
- * Falls back to a tar+gzip approach if zip isn't available.
1074
+ * Zip a directory's contents using archiver (cross-platform, no native tools needed).
898
1075
  */
899
- function zipDirectory(sourceDir, outputPath) {
1076
+ async function zipDirectory(sourceDir, outputPath) {
1077
+ const archiver = (await import('archiver')).default;
900
1078
  return new Promise((resolve, reject) => {
901
- // Use system zip: cd into dir so paths are relative
902
- exec(
903
- `cd "${sourceDir}" && zip -r -q "${outputPath}" .`,
904
- (error) => {
905
- if (error) {
906
- reject(new Error(`Failed to create zip: ${error.message}. Ensure 'zip' is installed.`));
907
- } else {
908
- resolve();
909
- }
910
- }
911
- );
1079
+ const output = fs.createWriteStream(outputPath);
1080
+ const archive = archiver('zip', { zlib: { level: 9 } });
1081
+
1082
+ output.on('close', () => resolve());
1083
+ archive.on('error', reject);
1084
+ archive.pipe(output);
1085
+ archive.directory(sourceDir, false);
1086
+ archive.finalize();
912
1087
  });
913
1088
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.9",
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": {
@@ -99,6 +99,7 @@
99
99
  },
100
100
  "dependencies": {
101
101
  "@modelcontextprotocol/sdk": "^1.0.0",
102
+ "archiver": "^7.0.1",
102
103
  "commander": "^14.0.3",
103
104
  "lz-string": "^1.5.0",
104
105
  "mammoth": "^1.8.0",
@@ -108,6 +109,7 @@
108
109
  "pdf2json": "^4.0.2",
109
110
  "pdf2md": "^1.0.2",
110
111
  "puppeteer-core": "^24.37.2",
112
+ "win-ca": "^3.5.1",
111
113
  "ws": "^8.18.0"
112
114
  },
113
115
  "devDependencies": {
@@ -115,7 +117,6 @@
115
117
  "@vitest/coverage-v8": "^4.0.18",
116
118
  "@xapi/cmi5": "^1.4.0",
117
119
  "acorn": "^8.15.0",
118
- "archiver": "^7.0.1",
119
120
  "eslint": "^10.0.0",
120
121
  "globals": "^17.3.0",
121
122
  "jose": "^6.1.3",
@@ -124,4 +125,4 @@
124
125
  "vite-plugin-static-copy": "^3.1.4",
125
126
  "vitest": "^4.0.18"
126
127
  }
127
- }
128
+ }