coursecode 0.1.13 → 0.1.15
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 +6 -0
- package/bin/cli.js +6 -2
- package/framework/docs/FRAMEWORK_GUIDE.md +1 -1
- package/framework/docs/USER_GUIDE.md +1 -0
- package/framework/js/core/event-bus.js +67 -20
- package/framework/js/drivers/scorm-2004-driver.js +21 -7
- package/lib/cloud.js +276 -3
- package/lib/manifest/cmi5-manifest.js +14 -16
- package/package.json +1 -1
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.
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
//
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|