coursecode 0.1.15 → 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.
Files changed (3) hide show
  1. package/bin/cli.js +19 -0
  2. package/lib/cloud.js +189 -7
  3. package/package.json +1 -1
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)')
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.15",
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": {