coursecode 0.1.14 → 0.1.16

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
@@ -393,6 +393,25 @@ program
393
393
  await status({ json: options.json, repairBinding: options.repairBinding });
394
394
  });
395
395
 
396
+ program
397
+ .command('preview-link')
398
+ .description('Show or update the Cloud preview link for the current course')
399
+ .option('--enable', 'Enable the preview link. Creates one if missing.')
400
+ .option('--disable', 'Disable the preview link')
401
+ .option('--password [password]', 'Set or update the preview password. Prompts if no value is provided.')
402
+ .option('--remove-password', 'Remove the preview password')
403
+ .option('--format <format>', 'Preview format: cmi5, scorm2004, scorm1.2')
404
+ .option('--expires-at <iso>', 'Set preview expiry timestamp (ISO 8601)')
405
+ .option('--expires-in-days <days>', 'Set preview expiry relative to now')
406
+ .option('--repair-binding', 'Clear a stale local Cloud binding if the remote course was deleted')
407
+ .option('--local', 'Use local Cloud instance (http://localhost:3000)')
408
+ .option('--json', 'Output raw JSON')
409
+ .action(async (options) => {
410
+ const { previewLink, setLocalMode } = await import('../lib/cloud.js');
411
+ if (options.local) setLocalMode();
412
+ await previewLink(options);
413
+ });
414
+
396
415
  program
397
416
  .command('delete')
398
417
  .description('Remove course from CourseCode Cloud (does not delete local files)')
@@ -1,10 +1,38 @@
1
1
  import { generateId } from '../utilities/utilities.js';
2
2
  import { logger } from '../utilities/logger.js';
3
3
 
