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 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) return 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 cloudUrl = getCloudUrl();
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 loginUrl = `${cloudUrl}/auth/connect?session=${nonce}`;
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, cloudUrl);
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
- // .coursecoderc.json is committed and shared — it is the single source of truth
532
- // for the cloud binding. If both cloudId and orgId are present, short-circuit.
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
- if (rcConfig?.cloudId && rcConfig?.orgId) {
535
- return { orgId: rcConfig.orgId, courseId: rcConfig.cloudId };
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
- writeRcConfig({ orgId: data.orgId, cloudId: data.courseId });
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
- writeRcConfig({ orgId: match.orgId, cloudId: match.courseId });
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 and local project.json
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: Stamp cloudId + orgId into .coursecoderc.json (committed, shared with team)
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 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 });
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 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 });
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 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 });
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
- // Write structured data if available (for AI analysis)
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
- // 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
 
@@ -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 SCORM Cloud, Moodle, Cornerstone, etc.
12
+ * production SCORM/cmi5/LTI environments and LMSs like Moodle or Cornerstone.
13
13
  */
14
14
 
15
15
  const isBrowser = typeof window !== 'undefined';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.11",
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": {