coursecode 0.1.11 → 0.1.13

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/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);
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,32 @@ 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
+ );
109
145
  }
110
146
 
111
147
  // =============================================================================
@@ -176,7 +212,10 @@ async function cloudFetch(urlPath, options = {}, token = null) {
176
212
  // Primary unreachable — try fallback before giving up
177
213
  if (!useLocal) {
178
214
  const fallback = await attemptFetch(FALLBACK_CLOUD_URL);
179
- if (fallback) return fallback;
215
+ if (fallback) {
216
+ activeCloudUrl = FALLBACK_CLOUD_URL;
217
+ return fallback;
218
+ }
180
219
  }
181
220
  console.error('\n❌ Could not connect to CourseCode Cloud. Check your internet connection.\n');
182
221
  process.exit(1);
@@ -185,11 +224,29 @@ async function cloudFetch(urlPath, options = {}, token = null) {
185
224
  // Peek at the body: if it's an HTML block page, silently retry on the fallback.
186
225
  // We must buffer the text here since Response bodies can only be read once.
187
226
  const text = await res.text();
227
+
228
+ // Token may be valid on the alternate cloud origin. Before triggering re-auth,
229
+ // retry authenticated 401s once on the other known origin.
230
+ if (res.status === 401 && token && !useLocal) {
231
+ const alternateUrl = primaryUrl === FALLBACK_CLOUD_URL ? DEFAULT_CLOUD_URL : FALLBACK_CLOUD_URL;
232
+ const alternateRes = await attemptFetch(alternateUrl);
233
+ if (alternateRes) {
234
+ const alternateText = await alternateRes.text();
235
+ if (!isBlockPage(alternateText) && alternateRes.status !== 401) {
236
+ activeCloudUrl = alternateUrl;
237
+ updateCredentialsCloudUrl(alternateUrl);
238
+ return syntheticResponse(alternateText, alternateRes.status);
239
+ }
240
+ }
241
+ }
242
+
188
243
  if (isBlockPage(text) && !useLocal) {
189
244
  const fallbackRes = await attemptFetch(FALLBACK_CLOUD_URL);
190
245
  if (fallbackRes) {
191
246
  const fallbackText = await fallbackRes.text();
192
247
  if (!isBlockPage(fallbackText)) {
248
+ activeCloudUrl = FALLBACK_CLOUD_URL;
249
+ updateCredentialsCloudUrl(FALLBACK_CLOUD_URL);
193
250
  // Fallback succeeded — return a synthetic Response with the buffered text
194
251
  return syntheticResponse(fallbackText, fallbackRes.status);
195
252
  }
@@ -200,6 +257,7 @@ async function cloudFetch(urlPath, options = {}, token = null) {
200
257
  }
201
258
 
202
259
  // Primary response is fine — return a synthetic Response with the buffered text
260
+ activeCloudUrl = primaryUrl;
203
261
  return syntheticResponse(text, res.status);
204
262
  }
205
263
 
@@ -435,7 +493,7 @@ async function runLoginFlow({ jsonMode = false } = {}) {
435
493
  if (data.pending) continue;
436
494
 
437
495
  if (data.token) {
438
- writeCredentials(data.token, getCloudUrl());
496
+ writeCredentials(data.token, activeCloudUrl || getCloudUrl());
439
497
  log(' ✓ Logged in successfully\n');
440
498
  return data.token;
441
499
  }
@@ -453,7 +511,7 @@ async function runLoginFlow({ jsonMode = false } = {}) {
453
511
  */
454
512
  async function runLegacyLoginFlow() {
455
513
  const nonce = crypto.randomBytes(32).toString('hex');
456
- const cloudUrl = getCloudUrl();
514
+ const initialCloudUrl = getCloudUrl();
457
515
 
458
516
  console.log(' → Registering session...');
459
517
  const createRes = await cloudFetch('/api/auth/connect', {
@@ -469,7 +527,8 @@ async function runLegacyLoginFlow() {
469
527
  process.exit(1);
470
528
  }
471
529
 
472
- const loginUrl = `${cloudUrl}/auth/connect?session=${nonce}`;
530
+ const effectiveCloudUrl = activeCloudUrl || initialCloudUrl;
531
+ const loginUrl = `${effectiveCloudUrl}/auth/connect?session=${nonce}`;
473
532
  console.log(' → Opening browser for authentication...');
474
533
  openBrowser(loginUrl);
475
534
 
@@ -490,7 +549,7 @@ async function runLegacyLoginFlow() {
490
549
  if (data.pending) continue;
491
550
 
492
551
  if (data.token) {
493
- writeCredentials(data.token, cloudUrl);
552
+ writeCredentials(data.token, activeCloudUrl || initialCloudUrl);
494
553
  console.log(' ✓ Logged in successfully');
495
554
  return data.token;
496
555
  }
@@ -528,11 +587,21 @@ export async function ensureAuthenticated() {
528
587
  * Returns { orgId, courseId, orgName } or prompts the user.
529
588
  */
530
589
  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.
590
+ // Shared binding (committed): cloudId in .coursecoderc.json.
591
+ // Local binding (per-user): orgId/courseId in .coursecode/project.json.
592
+ // This keeps login global while allowing per-course auth context.
533
593
  const rcConfig = readRcConfig();
534
- if (rcConfig?.cloudId && rcConfig?.orgId) {
535
- return { orgId: rcConfig.orgId, courseId: rcConfig.cloudId };
594
+ const projectConfig = readProjectConfig();
595
+ const rcCloudId = rcConfig?.cloudId;
596
+ const localOrgId = projectConfig?.orgId;
597
+ const localCourseId = projectConfig?.courseId;
598
+
599
+ if (rcCloudId && localOrgId) {
600
+ return { orgId: localOrgId, courseId: rcCloudId };
601
+ }
602
+
603
+ if (localOrgId && localCourseId) {
604
+ return { orgId: localOrgId, courseId: localCourseId };
536
605
  }
537
606
 
538
607
  // Call resolve endpoint
@@ -541,7 +610,8 @@ async function resolveOrgAndCourse(slug, token) {
541
610
 
542
611
  // Found in exactly one org
543
612
  if (data.found) {
544
- writeRcConfig({ orgId: data.orgId, cloudId: data.courseId });
613
+ writeProjectConfig({ slug, orgId: data.orgId, courseId: data.courseId });
614
+ writeRcConfig({ cloudId: data.courseId, orgId: data.orgId });
545
615
  return { orgId: data.orgId, courseId: data.courseId, orgName: data.orgName };
546
616
  }
547
617
 
@@ -558,7 +628,8 @@ async function resolveOrgAndCourse(slug, token) {
558
628
  process.exit(1);
559
629
  }
560
630
  const match = data.matches[idx];
561
- writeRcConfig({ orgId: match.orgId, cloudId: match.courseId });
631
+ writeProjectConfig({ slug, orgId: match.orgId, courseId: match.courseId });
632
+ writeRcConfig({ cloudId: match.courseId, orgId: match.orgId });
562
633
  return { orgId: match.orgId, courseId: match.courseId, orgName: match.orgName };
563
634
  }
564
635
 
@@ -638,7 +709,7 @@ export async function login(options = {}) {
638
709
  }
639
710
 
640
711
  /**
641
- * coursecode logout — delete credentials and local project.json
712
+ * coursecode logout — delete Cloud credentials
642
713
  */
643
714
  export async function logout(options = {}) {
644
715
  deleteCredentials();
@@ -817,8 +888,9 @@ export async function deploy(options = {}) {
817
888
 
818
889
  const result = await makeRequest();
819
890
 
820
- // Step 6: Stamp cloudId + orgId into .coursecoderc.json (committed, shared with team)
891
+ // Step 6: Persist per-user binding and stamp cloud identity into .coursecoderc.json
821
892
  const finalCourseId = result.courseId || courseId;
893
+ writeProjectConfig({ slug, orgId: result.orgId || orgId, courseId: finalCourseId });
822
894
  writeRcConfig({
823
895
  cloudId: finalCourseId,
824
896
  orgId: result.orgId || orgId,
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
  }
@@ -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.13",
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": {