4
+ /**
5
+ * Safely serialize any value for logging. Handles circular references,
6
+ * Error instances, and oversized payloads without throwing.
7
+ */
8
+ function safeStringify(data, maxLength = 4096) {
9
+ const seen = new WeakSet();
10
+ try {
11
+ const json = JSON.stringify(data, (key, value) => {
12
+ if (value instanceof Error) {
13
+ return { name: value.name, message: value.message, stack: value.stack };
14
+ }
15
+ if (typeof value === 'object' && value !== null) {
16
+ if (seen.has(value)) return '[Circular]';
17
+ seen.add(value);
18
+ }
19
+ return value;
20
+ }, 2);
21
+ if (json && json.length > maxLength) {
22
+ return json.slice(0, maxLength) + '...[truncated]';
23
+ }
24
+ return json;
25
+ } catch {
26
+ return `[Unserializable: ${typeof data}]`;
27
+ }
28
+ }
29
+
4
30
  class EventBus {
5
31
  constructor() {
6
32
  // Event listeners registry
7
33
  this.events = {};
34
+ // Re-entrancy guard — prevents infinite :error → log → :error cascade
35
+ this._emittingError = false;
8
36
  }
9
37
 
10
38
  /**
@@ -86,31 +114,50 @@ class EventBus {
86
114
  return false;
87
115
  }
88
116
 
89
- // Automatically log events that follow the ':error' naming convention
90
- if (event.endsWith(':error')) {
91
- logger.error(`[EventBus Error] ${event}:`, JSON.stringify(data, null, 2));
92
- }
117
+ const isErrorEvent = event.endsWith(':error');
93
118
 
94
- // Create a copy of listeners to avoid issues if listeners modify the array
95
- const listeners = [...this.events[event]];
96
- const onceListeners = [];
119
+ // Re-entrancy guard if we're already inside an :error emit,
120
+ // suppress to prevent infinite cascade
121
+ if (isErrorEvent) {
122
+ if (this._emittingError) {
123
+ console.warn(`[EventBus] Suppressed recursive error event: ${event}`);
124
+ return false;
125
+ }
126
+ this._emittingError = true;
127
+ }
97
128
 
98
- listeners.forEach(listener => {
99
- try {
100
- listener.callback(data);
129
+ try {
130
+ // Automatically log events that follow the ':error' naming convention
131
+ if (isErrorEvent) {
132
+ logger.error(`[EventBus Error] ${event}:`, safeStringify(data));
133
+ }
101
134
 
102
- // Track once listeners for removal
103
- if (listener.once) {
104
- onceListeners.push(listener.id);
135
+ // Create a copy of listeners to avoid issues if listeners modify the array
136
+ const listeners = [...this.events[event]];
137
+ const onceListeners = [];
138
+
139
+ listeners.forEach(listener => {
140
+ try {
141
+ listener.callback(data);
142
+
143
+ // Track once listeners for removal
144
+ if (listener.once) {
145
+ onceListeners.push(listener.id);
146
+ }
147
+ } catch (error) {
148
+ // Log the error but don't break other listeners — use safeStringify
149
+ // to prevent a secondary cascade from unserializable error objects
150
+ logger.error(`[EventBus] Error in listener for '${event}':`, safeStringify(error));
105
151
  }
106
- } catch (error) {
107
- // Log the error but don't break other listeners
108
- logger.error(`[EventBus] Error in listener for '${event}':`, error);
109
- }
110
- });
152
+ });
111
153
 
112
- // Remove once listeners
113
- onceListeners.forEach(id => this.off(event, id));
154
+ // Remove once listeners
155
+ onceListeners.forEach(id => this.off(event, id));
156
+ } finally {
157
+ if (isErrorEvent) {
158
+ this._emittingError = false;
159
+ }
160
+ }
114
161
 
115
162
  return true;
116
163
  }
@@ -561,13 +561,27 @@ export class Scorm2004Driver extends ScormDriverBase {
561
561
  * Populates the CMI cache at init time. Single LMS read pass.
562
562
  */
563
563
  _populateCache() {
564
- // Read-only scalars
565
- this._cmiCache.entry = this._getValue('cmi.entry') || '';
566
- this._cmiCache.bookmark = this._getValue('cmi.location') || '';
567
- this._cmiCache.completionStatus = this._getValue('cmi.completion_status') || 'unknown';
568
- this._cmiCache.successStatus = this._getValue('cmi.success_status') || 'unknown';
569
- this._cmiCache.learnerId = this._getValue('cmi.learner_id') || '';
570
- this._cmiCache.learnerName = this._getValue('cmi.learner_name') || '';
564
+ // Helper: read a CMI value via strict _getValue, but tolerate error 403
565
+ // ("Data Model Element Value Not Initialized") which strict LMSes like
566
+ // SCORM Cloud return for unset elements on a fresh session.
567
+ // Any other SCORM error still throws through _getValue's normal path.
568
+ const getOrDefault = (key, fallback) => {
569
+ try {
570
+ return this._getValue(key) || fallback;
571
+ } catch (e) {
572
+ const code = this._scorm.debug.getCode();
573
+ if (code === 403) return fallback;
574
+ throw e;
575
+ }
576
+ };
577
+
578
+ // Read-only scalars (may be uninitialized on first launch)
579
+ this._cmiCache.entry = getOrDefault('cmi.entry', '');
580
+ this._cmiCache.bookmark = getOrDefault('cmi.location', '');
581
+ this._cmiCache.completionStatus = getOrDefault('cmi.completion_status', 'unknown');
582
+ this._cmiCache.successStatus = getOrDefault('cmi.success_status', 'unknown');
583
+ this._cmiCache.learnerId = getOrDefault('cmi.learner_id', '');
584
+ this._cmiCache.learnerName = getOrDefault('cmi.learner_name', '');
571
585
 
572
586
  // Skip array hydration for fresh sessions
573
587
  if (this._cmiCache.entry === 'ab-initio') {
package/lib/cloud.js CHANGED
@@ -463,7 +463,7 @@ function handleResponseError(status, body) {
463
463
  if (status === 403 || status === 409) {
464
464
  console.error(`\n❌ ${message}\n`);
465
465
  } else if (status === 404) {
466
- console.error('\n❌ Course not found on Cloud.\n');
466
+ console.error(`\n❌ ${message === 'Course not found' ? 'Course not found on Cloud.' : message}\n`);
467
467
  } else if (status >= 500) {
468
468
  console.error('\n❌ Cloud server error. Try again later.\n');
469
469
  } else {
@@ -799,6 +799,33 @@ function formatDate(isoString) {
799
799
  });
800
800
  }
801
801
 
802
+ function formatDeploymentSummary(deployment) {
803
+ if (!deployment) return 'none';
804
+ const parts = [deployment.versionTimestamp || deployment.deploymentId];
805
+ if (deployment.source) parts.push(deployment.source);
806
+ if (deployment.previewOnly) parts.push('preview-only');
807
+ if (deployment.fileCount != null) parts.push(`${deployment.fileCount} files`);
808
+ if (deployment.commitSha) parts.push(deployment.commitSha.slice(0, 7));
809
+ return parts.join(' | ');
810
+ }
811
+
812
+ function formatPreviewState(state) {
813
+ if (!state) return 'missing';
814
+ return state.replace('_', ' ');
815
+ }
816
+
817
+ function printPreviewLinkDetails(previewLink) {
818
+ if (!previewLink || !previewLink.exists) {
819
+ console.log('Preview Link: missing');
820
+ return;
821
+ }
822
+
823
+ console.log(`Preview Link: ${formatPreviewState(previewLink.state)}`);
824
+ console.log(` ${previewLink.url}`);
825
+ if (previewLink.expiresAt) console.log(` Expires ${formatDate(previewLink.expiresAt)}`);
826
+ console.log(` Format ${previewLink.format} | ${previewLink.hasPassword ? 'password protected' : 'no password'} | ${previewLink.source}`);
827
+ }
828
+
802
829
  // =============================================================================
803
830
  // CLI COMMANDS
804
831
  // =============================================================================
@@ -1274,27 +1301,182 @@ export async function status(options = {}) {
1274
1301
 
1275
1302
  console.log(`\n${data.slug} — ${data.name} (${data.orgName})\n`);
1276
1303
 
1277
- if (data.source_type === 'github' && data.github_repo) {
1304
+ if (data.source?.type === 'github' && data.source?.githubRepo) {
1305
+ console.log(`Source: GitHub — ${data.source.githubRepo}`);
1306
+ console.log(' direct production deploy disabled; preview deploy allowed');
1307
+ } else if (data.source_type === 'github' && data.github_repo) {
1278
1308
  console.log(`Source: GitHub — ${data.github_repo}`);
1279
1309
  console.log(` (changes deploy via GitHub, not direct upload)`);
1280
1310
  } else if (data.source_type) {
1281
1311
  console.log(`Source: ${data.source_type}`);
1282
1312
  }
1283
1313
 
1314
+ if (data.courseId) console.log(`Course ID: ${data.courseId}`);
1315
+
1284
1316
  if (data.lastDeploy) {
1285
1317
  console.log(`Last deploy: ${formatDate(data.lastDeploy)} (${data.lastDeployFileCount} files, ${formatBytes(data.lastDeploySize)})`);
1286
1318
  } else {
1287
1319
  console.log('Last deploy: Never');
1288
1320
  }
1289
1321
 
1290
- if (data.errorCount24h != null) console.log(`Errors (24h): ${data.errorCount24h}`);
1291
- if (data.launchCount24h != null) console.log(`Launches (24h): ${data.launchCount24h}`);
1322
+ console.log(`Production: ${formatDeploymentSummary(data.production)}`);
1323
+ console.log(`Preview Ptr: ${formatDeploymentSummary(data.previewPointer)}`);
1324
+ if (data.health) {
1325
+ console.log(`Pointer Drift: ${data.health.previewMatchesProduction ? 'preview matches production' : 'preview differs from production'}`);
1326
+ }
1327
+ if (data.deployModes) {
1328
+ console.log(`Deploy Modes: production=${data.deployModes.production} | preview=${data.deployModes.preview}`);
1329
+ }
1330
+
1331
+ if (data.activity?.errorCount24h != null) console.log(`Errors (24h): ${data.activity.errorCount24h}`);
1332
+ else if (data.errorCount24h != null) console.log(`Errors (24h): ${data.errorCount24h}`);
1333
+
1334
+ if (data.activity?.launchCount24h != null) console.log(`Launches (24h): ${data.activity.launchCount24h}`);
1335
+ else if (data.launchCount24h != null) console.log(`Launches (24h): ${data.launchCount24h}`);
1336
+
1337
+ if (data.activity?.lastErrorAt) console.log(`Last error: ${formatDate(data.activity.lastErrorAt)}`);
1338
+ if (data.activity?.lastLaunchAt) console.log(`Last launch: ${formatDate(data.activity.lastLaunchAt)}`);
1339
+
1340
+ printPreviewLinkDetails(data.previewLink);
1341
+
1342
+ console.log('');
1343
+ }
1344
+
1345
+ /**
1346
+ * coursecode preview-link — show or update the current preview link
1347
+ */
1348
+ export async function previewLink(options = {}) {
1349
+ await ensureAuthenticated();
1350
+ const slug = resolveSlug();
1351
+ const rcConfig = readRcConfig();
1352
+ const orgQuery = rcConfig?.orgId ? `?orgId=${rcConfig.orgId}` : '';
1353
+
1354
+ if (options.enable && options.disable) {
1355
+ console.error('\n❌ Specify only one of --enable or --disable\n');
1356
+ process.exit(1);
1357
+ }
1358
+
1359
+ if (options.password !== undefined && options.removePassword) {
1360
+ console.error('\n❌ --password and --remove-password are mutually exclusive\n');
1361
+ process.exit(1);
1362
+ }
1363
+
1364
+ if (options.expiresAt && options.expiresInDays) {
1365
+ console.error('\n❌ Specify only one of --expires-at or --expires-in-days\n');
1366
+ process.exit(1);
1367
+ }
1368
+
1369
+ if (options.format && !['cmi5', 'scorm2004', 'scorm1.2'].includes(options.format)) {
1370
+ console.error('\n❌ Preview format must be one of: cmi5, scorm2004, scorm1.2\n');
1371
+ process.exit(1);
1372
+ }
1373
+
1374
+ const hasMutation = Boolean(
1375
+ options.enable
1376
+ || options.disable
1377
+ || options.password !== undefined
1378
+ || options.removePassword
1379
+ || options.format
1380
+ || options.expiresAt
1381
+ || options.expiresInDays
1382
+ );
1383
+
1384
+ const body = {};
1385
+ if (hasMutation) {
1386
+ if (options.enable) body.enabled = true;
1387
+ if (options.disable) body.enabled = false;
1388
+ if (options.format) body.format = options.format;
1389
+ if (options.expiresAt) body.expires_at = options.expiresAt;
1390
+ if (options.expiresInDays) {
1391
+ const days = Number.parseInt(String(options.expiresInDays), 10);
1392
+ if (!Number.isFinite(days) || days <= 0) {
1393
+ console.error('\n❌ --expires-in-days must be a positive integer\n');
1394
+ process.exit(1);
1395
+ }
1396
+ body.expires_at = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString();
1397
+ }
1398
+
1399
+ if (options.password !== undefined) {
1400
+ let pw = options.password;
1401
+ if (pw === true || pw === '') pw = await prompt(' Preview password: ');
1402
+ if (!pw) {
1403
+ console.error('\n❌ Preview password cannot be empty\n');
1404
+ process.exit(1);
1405
+ }
1406
+ body.password = pw;
1407
+ }
1408
+
1409
+ if (options.removePassword) body.remove_password = true;
1410
+ }
1411
+
1412
+ const requestOptions = hasMutation
1413
+ ? {
1414
+ method: 'PATCH',
1415
+ headers: { 'Content-Type': 'application/json' },
1416
+ body: JSON.stringify(body),
1417
+ }
1418
+ : {};
1419
+
1420
+ const makeRequest = async (_isRetry = false) => {
1421
+ const token = readCredentials()?.token;
1422
+ const res = await cloudFetch(
1423
+ `/api/cli/courses/${encodeURIComponent(slug)}/preview${orgQuery}`,
1424
+ requestOptions,
1425
+ token
1426
+ );
1427
+ return handleResponse(res, { retryFn: makeRequest, _isRetry });
1428
+ };
1429
+
1430
+ const token = readCredentials()?.token;
1431
+ const firstRes = await cloudFetch(
1432
+ `/api/cli/courses/${encodeURIComponent(slug)}/preview${orgQuery}`,
1433
+ requestOptions,
1434
+ token
1435
+ );
1436
+
1437
+ if (firstRes.status === 404 && getBindingSnapshot(slug).hasBinding) {
1438
+ const handled = await resolveStaleBinding({
1439
+ operation: 'preview-link',
1440
+ slug,
1441
+ options,
1442
+ promptText: '\n This project is still linked locally, but the Cloud course was deleted. Clear the stale binding?',
1443
+ onRepaired: (payload) => {
1444
+ const result = {
1445
+ ...payload,
1446
+ success: true,
1447
+ previewLink: null,
1448
+ message: 'Local stale Cloud binding cleared. This course is no longer deployed.',
1449
+ };
1450
+ if (options.json) {
1451
+ emitJson(result);
1452
+ } else {
1453
+ console.log('\n Cleared stale Cloud binding.');
1454
+ console.log(' This course is no longer deployed. Run `coursecode deploy` to create a new Cloud deployment.\n');
1455
+ }
1456
+ return true;
1457
+ },
1458
+ onJson: (payload) => {
1459
+ emitJson(payload);
1460
+ return true;
1461
+ },
1462
+ });
1463
+ if (handled) return;
1464
+ }
1465
+
1466
+ const data = await handleResponse(firstRes, { retryFn: makeRequest, _isRetry: false });
1467
+
1468
+ if (options.json) {
1469
+ emitJson(data);
1470
+ return;
1471
+ }
1292
1472
 
1293
- if (data.previewUrl) {
1294
- console.log(`Preview: ${data.previewUrl}`);
1295
- if (data.previewExpiresAt) console.log(` Expires ${formatDate(data.previewExpiresAt)}`);
1473
+ if (hasMutation) {
1474
+ console.log(`\n✓ Preview link ${data.created ? 'created' : 'updated'}.\n`);
1475
+ } else {
1476
+ console.log(`\n${slug} — Preview Link\n`);
1296
1477
  }
1297
1478
 
1479
+ printPreviewLinkDetails(data.previewLink);
1298
1480
  console.log('');
1299
1481
  }
1300
1482
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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": {