coursecode 0.1.11 → 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 +6 -0
- package/bin/cli.js +8 -3
- package/framework/docs/FRAMEWORK_GUIDE.md +1 -1
- package/framework/docs/USER_GUIDE.md +1 -0
- package/lib/cloud.js +361 -16
- package/lib/convert.js +4 -3
- package/lib/manifest/cmi5-manifest.js +14 -16
- package/lib/stub-player/lms-api.js +1 -1
- 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
|
@@ -166,12 +166,13 @@ program
|
|
|
166
166
|
// Convert command
|
|
167
167
|
program
|
|
168
168
|
.command('convert [source]')
|
|
169
|
-
.description('Convert docx, pptx, and pdf files to markdown for course authoring')
|
|
169
|
+
.description('Convert docx, pptx, and pdf files to markdown for course authoring (PDF JSON sidecars optional)')
|
|
170
170
|
.option('-o, --output <dir>', 'Output directory for converted files', './course/references/converted')
|
|
171
171
|
.option('-f, --format <type>', 'Limit to format: docx, pptx, pdf, or all', 'all')
|
|
172
172
|
.option('--dry-run', 'Show what would be converted without writing files')
|
|
173
173
|
.option('--overwrite', 'Overwrite existing markdown files')
|
|
174
174
|
.option('--flatten', 'Output all files to single directory (no subdirs)')
|
|
175
|
+
.option('--pdf-json', 'Also write PDF structure JSON sidecars (.json) next to converted markdown')
|
|
175
176
|
.action(async (source = './course/references', options) => {
|
|
176
177
|
const { convert } = await import('../lib/convert.js');
|
|
177
178
|
await convert(source, options);
|
|
@@ -352,6 +353,7 @@ program
|
|
|
352
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.')
|
|
353
354
|
.option('--promote', 'Force-promote: always move production pointer regardless of deploy_mode setting. Mutually exclusive with --stage.')
|
|
354
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')
|
|
355
357
|
.option('--password', 'Password-protect preview (interactive prompt, requires --preview)')
|
|
356
358
|
.option('-m, --message <message>', 'Deploy reason (e.g. "Fixed accessibility issues")')
|
|
357
359
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
@@ -368,6 +370,7 @@ program
|
|
|
368
370
|
.option('--production', 'Promote to the production pointer')
|
|
369
371
|
.option('--preview', 'Promote to the preview pointer')
|
|
370
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')
|
|
371
374
|
.option('-m, --message <message>', 'Reason for promotion')
|
|
372
375
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
373
376
|
.option('--json', 'Emit machine-readable JSON result')
|
|
@@ -381,24 +384,26 @@ program
|
|
|
381
384
|
program
|
|
382
385
|
.command('status')
|
|
383
386
|
.description('Show deployment status for current course')
|
|
387
|
+
.option('--repair-binding', 'Clear a stale local Cloud binding if the remote course was deleted')
|
|
384
388
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
385
389
|
.option('--json', 'Output raw JSON')
|
|
386
390
|
.action(async (options) => {
|
|
387
391
|
const { status, setLocalMode } = await import('../lib/cloud.js');
|
|
388
392
|
if (options.local) setLocalMode();
|
|
389
|
-
await status({ json: options.json });
|
|
393
|
+
await status({ json: options.json, repairBinding: options.repairBinding });
|
|
390
394
|
});
|
|
391
395
|
|
|
392
396
|
program
|
|
393
397
|
.command('delete')
|
|
394
398
|
.description('Remove course from CourseCode Cloud (does not delete local files)')
|
|
395
399
|
.option('--force', 'Skip confirmation prompt')
|
|
400
|
+
.option('--repair-binding', 'Clear a stale local Cloud binding if the remote course was already deleted')
|
|
396
401
|
.option('--local', 'Use local Cloud instance (http://localhost:3000)')
|
|
397
402
|
.option('--json', 'Emit machine-readable JSON result')
|
|
398
403
|
.action(async (options) => {
|
|
399
404
|
const { deleteCourse, setLocalMode } = await import('../lib/cloud.js');
|
|
400
405
|
if (options.local) setLocalMode();
|
|
401
|
-
await deleteCourse({ force: options.force, json: options.json });
|
|
406
|
+
await deleteCourse({ force: options.force, json: options.json, repairBinding: options.repairBinding });
|
|
402
407
|
});
|
|
403
408
|
|
|
404
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
|
@@ -29,8 +29,11 @@ const DEFAULT_CLOUD_URL = 'https://coursecodecloud.com';
|
|
|
29
29
|
const FALLBACK_CLOUD_URL = 'https://coursecode-cloud-web.vercel.app';
|
|
30
30
|
const LOCAL_CLOUD_URL = 'http://localhost:3000';
|
|
31
31
|
let useLocal = false;
|
|
32
|
+
let activeCloudUrl = null;
|
|
32
33
|
const CREDENTIALS_DIR = path.join(os.homedir(), '.coursecode');
|
|
33
34
|
const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, 'credentials.json');
|
|
35
|
+
const PROJECT_CONFIG_DIR = '.coursecode';
|
|
36
|
+
const PROJECT_CONFIG_PATH = path.join(PROJECT_CONFIG_DIR, 'project.json');
|
|
34
37
|
|
|
35
38
|
const POLL_INTERVAL_MS = 2000;
|
|
36
39
|
const POLL_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes (device code expiry)
|
|
@@ -91,12 +94,19 @@ function writeCredentials(token, cloudUrl = DEFAULT_CLOUD_URL) {
|
|
|
91
94
|
fs.writeFileSync(getCredentialsPath(), data, { mode: 0o600 });
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
function updateCredentialsCloudUrl(cloudUrl) {
|
|
98
|
+
const creds = readCredentials();
|
|
99
|
+
if (!creds?.token) return;
|
|
100
|
+
writeCredentials(creds.token, cloudUrl);
|
|
101
|
+
}
|
|
102
|
+
|
|
94
103
|
function deleteCredentials() {
|
|
95
104
|
try { fs.unlinkSync(getCredentialsPath()); } catch { /* already gone */ }
|
|
96
105
|
}
|
|
97
106
|
|
|
98
107
|
function getCloudUrl() {
|
|
99
108
|
if (useLocal) return LOCAL_CLOUD_URL;
|
|
109
|
+
if (activeCloudUrl) return activeCloudUrl;
|
|
100
110
|
return readCredentials()?.cloud_url || DEFAULT_CLOUD_URL;
|
|
101
111
|
}
|
|
102
112
|
|
|
@@ -106,6 +116,41 @@ function getCloudUrl() {
|
|
|
106
116
|
*/
|
|
107
117
|
export function setLocalMode() {
|
|
108
118
|
useLocal = true;
|
|
119
|
+
activeCloudUrl = LOCAL_CLOUD_URL;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// =============================================================================
|
|
123
|
+
// PROJECT BINDING (local: .coursecode/project.json)
|
|
124
|
+
// =============================================================================
|
|
125
|
+
|
|
126
|
+
function readProjectConfig() {
|
|
127
|
+
try {
|
|
128
|
+
const fullPath = path.join(process.cwd(), PROJECT_CONFIG_PATH);
|
|
129
|
+
if (!fs.existsSync(fullPath)) return null;
|
|
130
|
+
return JSON.parse(fs.readFileSync(fullPath, 'utf-8'));
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function writeProjectConfig(data) {
|
|
137
|
+
const dir = path.join(process.cwd(), PROJECT_CONFIG_DIR);
|
|
138
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
139
|
+
const existing = readProjectConfig() || {};
|
|
140
|
+
Object.assign(existing, data);
|
|
141
|
+
fs.writeFileSync(
|
|
142
|
+
path.join(process.cwd(), PROJECT_CONFIG_PATH),
|
|
143
|
+
JSON.stringify(existing, null, 2) + '\n'
|
|
144
|
+
);
|
|
145
|
+
}
|
|
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');
|
|
109
154
|
}
|
|
110
155
|
|
|
111
156
|
// =============================================================================
|
|
@@ -136,6 +181,117 @@ function writeRcConfig(fields) {
|
|
|
136
181
|
fs.writeFileSync(rcPath, JSON.stringify(existing, null, 2) + '\n');
|
|
137
182
|
}
|
|
138
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
|
+
|
|
139
295
|
// =============================================================================
|
|
140
296
|
// HTTP HELPERS
|
|
141
297
|
// =============================================================================
|
|
@@ -176,7 +332,10 @@ async function cloudFetch(urlPath, options = {}, token = null) {
|
|
|
176
332
|
// Primary unreachable — try fallback before giving up
|
|
177
333
|
if (!useLocal) {
|
|
178
334
|
const fallback = await attemptFetch(FALLBACK_CLOUD_URL);
|
|
179
|
-
if (fallback)
|
|
335
|
+
if (fallback) {
|
|
336
|
+
activeCloudUrl = FALLBACK_CLOUD_URL;
|
|
337
|
+
return fallback;
|
|
338
|
+
}
|
|
180
339
|
}
|
|
181
340
|
console.error('\n❌ Could not connect to CourseCode Cloud. Check your internet connection.\n');
|
|
182
341
|
process.exit(1);
|
|
@@ -185,11 +344,29 @@ async function cloudFetch(urlPath, options = {}, token = null) {
|
|
|
185
344
|
// Peek at the body: if it's an HTML block page, silently retry on the fallback.
|
|
186
345
|
// We must buffer the text here since Response bodies can only be read once.
|
|
187
346
|
const text = await res.text();
|
|
347
|
+
|
|
348
|
+
// Token may be valid on the alternate cloud origin. Before triggering re-auth,
|
|
349
|
+
// retry authenticated 401s once on the other known origin.
|
|
350
|
+
if (res.status === 401 && token && !useLocal) {
|
|
351
|
+
const alternateUrl = primaryUrl === FALLBACK_CLOUD_URL ? DEFAULT_CLOUD_URL : FALLBACK_CLOUD_URL;
|
|
352
|
+
const alternateRes = await attemptFetch(alternateUrl);
|
|
353
|
+
if (alternateRes) {
|
|
354
|
+
const alternateText = await alternateRes.text();
|
|
355
|
+
if (!isBlockPage(alternateText) && alternateRes.status !== 401) {
|
|
356
|
+
activeCloudUrl = alternateUrl;
|
|
357
|
+
updateCredentialsCloudUrl(alternateUrl);
|
|
358
|
+
return syntheticResponse(alternateText, alternateRes.status);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
188
363
|
if (isBlockPage(text) && !useLocal) {
|
|
189
364
|
const fallbackRes = await attemptFetch(FALLBACK_CLOUD_URL);
|
|
190
365
|
if (fallbackRes) {
|
|
191
366
|
const fallbackText = await fallbackRes.text();
|
|
192
367
|
if (!isBlockPage(fallbackText)) {
|
|
368
|
+
activeCloudUrl = FALLBACK_CLOUD_URL;
|
|
369
|
+
updateCredentialsCloudUrl(FALLBACK_CLOUD_URL);
|
|
193
370
|
// Fallback succeeded — return a synthetic Response with the buffered text
|
|
194
371
|
return syntheticResponse(fallbackText, fallbackRes.status);
|
|
195
372
|
}
|
|
@@ -200,6 +377,7 @@ async function cloudFetch(urlPath, options = {}, token = null) {
|
|
|
200
377
|
}
|
|
201
378
|
|
|
202
379
|
// Primary response is fine — return a synthetic Response with the buffered text
|
|
380
|
+
activeCloudUrl = primaryUrl;
|
|
203
381
|
return syntheticResponse(text, res.status);
|
|
204
382
|
}
|
|
205
383
|
|
|
@@ -435,7 +613,7 @@ async function runLoginFlow({ jsonMode = false } = {}) {
|
|
|
435
613
|
if (data.pending) continue;
|
|
436
614
|
|
|
437
615
|
if (data.token) {
|
|
438
|
-
writeCredentials(data.token, getCloudUrl());
|
|
616
|
+
writeCredentials(data.token, activeCloudUrl || getCloudUrl());
|
|
439
617
|
log(' ✓ Logged in successfully\n');
|
|
440
618
|
return data.token;
|
|
441
619
|
}
|
|
@@ -453,7 +631,7 @@ async function runLoginFlow({ jsonMode = false } = {}) {
|
|
|
453
631
|
*/
|
|
454
632
|
async function runLegacyLoginFlow() {
|
|
455
633
|
const nonce = crypto.randomBytes(32).toString('hex');
|
|
456
|
-
const
|
|
634
|
+
const initialCloudUrl = getCloudUrl();
|
|
457
635
|
|
|
458
636
|
console.log(' → Registering session...');
|
|
459
637
|
const createRes = await cloudFetch('/api/auth/connect', {
|
|
@@ -469,7 +647,8 @@ async function runLegacyLoginFlow() {
|
|
|
469
647
|
process.exit(1);
|
|
470
648
|
}
|
|
471
649
|
|
|
472
|
-
const
|
|
650
|
+
const effectiveCloudUrl = activeCloudUrl || initialCloudUrl;
|
|
651
|
+
const loginUrl = `${effectiveCloudUrl}/auth/connect?session=${nonce}`;
|
|
473
652
|
console.log(' → Opening browser for authentication...');
|
|
474
653
|
openBrowser(loginUrl);
|
|
475
654
|
|
|
@@ -490,7 +669,7 @@ async function runLegacyLoginFlow() {
|
|
|
490
669
|
if (data.pending) continue;
|
|
491
670
|
|
|
492
671
|
if (data.token) {
|
|
493
|
-
writeCredentials(data.token,
|
|
672
|
+
writeCredentials(data.token, activeCloudUrl || initialCloudUrl);
|
|
494
673
|
console.log(' ✓ Logged in successfully');
|
|
495
674
|
return data.token;
|
|
496
675
|
}
|
|
@@ -528,11 +707,21 @@ export async function ensureAuthenticated() {
|
|
|
528
707
|
* Returns { orgId, courseId, orgName } or prompts the user.
|
|
529
708
|
*/
|
|
530
709
|
async function resolveOrgAndCourse(slug, token) {
|
|
531
|
-
//
|
|
532
|
-
//
|
|
710
|
+
// Shared binding (committed): cloudId in .coursecoderc.json.
|
|
711
|
+
// Local binding (per-user): orgId/courseId in .coursecode/project.json.
|
|
712
|
+
// This keeps login global while allowing per-course auth context.
|
|
533
713
|
const rcConfig = readRcConfig();
|
|
534
|
-
|
|
535
|
-
|
|
714
|
+
const projectConfig = readProjectConfig();
|
|
715
|
+
const rcCloudId = rcConfig?.cloudId;
|
|
716
|
+
const localOrgId = projectConfig?.orgId;
|
|
717
|
+
const localCourseId = projectConfig?.courseId;
|
|
718
|
+
|
|
719
|
+
if (rcCloudId && localOrgId) {
|
|
720
|
+
return { orgId: localOrgId, courseId: rcCloudId };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (localOrgId && localCourseId) {
|
|
724
|
+
return { orgId: localOrgId, courseId: localCourseId };
|
|
536
725
|
}
|
|
537
726
|
|
|
538
727
|
// Call resolve endpoint
|
|
@@ -541,7 +730,8 @@ async function resolveOrgAndCourse(slug, token) {
|
|
|
541
730
|
|
|
542
731
|
// Found in exactly one org
|
|
543
732
|
if (data.found) {
|
|
544
|
-
|
|
733
|
+
writeProjectConfig({ slug, orgId: data.orgId, courseId: data.courseId });
|
|
734
|
+
writeRcConfig({ cloudId: data.courseId, orgId: data.orgId });
|
|
545
735
|
return { orgId: data.orgId, courseId: data.courseId, orgName: data.orgName };
|
|
546
736
|
}
|
|
547
737
|
|
|
@@ -558,7 +748,8 @@ async function resolveOrgAndCourse(slug, token) {
|
|
|
558
748
|
process.exit(1);
|
|
559
749
|
}
|
|
560
750
|
const match = data.matches[idx];
|
|
561
|
-
|
|
751
|
+
writeProjectConfig({ slug, orgId: match.orgId, courseId: match.courseId });
|
|
752
|
+
writeRcConfig({ cloudId: match.courseId, orgId: match.orgId });
|
|
562
753
|
return { orgId: match.orgId, courseId: match.courseId, orgName: match.orgName };
|
|
563
754
|
}
|
|
564
755
|
|
|
@@ -638,7 +829,7 @@ export async function login(options = {}) {
|
|
|
638
829
|
}
|
|
639
830
|
|
|
640
831
|
/**
|
|
641
|
-
* coursecode logout — delete credentials
|
|
832
|
+
* coursecode logout — delete Cloud credentials
|
|
642
833
|
*/
|
|
643
834
|
export async function logout(options = {}) {
|
|
644
835
|
deleteCredentials();
|
|
@@ -734,6 +925,39 @@ export async function deploy(options = {}) {
|
|
|
734
925
|
const log = (...args) => { if (!options.json) console.log(...args); };
|
|
735
926
|
const logErr = (...args) => { if (!options.json) console.error(...args); };
|
|
736
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
|
+
|
|
737
961
|
// Validate mutually exclusive flags
|
|
738
962
|
if (options.promote && options.stage) {
|
|
739
963
|
logErr('\n❌ --promote and --stage are mutually exclusive.\n');
|
|
@@ -817,8 +1041,9 @@ export async function deploy(options = {}) {
|
|
|
817
1041
|
|
|
818
1042
|
const result = await makeRequest();
|
|
819
1043
|
|
|
820
|
-
// Step 6:
|
|
1044
|
+
// Step 6: Persist per-user binding and stamp cloud identity into .coursecoderc.json
|
|
821
1045
|
const finalCourseId = result.courseId || courseId;
|
|
1046
|
+
writeProjectConfig({ slug, orgId: result.orgId || orgId, courseId: finalCourseId });
|
|
822
1047
|
writeRcConfig({
|
|
823
1048
|
cloudId: finalCourseId,
|
|
824
1049
|
orgId: result.orgId || orgId,
|
|
@@ -926,7 +1151,46 @@ export async function promote(options = {}) {
|
|
|
926
1151
|
return handleResponse(res, { retryFn: makeRequest, _isRetry });
|
|
927
1152
|
};
|
|
928
1153
|
|
|
929
|
-
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 });
|
|
930
1194
|
|
|
931
1195
|
if (options.json) {
|
|
932
1196
|
process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
|
|
@@ -965,7 +1229,43 @@ export async function status(options = {}) {
|
|
|
965
1229
|
return handleResponse(res, { retryFn: makeRequest, _isRetry });
|
|
966
1230
|
};
|
|
967
1231
|
|
|
968
|
-
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 });
|
|
969
1269
|
|
|
970
1270
|
if (options.json) {
|
|
971
1271
|
process.stdout.write(JSON.stringify(data) + '\n');
|
|
@@ -1050,7 +1350,52 @@ export async function deleteCourse(options = {}) {
|
|
|
1050
1350
|
return handleResponse(res, { retryFn: makeRequest, _isRetry });
|
|
1051
1351
|
};
|
|
1052
1352
|
|
|
1053
|
-
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 });
|
|
1054
1399
|
|
|
1055
1400
|
if (options.json) {
|
|
1056
1401
|
process.stdout.write(JSON.stringify({ success: true, ...result }) + '\n');
|
package/lib/convert.js
CHANGED
|
@@ -18,7 +18,8 @@ export async function convert(source, options) {
|
|
|
18
18
|
format = 'all',
|
|
19
19
|
dryRun = false,
|
|
20
20
|
overwrite = false,
|
|
21
|
-
flatten = false
|
|
21
|
+
flatten = false,
|
|
22
|
+
pdfJson = false
|
|
22
23
|
} = options;
|
|
23
24
|
|
|
24
25
|
console.log('\n📄 Converting documents to markdown...\n');
|
|
@@ -111,8 +112,8 @@ export async function convert(source, options) {
|
|
|
111
112
|
// Write output
|
|
112
113
|
await fs.writeFile(outputFile, result.markdown, 'utf-8');
|
|
113
114
|
|
|
114
|
-
//
|
|
115
|
-
if (result.data) {
|
|
115
|
+
// PDF structure sidecar is optional because it is much larger/token-heavier than markdown.
|
|
116
|
+
if (result.data && pdfJson) {
|
|
116
117
|
const jsonPath = outputFile.replace(/\.md$/, '.json');
|
|
117
118
|
await fs.writeFile(jsonPath, JSON.stringify(result.data, null, 2), 'utf-8');
|
|
118
119
|
}
|
|
@@ -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
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Enforces real LMS behavior — lifecycle violations return 'false' with proper
|
|
10
10
|
* error codes, read-only elements reject SetValue, and format-specific rules
|
|
11
11
|
* are enforced. This catches bugs that silently pass in dev but explode in
|
|
12
|
-
* production LMSs like
|
|
12
|
+
* production SCORM/cmi5/LTI environments and LMSs like Moodle or Cornerstone.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
const isBrowser = typeof window !== 'undefined';
|