coursecode 0.1.13 → 0.1.14

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/README.md CHANGED
@@ -191,6 +191,12 @@ When ready, deploy:
191
191
  coursecode deploy
192
192
  ```
193
193
 
194
+ If the cloud course was deleted but the project still has the old local binding, redeploy with:
195
+
196
+ ```bash
197
+ coursecode deploy --repair-binding
198
+ ```
199
+
194
200
  **Without Cloud**: Build a ZIP package and upload it to your LMS manually:
195
201
 
196
202
  ```bash
package/bin/cli.js CHANGED
@@ -353,6 +353,7 @@ program
353
353
  .option('--preview', 'Deploy as preview-only (production untouched, preview pointer always moved). Combine with --promote or --stage for a full deploy that also moves the preview pointer.')
354
354
  .option('--promote', 'Force-promote: always move production pointer regardless of deploy_mode setting. Mutually exclusive with --stage.')
355
355
  .option('--stage', 'Force-stage: never move production pointer regardless of deploy_mode setting. Mutually exclusive with --promote.')
356
+ .option('--repair-binding', 'Clear a stale local Cloud binding if the remote course was deleted, then continue')
356
357
  .option('--password', 'Password-protect preview (interactive prompt, requires --preview)')
357
358
  .option('-m, --message <message>', 'Deploy reason (e.g. "Fixed accessibility issues")')
358
359
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
@@ -369,6 +370,7 @@ program
369
370
  .option('--production', 'Promote to the production pointer')
370
371
  .option('--preview', 'Promote to the preview pointer')
371
372
  .option('--deployment <id>', 'Deployment ID to promote (skip interactive prompt)')
373
+ .option('--repair-binding', 'Clear a stale local Cloud binding if the remote course was deleted')
372
374
  .option('-m, --message <message>', 'Reason for promotion')
373
375
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
374
376
  .option('--json', 'Emit machine-readable JSON result')
@@ -382,24 +384,26 @@ program
382
384
  program
383
385
  .command('status')
384
386
  .description('Show deployment status for current course')
387
+ .option('--repair-binding', 'Clear a stale local Cloud binding if the remote course was deleted')
385
388
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
386
389
  .option('--json', 'Output raw JSON')
387
390
  .action(async (options) => {
388
391
  const { status, setLocalMode } = await import('../lib/cloud.js');
389
392
  if (options.local) setLocalMode();
390
- await status({ json: options.json });
393
+ await status({ json: options.json, repairBinding: options.repairBinding });
391
394
  });
392
395
 
393
396
  program
394
397
  .command('delete')
395
398
  .description('Remove course from CourseCode Cloud (does not delete local files)')
396
399
  .option('--force', 'Skip confirmation prompt')
400
+ .option('--repair-binding', 'Clear a stale local Cloud binding if the remote course was already deleted')
397
401
  .option('--local', 'Use local Cloud instance (http://localhost:3000)')
398
402
  .option('--json', 'Emit machine-readable JSON result')
399
403
  .action(async (options) => {
400
404
  const { deleteCourse, setLocalMode } = await import('../lib/cloud.js');
401
405
  if (options.local) setLocalMode();
402
- await deleteCourse({ force: options.force, json: options.json });
406
+ await deleteCourse({ force: options.force, json: options.json, repairBinding: options.repairBinding });
403
407
  });
404
408
 
