@learnpack/learnpack 5.0.343 → 5.0.346

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.
@@ -87,7 +87,8 @@ import { HistoryManager } from "../managers/historyManager"
87
87
  import { ReadmeHistoryService } from "../managers/readmeHistoryService"
88
88
  import { sanitizeReadmeNewlines } from "../utils/readmeSanitizer"
89
89
 
90
- const frontMatter = require("front-matter")
90
+ // eslint-disable-next-line
91
+ const frontMatter = require("front-matter");
91
92
 
92
93
  if (process.env.NEW_RELIC_ENABLED === "true") {
93
94
  require("newrelic")
@@ -174,6 +175,244 @@ const uploadFileToBucket = async (
174
175
  await fileRef.save(Buffer.from(content, "utf8"))
175
176
  }
176
177
 
178
+ const uploadBinaryToBucket = async (
179
+ bucket: Bucket,
180
+ buffer: Buffer,
181
+ path: string,
182
+ contentType?: string
183
+ ) => {
184
+ const fileRef = bucket.file(path)
185
+ await fileRef.save(buffer, {
186
+ resumable: false,
187
+ ...(contentType && { contentType }),
188
+ })
189
+ }
190
+
191
+ const getGithubCredentials = () => {
192
+ const token = process.env.GITHUB_TOKEN?.trim()
193
+ const username = process.env.GITHUB_USERNAME?.trim()
194
+ return { token, username, isConfigured: Boolean(token && username) }
195
+ }
196
+
197
+ type GithubOperation =
198
+ | "create_repo"
199
+ | "get_base_ref"
200
+ | "create_blob"
201
+ | "create_tree"
202
+ | "create_commit"
203
+ | "update_ref";
204
+
205
+ type GithubCallContext = {
206
+ operation: GithubOperation;
207
+ repository?: string;
208
+ failedPath?: string;
209
+ repoCreated?: boolean;
210
+ repoName?: string;
211
+ retryOnUnavailableConflict?: boolean;
212
+ };
213
+
214
+ const GITHUB_RETRY_DELAYS_MS = [250, 600, 1200]
215
+
216
+ const wait = (ms: number) =>
217
+ new Promise<void>(resolve => {
218
+ setTimeout(resolve, ms)
219
+ })
220
+
221
+ const getGithubErrorStatus = (error: any): number | undefined =>
222
+ error?.response?.status
223
+
224
+ const getGithubErrorData = (error: any): any => error?.response?.data || {}
225
+
226
+ const getGithubErrorMessage = (error: any): string | undefined => {
227
+ const data = getGithubErrorData(error)
228
+ if (typeof data?.message === "string") {
229
+ return data.message
230
+ }
231
+
232
+ if (typeof error?.message === "string") {
233
+ return error.message
234
+ }
235
+
236
+ return undefined
237
+ }
238
+
239
+ const errorPayloadHasText = (payload: unknown, term: string): boolean => {
240
+ if (!payload || !term) return false
241
+
242
+ try {
243
+ return JSON.stringify(payload).toLowerCase().includes(term.toLowerCase())
244
+ } catch {
245
+ return false
246
+ }
247
+ }
248
+
249
+ const isGithubPushProtectionConflict = (error: any): boolean => {
250
+ if (getGithubErrorStatus(error) !== 409) return false
251
+
252
+ const message = (getGithubErrorMessage(error) || "").toLowerCase()
253
+ const data = getGithubErrorData(error)
254
+
255
+ return (
256
+ message.includes("push protection") ||
257
+ message.includes("secret scanning") ||
258
+ message.includes("secret") ||
259
+ errorPayloadHasText(data?.errors, "push protection") ||
260
+ errorPayloadHasText(data?.errors, "secret")
261
+ )
262
+ }
263
+
264
+ const isGithubRepoUnavailableConflict = (error: any): boolean => {
265
+ if (getGithubErrorStatus(error) !== 409) return false
266
+
267
+ const message = (getGithubErrorMessage(error) || "").toLowerCase()
268
+ return (
269
+ message.includes("empty or unavailable") ||
270
+ message.includes("repository is empty") ||
271
+ message.includes("repository is unavailable") ||
272
+ message.includes("currently being created")
273
+ )
274
+ }
275
+
276
+ const shouldRetryGithubConflict = (error: any): boolean =>
277
+ isGithubRepoUnavailableConflict(error) &&
278
+ !isGithubPushProtectionConflict(error)
279
+
280
+ const toGithubOperationalError = (
281
+ error: any,
282
+ context: GithubCallContext
283
+ ): Error => {
284
+ if (error?.isOperational === true) {
285
+ return error
286
+ }
287
+
288
+ const status = getGithubErrorStatus(error)
289
+ const data = getGithubErrorData(error)
290
+ const githubMessage = getGithubErrorMessage(error)
291
+ const details = {
292
+ failedOperation: context.operation,
293
+ ...(context.failedPath ? { failedPath: context.failedPath } : {}),
294
+ ...(context.repository ? { repository: context.repository } : {}),
295
+ ...(context.repoCreated ? { repoCreated: true } : {}),
296
+ github: {
297
+ status,
298
+ message: githubMessage,
299
+ documentation_url: data?.documentation_url,
300
+ errors: data?.errors,
301
+ },
302
+ }
303
+
304
+ let message =
305
+ githubMessage ||
306
+ `GitHub request failed during operation "${context.operation}"`
307
+
308
+ if (status === 422 && context.operation === "create_repo") {
309
+ message = context.repoName ?
310
+ `Repository "${context.repoName}" may already exist or name is invalid` :
311
+ "Repository may already exist or the requested name is invalid"
312
+ } else if (status === 409 && isGithubPushProtectionConflict(error)) {
313
+ message = context.failedPath ?
314
+ `File blocked by push protection: ${context.failedPath}` :
315
+ "GitHub push protection blocked sensitive content during the initial commit"
316
+ } else if (context.repoCreated && context.operation !== "create_repo") {
317
+ message =
318
+ "Repo created on GitHub, but the initial commit failed. Check the details and try again."
319
+ }
320
+
321
+ if (status === 400) return new ValidationError(message, details)
322
+ if (status === 404) return new NotFoundError(message, details)
323
+ if (status === 409 || status === 422)
324
+ return new ConflictError(message, details)
325
+
326
+ return new InternalServerError(message, details)
327
+ }
328
+
329
+ const githubApiFetch = async (
330
+ url: string,
331
+ token: string,
332
+ options: { method?: string; body?: unknown } = {}
333
+ ) => {
334
+ const res = await axios({
335
+ url,
336
+ method: options.method || "GET",
337
+ headers: {
338
+ Authorization: `token ${token}`,
339
+ Accept: "application/vnd.github.v3+json",
340
+ },
341
+ data: options.body,
342
+ })
343
+ return res.data
344
+ }
345
+
346
+ const githubApiFetchWithContext = async (
347
+ url: string,
348
+ token: string,
349
+ context: GithubCallContext,
350
+ options: { method?: string; body?: unknown } = {}
351
+ ) => {
352
+ try {
353
+ return await githubApiFetch(url, token, options)
354
+ } catch (error) {
355
+ throw toGithubOperationalError(error, context)
356
+ }
357
+ }
358
+
359
+ const githubApiFetchWithRetry = async (
360
+ url: string,
361
+ token: string,
362
+ context: GithubCallContext,
363
+ options: { method?: string; body?: unknown } = {}
364
+ ) => {
365
+ const attempts = context.retryOnUnavailableConflict ?
366
+ GITHUB_RETRY_DELAYS_MS.length + 1 :
367
+ 1
368
+
369
+ for (let attempt = 0; attempt < attempts; attempt++) {
370
+ try {
371
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
372
+ return await githubApiFetch(url, token, options)
373
+ } catch (error) {
374
+ const canRetry =
375
+ attempt < attempts - 1 && shouldRetryGithubConflict(error)
376
+ if (!canRetry) {
377
+ throw toGithubOperationalError(error, context)
378
+ }
379
+
380
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
381
+ await wait(GITHUB_RETRY_DELAYS_MS[attempt])
382
+ }
383
+ }
384
+
385
+ throw new InternalServerError("GitHub request failed after retries", {
386
+ failedOperation: context.operation,
387
+ repository: context.repository,
388
+ })
389
+ }
390
+
391
+ const mapWithConcurrency = async <T, R>(
392
+ items: T[],
393
+ limit: number,
394
+ mapper: (item: T, index: number) => Promise<R>
395
+ ): Promise<R[]> => {
396
+ if (items.length === 0) {
397
+ return []
398
+ }
399
+
400
+ const concurrency = Math.max(1, Math.min(limit, items.length))
401
+ const results = Array.from({ length: items.length }) as R[]
402
+ let cursor = 0
403
+
404
+ const worker = async () => {
405
+ let index
406
+ while ((index = cursor++) < items.length) {
407
+ // eslint-disable-next-line no-await-in-loop -- intentional: each worker processes items sequentially
408
+ results[index] = await mapper(items[index], index)
409
+ }
410
+ }
411
+
412
+ await Promise.all(Array.from({ length: concurrency }, () => worker()))
413
+ return results
414
+ }
415
+
177
416
  const PARAMS = {
178
417
  expected_grade_level: "8",
179
418
  max_fkgl: 10,
@@ -386,6 +625,7 @@ const createMultiLangAsset = async (
386
625
  deployUrl,
387
626
  b64IndexReadme,
388
627
  academyId,
628
+ undefined,
389
629
  all_translations
390
630
  )
391
631
 
@@ -592,6 +832,23 @@ const getConfigJSON = async (bucket: Bucket, courseSlug: string) => {
592
832
  return JSON.parse(content.toString())
593
833
  }
594
834
 
835
+ async function mergeConfigPreservingGithub(
836
+ bucket: Bucket,
837
+ courseSlug: string,
838
+ newConfig: Record<string, unknown>
839
+ ): Promise<Record<string, unknown>> {
840
+ try {
841
+ const existing = await getConfigJSON(bucket, courseSlug)
842
+ if (existing?.config?.github) {
843
+ return { ...newConfig, github: { ...existing.config.github } }
844
+ }
845
+ } catch {
846
+ // No existing config or parse error - use newConfig as-is
847
+ }
848
+
849
+ return newConfig
850
+ }
851
+
595
852
  async function getSyllabus(
596
853
  courseSlug: string,
597
854
  bucket: Bucket
@@ -2826,7 +3083,15 @@ class ServeCommand extends SessionCommand {
2826
3083
  }
2827
3084
 
2828
3085
  try {
2829
- const { config, exercises } = await buildConfig(bucket, courseSlug)
3086
+ const { config: builtConfig, exercises } = await buildConfig(
3087
+ bucket,
3088
+ courseSlug
3089
+ )
3090
+ const config = await mergeConfigPreservingGithub(
3091
+ bucket,
3092
+ courseSlug,
3093
+ builtConfig
3094
+ )
2830
3095
 
2831
3096
  res.set("X-Creator-Web", "true")
2832
3097
  res.set("Access-Control-Expose-Headers", "X-Creator-Web")
@@ -4441,6 +4706,8 @@ class ServeCommand extends SessionCommand {
4441
4706
  title: string;
4442
4707
  slug: string;
4443
4708
  }> = []
4709
+ let repairedTranslationsInLessons = 0
4710
+ let repairedTranslationEntries = 0
4444
4711
 
4445
4712
  console.log(
4446
4713
  `📋 Checking ${syllabus.lessons.length} lessons in syllabus...`
@@ -4622,10 +4889,82 @@ class ServeCommand extends SessionCommand {
4622
4889
  })
4623
4890
  }
4624
4891
 
4625
- if (totalRemoved > 0 || addedLessons.length > 0) {
4892
+ // Fourth pass: reconcile lesson.translations from actual README files in bucket.
4893
+ // This fixes lessons that exist but lost translations metadata after renames or syncs.
4894
+ try {
4895
+ const { exercises } = await buildConfig(bucket, courseSlug)
4896
+ const translationsBySlug = new Map<string, string[]>()
4897
+
4898
+ for (const exercise of exercises) {
4899
+ const langs = Object.keys(exercise.translations || {})
4900
+ .map(lang => lang.toLowerCase())
4901
+ .filter(Boolean)
4902
+ if (langs.length > 0) {
4903
+ translationsBySlug.set(exercise.slug, [...new Set(langs)])
4904
+ }
4905
+ }
4906
+
4907
+ for (const lesson of syllabus.lessons) {
4908
+ const candidateSlugs = [
4909
+ slugify(lesson.id + "-" + lesson.title),
4910
+ lesson.uid,
4911
+ ].filter(Boolean)
4912
+ const matchedSlug = candidateSlugs.find(s =>
4913
+ translationsBySlug.has(s)
4914
+ )
4915
+ if (!matchedSlug) continue
4916
+
4917
+ const languageCodes = translationsBySlug.get(matchedSlug) || []
4918
+ if (languageCodes.length === 0) continue
4919
+
4920
+ const currentTranslations = lesson.translations || {}
4921
+ let lessonChanged = false
4922
+
4923
+ for (const lang of languageCodes) {
4924
+ const now = Date.now()
4925
+ if (!currentTranslations[lang]) {
4926
+ currentTranslations[lang] = {
4927
+ completionId: 0,
4928
+ startedAt: now,
4929
+ completedAt: now,
4930
+ }
4931
+ repairedTranslationEntries += 1
4932
+ lessonChanged = true
4933
+ } else {
4934
+ // If README exists for this language but metadata is incomplete, mark it as completed.
4935
+ if (!currentTranslations[lang].startedAt) {
4936
+ currentTranslations[lang].startedAt = now
4937
+ lessonChanged = true
4938
+ }
4939
+
4940
+ if (!currentTranslations[lang].completedAt) {
4941
+ currentTranslations[lang].completedAt = now
4942
+ repairedTranslationEntries += 1
4943
+ lessonChanged = true
4944
+ }
4945
+ }
4946
+ }
4947
+
4948
+ if (lessonChanged) {
4949
+ lesson.translations = currentTranslations
4950
+ repairedTranslationsInLessons += 1
4951
+ }
4952
+ }
4953
+ } catch (error) {
4954
+ console.error(
4955
+ "⚠️ Could not reconcile lesson translations during syllabus sync:",
4956
+ error
4957
+ )
4958
+ }
4959
+
4960
+ if (
4961
+ totalRemoved > 0 ||
4962
+ addedLessons.length > 0 ||
4963
+ repairedTranslationsInLessons > 0
4964
+ ) {
4626
4965
  await saveSyllabus(courseSlug, syllabus, bucket)
4627
4966
  console.log(
4628
- `✅ Syllabus synchronized. Removed ${removedLessons.length} non-existent, ${duplicatesRemoved.length} duplicate(s); added ${addedLessons.length} from bucket.`
4967
+ `✅ Syllabus synchronized. Removed ${removedLessons.length} non-existent, ${duplicatesRemoved.length} duplicate(s); added ${addedLessons.length} from bucket; repaired translations in ${repairedTranslationsInLessons} lesson(s).`
4629
4968
  )
4630
4969
  } else {
4631
4970
  console.log(`✅ Syllabus is already in sync. No changes.`)
@@ -4639,6 +4978,8 @@ class ServeCommand extends SessionCommand {
4639
4978
  removedLessons: removedLessons.length,
4640
4979
  duplicatesResolved: duplicatesRemoved.length,
4641
4980
  addedLessons: addedLessons.length,
4981
+ repairedTranslationsInLessons,
4982
+ repairedTranslationEntries,
4642
4983
  removed: removedLessons,
4643
4984
  duplicates: duplicatesRemoved,
4644
4985
  kept: keptLessons,
@@ -5112,30 +5453,56 @@ class ServeCommand extends SessionCommand {
5112
5453
  const { config } = configJson
5113
5454
 
5114
5455
  const availableLangs = Object.keys(config.title || {})
5115
- let academyId: number | null = null
5116
5456
  let isPublished = false
5117
5457
 
5118
- for (const lang of availableLangs) {
5119
- const assetTitle = getLocalizedValue(config.title, lang)
5120
- if (!assetTitle) continue
5458
+ // Collect academy ids from all existing assets
5459
+ const foundAcademyIds: number[] = []
5121
5460
 
5122
- let assetSlug = slugify(assetTitle).slice(0, 47)
5123
- assetSlug = `${assetSlug}-${lang}`
5461
+ const slugsToCheck = availableLangs
5462
+ .map(lang => {
5463
+ const assetTitle = getLocalizedValue(config.title, lang)
5464
+ if (!assetTitle) return null
5465
+ const assetSlug = slugify(assetTitle).slice(0, 47)
5466
+ return `${assetSlug}-${lang}`
5467
+ })
5468
+ .filter((slug): slug is string => slug !== null)
5124
5469
 
5125
- const { exists, academyId: existingAcademyId } =
5126
- // eslint-disable-next-line no-await-in-loop
5127
- await doesAssetExists(bcToken, assetSlug)
5470
+ const assetChecks = await Promise.all(
5471
+ slugsToCheck.map(slug => doesAssetExists(bcToken, slug))
5472
+ )
5128
5473
 
5474
+ for (const { exists, academyId: existingAcademyId } of assetChecks) {
5129
5475
  if (exists) {
5130
5476
  isPublished = true
5131
5477
  if (existingAcademyId !== undefined) {
5132
- academyId = existingAcademyId
5133
- break
5478
+ foundAcademyIds.push(existingAcademyId)
5134
5479
  }
5135
5480
  }
5136
5481
  }
5137
5482
 
5138
- return res.json({ academyId, isPublished })
5483
+ const uniqueAcademies = [...new Set(foundAcademyIds)]
5484
+
5485
+ type AcademyMode = "select" | "locked" | "conflict";
5486
+ let mode: AcademyMode
5487
+ let lockedAcademyId: number | undefined
5488
+ let conflictAcademies: number[] | undefined
5489
+
5490
+ if (uniqueAcademies.length === 0) {
5491
+ mode = "select"
5492
+ } else if (uniqueAcademies.length === 1) {
5493
+ mode = "locked"
5494
+ lockedAcademyId = uniqueAcademies[0]
5495
+ } else {
5496
+ mode = "conflict"
5497
+ conflictAcademies = uniqueAcademies
5498
+ }
5499
+
5500
+ return res.json({
5501
+ isPublished,
5502
+ mode,
5503
+ lockedAcademyId,
5504
+ conflictAcademies,
5505
+ })
5139
5506
  } catch (error) {
5140
5507
  console.error("Error fetching package academy:", error)
5141
5508
  return res.status(500).json({ error: (error as Error).message })
@@ -5805,6 +6172,817 @@ class ServeCommand extends SessionCommand {
5805
6172
  }
5806
6173
  })
5807
6174
 
6175
+ app.get("/actions/github/status", async (req, res) => {
6176
+ const courseSlug = req.query.slug as string
6177
+ if (!courseSlug) {
6178
+ return res
6179
+ .status(400)
6180
+ .json({ error: "slug query parameter is required" })
6181
+ }
6182
+
6183
+ const { isConfigured } = getGithubCredentials()
6184
+
6185
+ try {
6186
+ const configJson = await getConfigJSON(bucket, courseSlug)
6187
+ const github = configJson.config?.github
6188
+ const repository = github?.repository
6189
+ const linked = Boolean(repository)
6190
+
6191
+ return res.json({
6192
+ configured: isConfigured,
6193
+ linked,
6194
+ repository: linked ? repository : null,
6195
+ })
6196
+ } catch {
6197
+ return res.json({
6198
+ configured: isConfigured,
6199
+ linked: false,
6200
+ repository: null,
6201
+ })
6202
+ }
6203
+ })
6204
+
6205
+ app.post(
6206
+ "/actions/github/create-repo",
6207
+ express.json(),
6208
+ asyncHandler(async (req, res) => {
6209
+ const { courseSlug, repoName, isPrivate, description } = req.body
6210
+
6211
+ if (!courseSlug) {
6212
+ throw new ValidationError("courseSlug is required in request body")
6213
+ }
6214
+
6215
+ const { token, username, isConfigured } = getGithubCredentials()
6216
+ if (!isConfigured || !token || !username) {
6217
+ throw new ValidationError(
6218
+ "GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env"
6219
+ )
6220
+ }
6221
+
6222
+ const configJson = await getConfigJSON(bucket, courseSlug)
6223
+ if (configJson.config?.github?.repository) {
6224
+ throw new ConflictError(
6225
+ "Package already has a linked GitHub repository"
6226
+ )
6227
+ }
6228
+
6229
+ const finalRepoName = repoName || courseSlug
6230
+ const finalDescription =
6231
+ description || "Tutorial created with LearnPack"
6232
+
6233
+ const createRepoUrl = "https://api.github.com/user/repos"
6234
+ const createdRepo = await githubApiFetchWithContext(
6235
+ createRepoUrl,
6236
+ token,
6237
+ {
6238
+ operation: "create_repo",
6239
+ repoName: finalRepoName,
6240
+ },
6241
+ {
6242
+ method: "POST",
6243
+ body: {
6244
+ name: finalRepoName,
6245
+ private: Boolean(isPrivate),
6246
+ description: finalDescription,
6247
+ auto_init: true,
6248
+ },
6249
+ }
6250
+ )
6251
+
6252
+ const repository =
6253
+ createdRepo?.full_name || `${username}/${finalRepoName}`
6254
+ const defaultBranch = createdRepo?.default_branch || "main"
6255
+ const repositoryUrl =
6256
+ createdRepo?.html_url || `https://github.com/${repository}`
6257
+
6258
+ const refRes = await githubApiFetchWithRetry(
6259
+ `https://api.github.com/repos/${repository}/git/ref/heads/${defaultBranch}`,
6260
+ token,
6261
+ {
6262
+ operation: "get_base_ref",
6263
+ repository,
6264
+ repoCreated: true,
6265
+ retryOnUnavailableConflict: true,
6266
+ },
6267
+ {}
6268
+ )
6269
+ const baseSHA =
6270
+ refRes.object?.sha ?? refRes.ref?.object?.sha ?? refRes.object?.sha
6271
+ if (!baseSHA) {
6272
+ throw new InternalServerError(
6273
+ "Could not get default branch SHA from GitHub after repository creation",
6274
+ {
6275
+ failedOperation: "get_base_ref",
6276
+ repository,
6277
+ repoCreated: true,
6278
+ }
6279
+ )
6280
+ }
6281
+
6282
+ const [allFiles] = await bucket.getFiles({
6283
+ prefix: `courses/${courseSlug}/`,
6284
+ })
6285
+
6286
+ const treeEntries = await mapWithConcurrency(
6287
+ allFiles,
6288
+ 5,
6289
+ async file => {
6290
+ const relativePath = file.name.replace(
6291
+ `courses/${courseSlug}/`,
6292
+ ""
6293
+ )
6294
+ const [content] = await file.download()
6295
+ const contentBase64 = content.toString("base64")
6296
+
6297
+ const blobRes = await githubApiFetchWithRetry(
6298
+ `https://api.github.com/repos/${repository}/git/blobs`,
6299
+ token,
6300
+ {
6301
+ operation: "create_blob",
6302
+ repository,
6303
+ failedPath: relativePath,
6304
+ repoCreated: true,
6305
+ retryOnUnavailableConflict: true,
6306
+ },
6307
+ {
6308
+ method: "POST",
6309
+ body: { content: contentBase64, encoding: "base64" },
6310
+ }
6311
+ )
6312
+
6313
+ return {
6314
+ path: relativePath,
6315
+ mode: "100644",
6316
+ type: "blob",
6317
+ sha: blobRes.sha,
6318
+ }
6319
+ }
6320
+ )
6321
+
6322
+ const treeRes = await githubApiFetchWithRetry(
6323
+ `https://api.github.com/repos/${repository}/git/trees`,
6324
+ token,
6325
+ {
6326
+ operation: "create_tree",
6327
+ repository,
6328
+ repoCreated: true,
6329
+ retryOnUnavailableConflict: true,
6330
+ },
6331
+ {
6332
+ method: "POST",
6333
+ body: { tree: treeEntries },
6334
+ }
6335
+ )
6336
+
6337
+ const commitRes = await githubApiFetchWithRetry(
6338
+ `https://api.github.com/repos/${repository}/git/commits`,
6339
+ token,
6340
+ {
6341
+ operation: "create_commit",
6342
+ repository,
6343
+ repoCreated: true,
6344
+ retryOnUnavailableConflict: true,
6345
+ },
6346
+ {
6347
+ method: "POST",
6348
+ body: {
6349
+ message: "Initial commit from LearnPack",
6350
+ tree: treeRes.sha,
6351
+ parents: [baseSHA],
6352
+ },
6353
+ }
6354
+ )
6355
+
6356
+ await githubApiFetchWithRetry(
6357
+ `https://api.github.com/repos/${repository}/git/refs/heads/${defaultBranch}`,
6358
+ token,
6359
+ {
6360
+ operation: "update_ref",
6361
+ repository,
6362
+ repoCreated: true,
6363
+ retryOnUnavailableConflict: true,
6364
+ },
6365
+ {
6366
+ method: "PATCH",
6367
+ body: {
6368
+ sha: commitRes.sha,
6369
+ force: false,
6370
+ },
6371
+ }
6372
+ )
6373
+
6374
+ configJson.config = configJson.config || {}
6375
+ configJson.config.github = {
6376
+ repository,
6377
+ defaultBranch,
6378
+ lastSyncSHA: commitRes.sha,
6379
+ }
6380
+ await uploadFileToBucket(
6381
+ bucket,
6382
+ JSON.stringify(configJson),
6383
+ `courses/${courseSlug}/.learn/config.json`
6384
+ )
6385
+
6386
+ return res.json({
6387
+ success: true,
6388
+ repository,
6389
+ url: repositoryUrl,
6390
+ sha: commitRes.sha,
6391
+ })
6392
+ })
6393
+ )
6394
+
6395
+ const GITHUB_STRUCTURAL_FILES = new Set([
6396
+ "learn.json",
6397
+ ".learn/config.json",
6398
+ ".learn/initialSyllabus.json",
6399
+ ".learn/sidebar.json",
6400
+ ".learn/memory_bank.txt",
6401
+ ])
6402
+
6403
+ const isStructuralFile = (relativePath: string) =>
6404
+ GITHUB_STRUCTURAL_FILES.has(relativePath) ||
6405
+ relativePath === "learn.json" ||
6406
+ (relativePath.startsWith(".learn/") &&
6407
+ !relativePath.startsWith(".learn/assets/"))
6408
+
6409
+ const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]
6410
+
6411
+ const isImageFile = (path: string) =>
6412
+ IMAGE_EXTENSIONS.some(ext => path.toLowerCase().endsWith(ext))
6413
+
6414
+ app.get(
6415
+ "/actions/github/check-changes",
6416
+ asyncHandler(async (req, res) => {
6417
+ const courseSlug = req.query.slug as string
6418
+ if (!courseSlug) {
6419
+ throw new ValidationError("slug query parameter is required")
6420
+ }
6421
+
6422
+ const { token, isConfigured } = getGithubCredentials()
6423
+ if (!isConfigured || !token) {
6424
+ throw new ValidationError(
6425
+ "GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env"
6426
+ )
6427
+ }
6428
+
6429
+ const configJson = await getConfigJSON(bucket, courseSlug)
6430
+ const repository = configJson.config?.github?.repository
6431
+ const lastSyncSHA = configJson.config?.github?.lastSyncSHA
6432
+ const defaultBranch =
6433
+ configJson.config?.github?.defaultBranch || "main"
6434
+
6435
+ if (!repository || !lastSyncSHA) {
6436
+ throw new ValidationError(
6437
+ "Package has no linked GitHub repository. Create one first."
6438
+ )
6439
+ }
6440
+
6441
+ const refRes = await githubApiFetch(
6442
+ `https://api.github.com/repos/${repository}/git/ref/heads/${defaultBranch}`,
6443
+ token
6444
+ )
6445
+ const currentBranchSHA =
6446
+ refRes.object?.sha ?? refRes.ref?.object?.sha ?? refRes.object?.sha
6447
+ if (!currentBranchSHA) {
6448
+ throw new ValidationError(
6449
+ `Could not get ${defaultBranch} branch SHA from GitHub`
6450
+ )
6451
+ }
6452
+
6453
+ const compareRes = await githubApiFetch(
6454
+ `https://api.github.com/repos/${repository}/compare/${lastSyncSHA}...${currentBranchSHA}`,
6455
+ token
6456
+ )
6457
+
6458
+ if (
6459
+ compareRes.status === "identical" ||
6460
+ !compareRes.files ||
6461
+ compareRes.files.length === 0
6462
+ ) {
6463
+ return res.json({ hasChanges: false })
6464
+ }
6465
+
6466
+ const [allExerciseFiles] = await bucket.getFiles({
6467
+ prefix: `courses/${courseSlug}/exercises/`,
6468
+ })
6469
+
6470
+ const lessonSlugs = new Set<string>()
6471
+ const lessonFilesMap = new Map<string, Set<string>>()
6472
+
6473
+ for (const file of allExerciseFiles) {
6474
+ const match = file.name.match(
6475
+ new RegExp(`courses/${courseSlug}/exercises/([^/]+)/(.+)$`)
6476
+ )
6477
+ if (match) {
6478
+ const [, slug, filePart] = match
6479
+ lessonSlugs.add(slug)
6480
+ if (!lessonFilesMap.has(slug)) {
6481
+ lessonFilesMap.set(slug, new Set())
6482
+ }
6483
+
6484
+ lessonFilesMap.get(slug)!.add(filePart)
6485
+ }
6486
+ }
6487
+
6488
+ const syncableLessons: Array<{
6489
+ slug: string;
6490
+ files: Array<{ filename: string; status: string }>;
6491
+ }> = []
6492
+ const syncableAssets: Array<{ filename: string; status: string }> = []
6493
+ const skippedFiles: Array<{
6494
+ filename: string;
6495
+ status: string;
6496
+ reason: string;
6497
+ }> = []
6498
+
6499
+ for (const f of compareRes.files) {
6500
+ const relativePath = f.filename
6501
+ const status = f.status || "modified"
6502
+
6503
+ if (isStructuralFile(relativePath)) {
6504
+ skippedFiles.push({
6505
+ filename: relativePath,
6506
+ status,
6507
+ reason: "Structural file",
6508
+ })
6509
+ continue
6510
+ }
6511
+
6512
+ const exercisesMatch = relativePath.match(
6513
+ /^exercises\/([^/]+)\/(.+)$/
6514
+ )
6515
+ if (exercisesMatch) {
6516
+ const [, slug, filename] = exercisesMatch
6517
+ if (!lessonSlugs.has(slug)) {
6518
+ skippedFiles.push({
6519
+ filename: relativePath,
6520
+ status,
6521
+ reason: "Lesson does not exist in bucket",
6522
+ })
6523
+ continue
6524
+ }
6525
+
6526
+ const existingFiles = lessonFilesMap.get(slug)!
6527
+ if (status !== "modified" || !existingFiles.has(filename)) {
6528
+ skippedFiles.push({
6529
+ filename: relativePath,
6530
+ status,
6531
+ reason:
6532
+ status === "modified" ?
6533
+ "File does not exist in bucket (only modified existing files are synced)" :
6534
+ "Structural change in lesson (only modified files are synced)",
6535
+ })
6536
+ continue
6537
+ }
6538
+
6539
+ let lessonEntry = syncableLessons.find(l => l.slug === slug)
6540
+ if (!lessonEntry) {
6541
+ lessonEntry = { slug, files: [] }
6542
+ syncableLessons.push(lessonEntry)
6543
+ }
6544
+
6545
+ lessonEntry.files.push({ filename, status })
6546
+ continue
6547
+ }
6548
+
6549
+ if (relativePath.startsWith(".learn/assets/")) {
6550
+ syncableAssets.push({ filename: relativePath, status })
6551
+ continue
6552
+ }
6553
+
6554
+ skippedFiles.push({
6555
+ filename: relativePath,
6556
+ status,
6557
+ reason: "Unknown path",
6558
+ })
6559
+ }
6560
+
6561
+ return res.json({
6562
+ hasChanges: true,
6563
+ currentSHA: currentBranchSHA,
6564
+ lastSyncSHA,
6565
+ syncableChanges: {
6566
+ lessons: syncableLessons,
6567
+ assets: syncableAssets,
6568
+ totalFiles:
6569
+ syncableLessons.reduce((s, l) => s + l.files.length, 0) +
6570
+ syncableAssets.length,
6571
+ },
6572
+ skippedChanges: {
6573
+ files: skippedFiles,
6574
+ totalFiles: skippedFiles.length,
6575
+ },
6576
+ })
6577
+ })
6578
+ )
6579
+
6580
+ app.post(
6581
+ "/actions/github/pull",
6582
+ express.json(),
6583
+ asyncHandler(async (req, res) => {
6584
+ const { courseSlug, targetSHA, lessons } = req.body
6585
+
6586
+ if (!courseSlug || !targetSHA) {
6587
+ throw new ValidationError(
6588
+ "courseSlug and targetSHA are required in request body"
6589
+ )
6590
+ }
6591
+
6592
+ const { token, isConfigured } = getGithubCredentials()
6593
+ if (!isConfigured || !token) {
6594
+ throw new ValidationError(
6595
+ "GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env"
6596
+ )
6597
+ }
6598
+
6599
+ const configJson = await getConfigJSON(bucket, courseSlug)
6600
+ const repository = configJson.config?.github?.repository
6601
+ const lastSyncSHA = configJson.config?.github?.lastSyncSHA
6602
+
6603
+ if (!repository || !lastSyncSHA) {
6604
+ throw new ValidationError(
6605
+ "Package has no linked GitHub repository. Create one first."
6606
+ )
6607
+ }
6608
+
6609
+ const [allExerciseFiles] = await bucket.getFiles({
6610
+ prefix: `courses/${courseSlug}/exercises/`,
6611
+ })
6612
+
6613
+ const lessonSlugs = new Set<string>()
6614
+ const lessonFilesMap = new Map<string, Set<string>>()
6615
+
6616
+ for (const file of allExerciseFiles) {
6617
+ const match = file.name.match(
6618
+ new RegExp(`courses/${courseSlug}/exercises/([^/]+)/(.+)$`)
6619
+ )
6620
+ if (match) {
6621
+ const [, slug, filePart] = match
6622
+ lessonSlugs.add(slug)
6623
+ if (!lessonFilesMap.has(slug)) {
6624
+ lessonFilesMap.set(slug, new Set())
6625
+ }
6626
+
6627
+ lessonFilesMap.get(slug)!.add(filePart)
6628
+ }
6629
+ }
6630
+
6631
+ const compareRes = await githubApiFetch(
6632
+ `https://api.github.com/repos/${repository}/compare/${lastSyncSHA}...${targetSHA}`,
6633
+ token
6634
+ )
6635
+
6636
+ if (
6637
+ compareRes.status === "identical" ||
6638
+ !compareRes.files ||
6639
+ compareRes.files.length === 0
6640
+ ) {
6641
+ configJson.config = configJson.config || {}
6642
+ configJson.config.github = {
6643
+ ...configJson.config.github,
6644
+ lastSyncSHA: targetSHA,
6645
+ }
6646
+ await uploadFileToBucket(
6647
+ bucket,
6648
+ JSON.stringify(configJson),
6649
+ `courses/${courseSlug}/.learn/config.json`
6650
+ )
6651
+ return res.json({
6652
+ success: true,
6653
+ syncedLessons: [],
6654
+ syncedAssets: 0,
6655
+ syncedFiles: 0,
6656
+ removedFiles: 0,
6657
+ skippedFiles: [],
6658
+ newSHA: targetSHA,
6659
+ })
6660
+ }
6661
+
6662
+ const lessonFilter =
6663
+ Array.isArray(lessons) && lessons.length > 0 ?
6664
+ new Set(lessons as string[]) :
6665
+ null
6666
+
6667
+ const toSync: Array<{
6668
+ relativePath: string;
6669
+ status: string;
6670
+ bucketPath: string;
6671
+ isAsset: boolean;
6672
+ previousBucketPath?: string;
6673
+ }> = []
6674
+ const skippedFiles: Array<{
6675
+ filename: string;
6676
+ status: string;
6677
+ reason: string;
6678
+ }> = []
6679
+
6680
+ for (const f of compareRes.files) {
6681
+ const relativePath = f.filename
6682
+ const status = f.status || "modified"
6683
+
6684
+ if (isStructuralFile(relativePath)) {
6685
+ skippedFiles.push({
6686
+ filename: relativePath,
6687
+ status,
6688
+ reason: "Structural file",
6689
+ })
6690
+ continue
6691
+ }
6692
+
6693
+ const exercisesMatch = relativePath.match(
6694
+ /^exercises\/([^/]+)\/(.+)$/
6695
+ )
6696
+ if (exercisesMatch) {
6697
+ const [, slug, filename] = exercisesMatch
6698
+ if (lessonFilter && !lessonFilter.has(slug)) continue
6699
+ if (!lessonSlugs.has(slug)) {
6700
+ skippedFiles.push({
6701
+ filename: relativePath,
6702
+ status,
6703
+ reason: "Lesson does not exist in bucket",
6704
+ })
6705
+ continue
6706
+ }
6707
+
6708
+ if (
6709
+ status !== "modified" ||
6710
+ !lessonFilesMap.get(slug)!.has(filename)
6711
+ ) {
6712
+ skippedFiles.push({
6713
+ filename: relativePath,
6714
+ status,
6715
+ reason:
6716
+ status === "modified" ?
6717
+ "File does not exist in bucket" :
6718
+ "Structural change in lesson",
6719
+ })
6720
+ continue
6721
+ }
6722
+
6723
+ toSync.push({
6724
+ relativePath,
6725
+ status,
6726
+ bucketPath: `courses/${courseSlug}/exercises/${slug}/${filename}`,
6727
+ isAsset: false,
6728
+ })
6729
+ continue
6730
+ }
6731
+
6732
+ if (relativePath.startsWith(".learn/assets/")) {
6733
+ const bucketPath = `courses/${courseSlug}/${relativePath}`
6734
+ const previousPath = f.previous_filename
6735
+ const previousBucketPath = previousPath ?
6736
+ `courses/${courseSlug}/${previousPath}` :
6737
+ undefined
6738
+
6739
+ toSync.push({
6740
+ relativePath,
6741
+ status,
6742
+ bucketPath,
6743
+ isAsset: true,
6744
+ previousBucketPath,
6745
+ })
6746
+ }
6747
+ }
6748
+
6749
+ const syncedLessonSlugs = new Set<string>()
6750
+ let syncedLessonFilesCount = 0
6751
+ let syncedAssetsUploaded = 0
6752
+ let removedCount = 0
6753
+
6754
+ for (const item of toSync) {
6755
+ if (item.isAsset && item.status === "removed") {
6756
+ try {
6757
+ const fileRef = bucket.file(item.bucketPath)
6758
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
6759
+ await fileRef.delete()
6760
+ removedCount++
6761
+ } catch {
6762
+ // File may not exist, ignore
6763
+ }
6764
+
6765
+ continue
6766
+ }
6767
+
6768
+ if (item.isAsset && item.previousBucketPath) {
6769
+ try {
6770
+ const oldFileRef = bucket.file(item.previousBucketPath)
6771
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
6772
+ await oldFileRef.delete()
6773
+ removedCount++
6774
+ } catch {
6775
+ // Old file may not exist, ignore
6776
+ }
6777
+ }
6778
+
6779
+ if (item.isAsset || item.status === "modified") {
6780
+ let contentRes: { content?: string; encoding?: string }
6781
+ try {
6782
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
6783
+ contentRes = await githubApiFetch(
6784
+ `https://api.github.com/repos/${repository}/contents/${encodeURIComponent(
6785
+ item.relativePath
6786
+ )}?ref=${targetSHA}`,
6787
+ token
6788
+ )
6789
+ } catch (error: unknown) {
6790
+ const axErr = error as { response?: { status: number } }
6791
+ if (axErr.response?.status === 404) {
6792
+ skippedFiles.push({
6793
+ filename: item.relativePath,
6794
+ status: item.status,
6795
+ reason: "File not found in GitHub",
6796
+ })
6797
+ continue
6798
+ }
6799
+
6800
+ throw error
6801
+ }
6802
+
6803
+ const content = contentRes.content
6804
+ if (!content) {
6805
+ skippedFiles.push({
6806
+ filename: item.relativePath,
6807
+ status: item.status,
6808
+ reason: "No content in response",
6809
+ })
6810
+ continue
6811
+ }
6812
+
6813
+ const buffer = Buffer.from(content, "base64")
6814
+
6815
+ if (isImageFile(item.relativePath)) {
6816
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
6817
+ await uploadBinaryToBucket(
6818
+ bucket,
6819
+ buffer,
6820
+ item.bucketPath,
6821
+ "application/octet-stream"
6822
+ )
6823
+ } else {
6824
+ const text = buffer.toString("utf8")
6825
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
6826
+ await uploadFileToBucket(bucket, text, item.bucketPath)
6827
+ }
6828
+
6829
+ if (item.isAsset) {
6830
+ syncedAssetsUploaded++
6831
+ } else {
6832
+ syncedLessonSlugs.add(
6833
+ item.relativePath.replace(/^exercises\/([^/]+)\/.*/, "$1")
6834
+ )
6835
+ syncedLessonFilesCount++
6836
+ }
6837
+ }
6838
+ }
6839
+
6840
+ configJson.config = configJson.config || {}
6841
+ configJson.config.github = {
6842
+ ...configJson.config.github,
6843
+ lastSyncSHA: targetSHA,
6844
+ }
6845
+ await uploadFileToBucket(
6846
+ bucket,
6847
+ JSON.stringify(configJson),
6848
+ `courses/${courseSlug}/.learn/config.json`
6849
+ )
6850
+
6851
+ return res.json({
6852
+ success: true,
6853
+ syncedLessons: [...syncedLessonSlugs],
6854
+ syncedAssets: syncedAssetsUploaded + removedCount,
6855
+ syncedFiles: syncedLessonFilesCount + syncedAssetsUploaded,
6856
+ removedFiles: removedCount,
6857
+ skippedFiles,
6858
+ newSHA: targetSHA,
6859
+ })
6860
+ })
6861
+ )
6862
+
6863
+ app.post(
6864
+ "/actions/github/push",
6865
+ express.json(),
6866
+ asyncHandler(async (req, res) => {
6867
+ const { courseSlug } = req.body
6868
+
6869
+ if (!courseSlug) {
6870
+ throw new ValidationError("courseSlug is required in request body")
6871
+ }
6872
+
6873
+ const { token, username, isConfigured } = getGithubCredentials()
6874
+ if (!isConfigured || !token || !username) {
6875
+ throw new ValidationError(
6876
+ "GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env"
6877
+ )
6878
+ }
6879
+
6880
+ const configJson = await getConfigJSON(bucket, courseSlug)
6881
+ const repository = configJson.config?.github?.repository
6882
+ const lastSyncSHA = configJson.config?.github?.lastSyncSHA
6883
+ const defaultBranch =
6884
+ configJson.config?.github?.defaultBranch || "main"
6885
+
6886
+ if (!repository || !lastSyncSHA) {
6887
+ throw new ValidationError(
6888
+ "Package has no linked GitHub repository. Create one first."
6889
+ )
6890
+ }
6891
+
6892
+ const refRes = await githubApiFetch(
6893
+ `https://api.github.com/repos/${repository}/git/ref/heads/${defaultBranch}`,
6894
+ token
6895
+ )
6896
+ const currentBranchSHA =
6897
+ refRes.object?.sha ?? refRes.ref?.object?.sha ?? refRes.object?.sha
6898
+ if (!currentBranchSHA) {
6899
+ throw new ValidationError(
6900
+ `Could not get ${defaultBranch} branch SHA from GitHub`
6901
+ )
6902
+ }
6903
+
6904
+ const [allFiles] = await bucket.getFiles({
6905
+ prefix: `courses/${courseSlug}/`,
6906
+ })
6907
+
6908
+ const treeEntries = await Promise.all(
6909
+ allFiles.map(async file => {
6910
+ const [content] = await file.download()
6911
+ const contentBase64 = content.toString("base64")
6912
+
6913
+ const blobRes = await githubApiFetch(
6914
+ `https://api.github.com/repos/${repository}/git/blobs`,
6915
+ token,
6916
+ {
6917
+ method: "POST",
6918
+ body: { content: contentBase64, encoding: "base64" },
6919
+ }
6920
+ )
6921
+
6922
+ const relativePath = file.name.replace(
6923
+ `courses/${courseSlug}/`,
6924
+ ""
6925
+ )
6926
+ return {
6927
+ path: relativePath,
6928
+ mode: "100644",
6929
+ type: "blob",
6930
+ sha: blobRes.sha,
6931
+ }
6932
+ })
6933
+ )
6934
+
6935
+ const treeRes = await githubApiFetch(
6936
+ `https://api.github.com/repos/${repository}/git/trees`,
6937
+ token,
6938
+ {
6939
+ method: "POST",
6940
+ body: { tree: treeEntries },
6941
+ }
6942
+ )
6943
+
6944
+ const commitRes = await githubApiFetch(
6945
+ `https://api.github.com/repos/${repository}/git/commits`,
6946
+ token,
6947
+ {
6948
+ method: "POST",
6949
+ body: {
6950
+ message: "Sync from LearnPack bucket",
6951
+ tree: treeRes.sha,
6952
+ parents: [currentBranchSHA],
6953
+ },
6954
+ }
6955
+ )
6956
+
6957
+ await githubApiFetch(
6958
+ `https://api.github.com/repos/${repository}/git/refs/heads/${defaultBranch}`,
6959
+ token,
6960
+ {
6961
+ method: "PATCH",
6962
+ body: { sha: commitRes.sha, force: false },
6963
+ }
6964
+ )
6965
+
6966
+ configJson.config = configJson.config || {}
6967
+ configJson.config.github = {
6968
+ ...configJson.config.github,
6969
+ lastSyncSHA: commitRes.sha,
6970
+ }
6971
+ await uploadFileToBucket(
6972
+ bucket,
6973
+ JSON.stringify(configJson),
6974
+ `courses/${courseSlug}/.learn/config.json`
6975
+ )
6976
+
6977
+ return res.json({
6978
+ success: true,
6979
+ repository,
6980
+ sha: commitRes.sha,
6981
+ totalFiles: allFiles.length,
6982
+ })
6983
+ })
6984
+ )
6985
+
5808
6986
  app.get("/proxy", async (req, res) => {
5809
6987
  const { url } = req.query
5810
6988