405
409
  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. Flags: `--promote` (force live), `--stage` (force staged), `--preview` (preview-only: production untouched, preview always moved). `--promote`/`--stage` are mutually exclusive; `--preview` stacks with either. |
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), `--repair-binding` (clear stale local cloud binding first if the remote course was deleted). `--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
 
@@ -740,6 +740,7 @@ Open the URL in any browser, log in with your CourseCode account, and enter the
740
740
  - **Preview pointer** — the version served on the cloud preview link (for stakeholder review).
741
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
742
  - `--promote` and `--stage` are mutually exclusive.
743
+ - If a cloud deployment was deleted outside the CLI and this project still has the old local binding, rerun with `coursecode deploy --repair-binding`. To clear the stale binding without deploying yet, run `coursecode status --repair-binding`.
743
744
 
744
745
  **Typical Cloud workflow:**
745
746
  1. Run `coursecode login` once, open the URL shown, and enter the code.
package/lib/cloud.js CHANGED
@@ -144,6 +144,15 @@ function writeProjectConfig(data) {
144
144
  );
145
145
  }
146
146
 
147
+ function updateProjectConfig(mutator) {
148
+ const dir = path.join(process.cwd(), PROJECT_CONFIG_DIR);
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ const fullPath = path.join(process.cwd(), PROJECT_CONFIG_PATH);
151
+ const existing = readProjectConfig() || {};
152
+ const next = mutator({ ...existing }) || existing;
153
+ fs.writeFileSync(fullPath, JSON.stringify(next, null, 2) + '\n');
154
+ }
155
+
147
156
  // =============================================================================
148
157
  // COURSE IDENTITY (committed: .coursecoderc.json → cloudId)
149
158
  // =============================================================================
@@ -172,6 +181,117 @@ function writeRcConfig(fields) {
172
181
  fs.writeFileSync(rcPath, JSON.stringify(existing, null, 2) + '\n');
173
182
  }
174
183
 
184
+ function updateRcConfig(mutator) {
185
+ const rcPath = path.join(process.cwd(), '.coursecoderc.json');
186
+ const existing = readRcConfig() || {};
187
+ const next = mutator({ ...existing }) || existing;
188
+ fs.writeFileSync(rcPath, JSON.stringify(next, null, 2) + '\n');
189
+ }
190
+
191
+ function emitJson(payload) {
192
+ process.stdout.write(JSON.stringify(payload) + '\n');
193
+ }
194
+
195
+ function getBindingSnapshot(slug = resolveSlug()) {
196
+ const rcConfig = readRcConfig() || {};
197
+ const projectConfig = readProjectConfig() || {};
198
+ return {
199
+ slug,
200
+ cloudId: rcConfig.cloudId || projectConfig.courseId || null,
201
+ orgId: projectConfig.orgId || rcConfig.orgId || null,
202
+ hasBinding: Boolean(rcConfig.cloudId || projectConfig.courseId),
203
+ };
204
+ }
205
+
206
+ function clearCloudBinding() {
207
+ updateRcConfig((rc) => {
208
+ delete rc.cloudId;
209
+ delete rc.orgId;
210
+ return rc;
211
+ });
212
+ updateProjectConfig((project) => {
213
+ delete project.courseId;
214
+ delete project.orgId;
215
+ return project;
216
+ });
217
+ }
218
+
219
+ function buildStaleBindingPayload({
220
+ slug,
221
+ operation,
222
+ binding,
223
+ bindingCleared = false,
224
+ success = false,
225
+ alreadyDeleted = false,
226
+ }) {
227
+ return {
228
+ success,
229
+ error: 'Cloud course was deleted. Local binding is stale.',
230
+ errorCode: 'stale_cloud_binding',
231
+ staleBinding: true,
232
+ bindingCleared,
233
+ repairable: true,
234
+ needsRedeploy: true,
235
+ alreadyDeleted,
236
+ operation,
237
+ suggestedAction: 'redeploy',
238
+ suggestedCommand: 'coursecode deploy --repair-binding',
239
+ repairFlag: '--repair-binding',
240
+ binding,
241
+ slug,
242
+ };
243
+ }
244
+
245
+ async function resolveStaleBinding({
246
+ operation,
247
+ slug,
248
+ options = {},
249
+ promptText,
250
+ onRepaired,
251
+ onDeclined,
252
+ onJson,
253
+ }) {
254
+ const binding = getBindingSnapshot(slug);
255
+ if (!binding.hasBinding) return false;
256
+
257
+ const payload = buildStaleBindingPayload({ slug, operation, binding });
258
+
259
+ if (options.repairBinding) {
260
+ clearCloudBinding();
261
+ return onRepaired(buildStaleBindingPayload({
262
+ slug,
263
+ operation,
264
+ binding,
265
+ bindingCleared: true,
266
+ success: operation === 'delete',
267
+ alreadyDeleted: operation === 'delete',
268
+ }));
269
+ }
270
+
271
+ if (options.json || !process.stdin.isTTY) {
272
+ if (onJson) return onJson(payload);
273
+ emitJson(payload);
274
+ return true;
275
+ }
276
+
277
+ const answer = await prompt(`${promptText} [Y/n] `);
278
+ if (answer && !['y', 'yes'].includes(answer.toLowerCase())) {
279
+ if (onDeclined) return onDeclined(payload);
280
+ console.log(' Cancelled.\n');
281
+ process.exit(1);
282
+ }
283
+
284
+ clearCloudBinding();
285
+ return onRepaired(buildStaleBindingPayload({
286
+ slug,
287
+ operation,
288
+ binding,
289
+ bindingCleared: true,
290
+ success: operation === 'delete',
291
+ alreadyDeleted: operation === 'delete',
292
+ }));
293
+ }
294
+
175
295
  // =============================================================================
176
296
  // HTTP HELPERS
177
297
  // =============================================================================
@@ -805,6 +925,39 @@ export async function deploy(options = {}) {
805
925
  const log = (...args) => { if (!options.json) console.log(...args); };
806
926
  const logErr = (...args) => { if (!options.json) console.error(...args); };
807
927
 
928
+ // Preflight a cached binding so deleted cloud courses can be repaired
929
+ // before we spend time building and uploading.
930
+ const binding = getBindingSnapshot(slug);
931
+ if (binding.hasBinding && binding.orgId) {
932
+ const statusRes = await cloudFetch(
933
+ `/api/cli/courses/${encodeURIComponent(slug)}/status?orgId=${binding.orgId}`,
934
+ {},
935
+ readCredentials()?.token
936
+ );
937
+
938
+ if (statusRes.status === 404) {
939
+ const handled = await resolveStaleBinding({
940
+ operation: 'deploy',
941
+ slug,
942
+ options,
943
+ promptText: '\n This project is still linked locally, but the Cloud course was deleted. Clear the stale binding and rebuild/redeploy?',
944
+ onRepaired: () => false,
945
+ onDeclined: () => {
946
+ logErr('\n❌ Deploy cancelled. Local binding still points to a deleted Cloud course.\n');
947
+ process.exit(1);
948
+ },
949
+ onJson: (payload) => {
950
+ emitJson(payload);
951
+ console.error('\n❌ Cloud course was deleted. Re-run deploy with --repair-binding to clear the stale binding first.\n');
952
+ process.exit(1);
953
+ },
954
+ });
955
+ if (handled) return;
956
+ } else if (!statusRes.ok) {
957
+ await handleResponse(statusRes);
958
+ }
959
+ }
960
+
808
961
  // Validate mutually exclusive flags
809
962
  if (options.promote && options.stage) {
810
963
  logErr('\n❌ --promote and --stage are mutually exclusive.\n');
@@ -998,7 +1151,46 @@ export async function promote(options = {}) {
998
1151
  return handleResponse(res, { retryFn: makeRequest, _isRetry });
999
1152
  };
1000
1153
 
1001
- const result = await makeRequest();
1154
+ const token = readCredentials()?.token;
1155
+ const firstRes = await cloudFetch(
1156
+ `/api/cli/courses/${encodeURIComponent(slug)}/promote`,
1157
+ {
1158
+ method: 'POST',
1159
+ headers: { 'Content-Type': 'application/json' },
1160
+ body: JSON.stringify({ deployment_id: deploymentId, target, reason }),
1161
+ },
1162
+ token
1163
+ );
1164
+
1165
+ if (firstRes.status === 404 && getBindingSnapshot(slug).hasBinding) {
1166
+ const handled = await resolveStaleBinding({
1167
+ operation: 'promote',
1168
+ slug,
1169
+ options,
1170
+ promptText: '\n This project is still linked locally, but the Cloud course was deleted. Clear the stale binding?',
1171
+ onRepaired: (payload) => {
1172
+ if (options.json) {
1173
+ emitJson(payload);
1174
+ } else {
1175
+ console.log('\n Cleared stale Cloud binding.');
1176
+ console.log(' The course is no longer deployed. Run `coursecode deploy` before promoting.\n');
1177
+ }
1178
+ return true;
1179
+ },
1180
+ onDeclined: () => {
1181
+ console.error('\n❌ Promote cancelled. Local binding still points to a deleted Cloud course.\n');
1182
+ process.exit(1);
1183
+ },
1184
+ onJson: (payload) => {
1185
+ emitJson(payload);
1186
+ console.error('\n❌ Cloud course was deleted. Deploy again before promoting.\n');
1187
+ process.exit(1);
1188
+ },
1189
+ });
1190
+ if (handled) return;
1191
+ }
1192
+
1193
+ const result = await handleResponse(firstRes, { retryFn: makeRequest, _isRetry: false });
1002
1194
 
1003
1195
  if (options.json) {
1004
1196
  process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
@@ -1037,7 +1229,43 @@ export async function status(options = {}) {
1037
1229
  return handleResponse(res, { retryFn: makeRequest, _isRetry });
1038
1230
  };
1039
1231
 
1040
- const data = await makeRequest();
1232
+ const token = readCredentials()?.token;
1233
+ const firstRes = await cloudFetch(
1234
+ `/api/cli/courses/${encodeURIComponent(slug)}/status${orgQuery}`,
1235
+ {},
1236
+ token
1237
+ );
1238
+
1239
+ if (firstRes.status === 404 && getBindingSnapshot(slug).hasBinding) {
1240
+ const handled = await resolveStaleBinding({
1241
+ operation: 'status',
1242
+ slug,
1243
+ options,
1244
+ promptText: '\n This project is still linked locally, but the Cloud course was deleted. Clear the stale binding?',
1245
+ onRepaired: (payload) => {
1246
+ const result = {
1247
+ ...payload,
1248
+ success: true,
1249
+ deployed: false,
1250
+ message: 'Local stale Cloud binding cleared. This course is no longer deployed.',
1251
+ };
1252
+ if (options.json) {
1253
+ emitJson(result);
1254
+ } else {
1255
+ console.log('\n Cleared stale Cloud binding.');
1256
+ console.log(' This course is no longer deployed. Run `coursecode deploy` to create a new Cloud deployment.\n');
1257
+ }
1258
+ return true;
1259
+ },
1260
+ onJson: (payload) => {
1261
+ emitJson(payload);
1262
+ return true;
1263
+ },
1264
+ });
1265
+ if (handled) return;
1266
+ }
1267
+
1268
+ const data = await handleResponse(firstRes, { retryFn: makeRequest, _isRetry: false });
1041
1269
 
1042
1270
  if (options.json) {
1043
1271
  process.stdout.write(JSON.stringify(data) + '\n');
@@ -1122,7 +1350,52 @@ export async function deleteCourse(options = {}) {
1122
1350
  return handleResponse(res, { retryFn: makeRequest, _isRetry });
1123
1351
  };
1124
1352
 
1125
- const result = await makeRequest();
1353
+ const token = readCredentials()?.token;
1354
+ const firstRes = await cloudFetch(
1355
+ `/api/cli/courses/${encodeURIComponent(slug)}${orgQuery}`,
1356
+ {
1357
+ method: 'DELETE',
1358
+ headers: { 'Content-Type': 'application/json' },
1359
+ body: JSON.stringify({ cloudId: rcConfig.cloudId }),
1360
+ },
1361
+ token
1362
+ );
1363
+
1364
+ if (firstRes.status === 404 && getBindingSnapshot(slug).hasBinding) {
1365
+ const handled = await resolveStaleBinding({
1366
+ operation: 'delete',
1367
+ slug,
1368
+ options,
1369
+ promptText: '\n The Cloud course is already gone, but this project still has a local binding. Clear the stale binding too?',
1370
+ onRepaired: (payload) => {
1371
+ const result = {
1372
+ ...payload,
1373
+ success: true,
1374
+ alreadyDeleted: true,
1375
+ message: 'Cloud course was already deleted. Local stale binding cleared.',
1376
+ };
1377
+ if (options.json) {
1378
+ emitJson(result);
1379
+ } else {
1380
+ console.log('\n✓ Course was already deleted from CourseCode Cloud.');
1381
+ console.log(' Cleared stale local Cloud binding.\n');
1382
+ }
1383
+ return true;
1384
+ },
1385
+ onJson: (payload) => {
1386
+ emitJson({
1387
+ ...payload,
1388
+ success: true,
1389
+ alreadyDeleted: true,
1390
+ message: 'Cloud course was already deleted. Local binding still needs cleanup.',
1391
+ });
1392
+ return true;
1393
+ },
1394
+ });
1395
+ if (handled) return;
1396
+ }
1397
+
1398
+ const result = await handleResponse(firstRes, { retryFn: makeRequest, _isRetry: false });
1126
1399
 
1127
1400
  if (options.json) {
1128
1401
  process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
@@ -19,22 +19,20 @@
19
19
  * @returns {string} The cmi5.xml content
20
20
  */
21
21
  export function generateCmi5Manifest(config, _files, options = {}) {
22
- // cmi5 course identifier - use configured identifier or generate from title
23
- const courseId = config.identifier ||
24
- `urn:coursecode:${config.title.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`;
25
-
26
- // Calculate mastery score from passing percentage (convert percentage to 0-1 scale)
27
- const masteryScore = config.passingScore ? (config.passingScore / 100).toFixed(2) : '0.8';
28
-
29
- // AU identifier - derive from course ID
30
- const auId = `${courseId}/au/1`;
31
-
32
- // URL: absolute for cmi5-remote, relative for standard cmi5
33
- const auUrl = options.externalUrl
34
- ? `${options.externalUrl.replace(/\/$/, '')}/index.html`
35
- : 'index.html';
36
-
37
- return `<?xml version="1.0" encoding="UTF-8"?>
22
+ // cmi5 course identifier - use configured identifier or generate from title
23
+ const courseId = config.identifier ||
24
+ `urn:coursecode:${config.title.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`;
25
+
26
+ // Calculate mastery score from passing percentage (convert percentage to 0-1 scale)
27
+ const masteryScore = config.passingScore ? (config.passingScore / 100).toFixed(2) : '0.8';
28
+
29
+ // AU identifier - derive from course ID
30
+ const auId = `${courseId}/au/1`;
31
+
32
+ // URL: absolute for cmi5-remote (use as-is), relative for standard cmi5
33
+ const auUrl = options.externalUrl || 'index.html';
34
+
35
+ return `<?xml version="1.0" encoding="UTF-8"?>
38
36
  <!-- cmi5 Course Structure - GENERATED FILE - DO NOT EDIT MANUALLY -->
39
37
  <courseStructure xmlns="https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd">
40
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
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": {