@learnpack/learnpack 5.0.342 → 5.0.344

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.
@@ -174,6 +174,244 @@ const uploadFileToBucket = async (
174
174
  await fileRef.save(Buffer.from(content, "utf8"))
175
175
  }
176
176
 
177
+ const uploadBinaryToBucket = async (
178
+ bucket: Bucket,
179
+ buffer: Buffer,
180
+ path: string,
181
+ contentType?: string
182
+ ) => {
183
+ const fileRef = bucket.file(path)
184
+ await fileRef.save(buffer, {
185
+ resumable: false,
186
+ ...(contentType && { contentType }),
187
+ })
188
+ }
189
+
190
+ const getGithubCredentials = () => {
191
+ const token = process.env.GITHUB_TOKEN?.trim()
192
+ const username = process.env.GITHUB_USERNAME?.trim()
193
+ return { token, username, isConfigured: Boolean(token && username) }
194
+ }
195
+
196
+ type GithubOperation =
197
+ | "create_repo"
198
+ | "get_base_ref"
199
+ | "create_blob"
200
+ | "create_tree"
201
+ | "create_commit"
202
+ | "update_ref";
203
+
204
+ type GithubCallContext = {
205
+ operation: GithubOperation;
206
+ repository?: string;
207
+ failedPath?: string;
208
+ repoCreated?: boolean;
209
+ repoName?: string;
210
+ retryOnUnavailableConflict?: boolean;
211
+ };
212
+
213
+ const GITHUB_RETRY_DELAYS_MS = [250, 600, 1200]
214
+
215
+ const wait = (ms: number) =>
216
+ new Promise<void>(resolve => {
217
+ setTimeout(resolve, ms)
218
+ })
219
+
220
+ const getGithubErrorStatus = (error: any): number | undefined =>
221
+ error?.response?.status
222
+
223
+ const getGithubErrorData = (error: any): any => error?.response?.data || {}
224
+
225
+ const getGithubErrorMessage = (error: any): string | undefined => {
226
+ const data = getGithubErrorData(error)
227
+ if (typeof data?.message === "string") {
228
+ return data.message
229
+ }
230
+
231
+ if (typeof error?.message === "string") {
232
+ return error.message
233
+ }
234
+
235
+ return undefined
236
+ }
237
+
238
+ const errorPayloadHasText = (payload: unknown, term: string): boolean => {
239
+ if (!payload || !term) return false
240
+
241
+ try {
242
+ return JSON.stringify(payload).toLowerCase().includes(term.toLowerCase())
243
+ } catch {
244
+ return false
245
+ }
246
+ }
247
+
248
+ const isGithubPushProtectionConflict = (error: any): boolean => {
249
+ if (getGithubErrorStatus(error) !== 409) return false
250
+
251
+ const message = (getGithubErrorMessage(error) || "").toLowerCase()
252
+ const data = getGithubErrorData(error)
253
+
254
+ return (
255
+ message.includes("push protection") ||
256
+ message.includes("secret scanning") ||
257
+ message.includes("secret") ||
258
+ errorPayloadHasText(data?.errors, "push protection") ||
259
+ errorPayloadHasText(data?.errors, "secret")
260
+ )
261
+ }
262
+
263
+ const isGithubRepoUnavailableConflict = (error: any): boolean => {
264
+ if (getGithubErrorStatus(error) !== 409) return false
265
+
266
+ const message = (getGithubErrorMessage(error) || "").toLowerCase()
267
+ return (
268
+ message.includes("empty or unavailable") ||
269
+ message.includes("repository is empty") ||
270
+ message.includes("repository is unavailable") ||
271
+ message.includes("currently being created")
272
+ )
273
+ }
274
+
275
+ const shouldRetryGithubConflict = (error: any): boolean =>
276
+ isGithubRepoUnavailableConflict(error) &&
277
+ !isGithubPushProtectionConflict(error)
278
+
279
+ const toGithubOperationalError = (
280
+ error: any,
281
+ context: GithubCallContext
282
+ ): Error => {
283
+ if (error?.isOperational === true) {
284
+ return error
285
+ }
286
+
287
+ const status = getGithubErrorStatus(error)
288
+ const data = getGithubErrorData(error)
289
+ const githubMessage = getGithubErrorMessage(error)
290
+ const details = {
291
+ failedOperation: context.operation,
292
+ ...(context.failedPath ? { failedPath: context.failedPath } : {}),
293
+ ...(context.repository ? { repository: context.repository } : {}),
294
+ ...(context.repoCreated ? { repoCreated: true } : {}),
295
+ github: {
296
+ status,
297
+ message: githubMessage,
298
+ documentation_url: data?.documentation_url,
299
+ errors: data?.errors,
300
+ },
301
+ }
302
+
303
+ let message =
304
+ githubMessage ||
305
+ `GitHub request failed during operation "${context.operation}"`
306
+
307
+ if (status === 422 && context.operation === "create_repo") {
308
+ message = context.repoName ?
309
+ `Repository "${context.repoName}" may already exist or name is invalid` :
310
+ "Repository may already exist or the requested name is invalid"
311
+ } else if (status === 409 && isGithubPushProtectionConflict(error)) {
312
+ message = context.failedPath ?
313
+ `File blocked by push protection: ${context.failedPath}` :
314
+ "GitHub push protection blocked sensitive content during the initial commit"
315
+ } else if (context.repoCreated && context.operation !== "create_repo") {
316
+ message =
317
+ "Repo created on GitHub, but the initial commit failed. Check the details and try again."
318
+ }
319
+
320
+ if (status === 400) return new ValidationError(message, details)
321
+ if (status === 404) return new NotFoundError(message, details)
322
+ if (status === 409 || status === 422)
323
+ return new ConflictError(message, details)
324
+
325
+ return new InternalServerError(message, details)
326
+ }
327
+
328
+ const githubApiFetch = async (
329
+ url: string,
330
+ token: string,
331
+ options: { method?: string; body?: unknown } = {}
332
+ ) => {
333
+ const res = await axios({
334
+ url,
335
+ method: options.method || "GET",
336
+ headers: {
337
+ Authorization: `token ${token}`,
338
+ Accept: "application/vnd.github.v3+json",
339
+ },
340
+ data: options.body,
341
+ })
342
+ return res.data
343
+ }
344
+
345
+ const githubApiFetchWithContext = async (
346
+ url: string,
347
+ token: string,
348
+ context: GithubCallContext,
349
+ options: { method?: string; body?: unknown } = {}
350
+ ) => {
351
+ try {
352
+ return await githubApiFetch(url, token, options)
353
+ } catch (error) {
354
+ throw toGithubOperationalError(error, context)
355
+ }
356
+ }
357
+
358
+ const githubApiFetchWithRetry = async (
359
+ url: string,
360
+ token: string,
361
+ context: GithubCallContext,
362
+ options: { method?: string; body?: unknown } = {}
363
+ ) => {
364
+ const attempts = context.retryOnUnavailableConflict ?
365
+ GITHUB_RETRY_DELAYS_MS.length + 1 :
366
+ 1
367
+
368
+ for (let attempt = 0; attempt < attempts; attempt++) {
369
+ try {
370
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
371
+ return await githubApiFetch(url, token, options)
372
+ } catch (error) {
373
+ const canRetry =
374
+ attempt < attempts - 1 && shouldRetryGithubConflict(error)
375
+ if (!canRetry) {
376
+ throw toGithubOperationalError(error, context)
377
+ }
378
+
379
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
380
+ await wait(GITHUB_RETRY_DELAYS_MS[attempt])
381
+ }
382
+ }
383
+
384
+ throw new InternalServerError("GitHub request failed after retries", {
385
+ failedOperation: context.operation,
386
+ repository: context.repository,
387
+ })
388
+ }
389
+
390
+ const mapWithConcurrency = async <T, R>(
391
+ items: T[],
392
+ limit: number,
393
+ mapper: (item: T, index: number) => Promise<R>
394
+ ): Promise<R[]> => {
395
+ if (items.length === 0) {
396
+ return []
397
+ }
398
+
399
+ const concurrency = Math.max(1, Math.min(limit, items.length))
400
+ const results = Array.from({ length: items.length }) as R[]
401
+ let cursor = 0
402
+
403
+ const worker = async () => {
404
+ let index
405
+ while ((index = cursor++) < items.length) {
406
+ // eslint-disable-next-line no-await-in-loop -- intentional: each worker processes items sequentially
407
+ results[index] = await mapper(items[index], index)
408
+ }
409
+ }
410
+
411
+ await Promise.all(Array.from({ length: concurrency }, () => worker()))
412
+ return results
413
+ }
414
+
177
415
  const PARAMS = {
178
416
  expected_grade_level: "8",
179
417
  max_fkgl: 10,
@@ -592,6 +830,23 @@ const getConfigJSON = async (bucket: Bucket, courseSlug: string) => {
592
830
  return JSON.parse(content.toString())
593
831
  }
594
832
 
833
+ async function mergeConfigPreservingGithub(
834
+ bucket: Bucket,
835
+ courseSlug: string,
836
+ newConfig: Record<string, unknown>
837
+ ): Promise<Record<string, unknown>> {
838
+ try {
839
+ const existing = await getConfigJSON(bucket, courseSlug)
840
+ if (existing?.config?.github) {
841
+ return { ...newConfig, github: { ...existing.config.github } }
842
+ }
843
+ } catch {
844
+ // No existing config or parse error - use newConfig as-is
845
+ }
846
+
847
+ return newConfig
848
+ }
849
+
595
850
  async function getSyllabus(
596
851
  courseSlug: string,
597
852
  bucket: Bucket
@@ -1647,9 +1902,12 @@ class ServeCommand extends SessionCommand {
1647
1902
  try {
1648
1903
  console.log(`📄 Processing file: ${fileObj.name}`)
1649
1904
 
1905
+ // Flatten path: use only the file name so files are stored directly in the exercise folder
1906
+ const flatFileName = path.basename(fileObj.name)
1907
+
1650
1908
  // Save the main file with content
1651
1909
  if (fileObj.name && fileObj.content) {
1652
- const filePath = `${exerciseDir}/${fileObj.name}`
1910
+ const filePath = `${exerciseDir}/${flatFileName}`
1653
1911
  // eslint-disable-next-line no-await-in-loop
1654
1912
  await uploadFileToBucket(bucket, fileObj.content, filePath)
1655
1913
  console.log(`✅ Saved file: ${filePath}`)
@@ -1657,7 +1915,7 @@ class ServeCommand extends SessionCommand {
1657
1915
 
1658
1916
  // Save the solution file if it exists
1659
1917
  if (fileObj.name && fileObj.solution) {
1660
- const nameParts = fileObj.name.split(".")
1918
+ const nameParts = flatFileName.split(".")
1661
1919
  if (nameParts.length > 1) {
1662
1920
  const extension = nameParts.pop()
1663
1921
  const baseName = nameParts.join(".")
@@ -1674,7 +1932,7 @@ class ServeCommand extends SessionCommand {
1674
1932
  )
1675
1933
  } else {
1676
1934
  // If no extension, just add .solution.hide
1677
- const solutionFileName = `${fileObj.name}.solution.hide`
1935
+ const solutionFileName = `${flatFileName}.solution.hide`
1678
1936
  const solutionFilePath = `${exerciseDir}/${solutionFileName}`
1679
1937
  // eslint-disable-next-line no-await-in-loop
1680
1938
  await uploadFileToBucket(
@@ -2823,7 +3081,16 @@ class ServeCommand extends SessionCommand {
2823
3081
  }
2824
3082
 
2825
3083
  try {
2826
- const { config, exercises } = await buildConfig(bucket, courseSlug)
3084
+ const { config: builtConfig, exercises } = await buildConfig(
3085
+ bucket,
3086
+ courseSlug
3087
+ )
3088
+ const config = await mergeConfigPreservingGithub(
3089
+ bucket,
3090
+ courseSlug,
3091
+ builtConfig
3092
+ )
3093
+
2827
3094
  res.set("X-Creator-Web", "true")
2828
3095
  res.set("Access-Control-Expose-Headers", "X-Creator-Web")
2829
3096
 
@@ -2975,21 +3242,24 @@ class ServeCommand extends SessionCommand {
2975
3242
  throw new ValidationError("File name and exercise slug are required")
2976
3243
  }
2977
3244
 
2978
- // Extract content from body (can be text or JSON)
2979
- let fileContent: string
2980
- let contentToSaveInHistory: string | undefined
2981
-
2982
- if (typeof req.body === "string") {
2983
- // Old format: plain text
2984
- fileContent = req.body
2985
- } else if (req.body && typeof req.body === "object") {
2986
- // New format: JSON with content and historyContent
2987
- fileContent = req.body.content
2988
- contentToSaveInHistory = req.body.historyContent
2989
- } else {
2990
- throw new ValidationError("Invalid request body format")
3245
+ // Require JSON body with content field; return 400 with clear message otherwise
3246
+ if (
3247
+ !req.body ||
3248
+ typeof req.body !== "object" ||
3249
+ req.body.content === undefined
3250
+ ) {
3251
+ throw new ValidationError(
3252
+ 'Request body must be JSON with a \'content\' field. Use Content-Type: application/json and body: { "content": "..." }.'
3253
+ )
2991
3254
  }
2992
3255
 
3256
+ const fileContent = String(req.body.content)
3257
+ const contentToSaveInHistory =
3258
+ req.body.historyContent !== null &&
3259
+ req.body.historyContent !== undefined ?
3260
+ String(req.body.historyContent) :
3261
+ undefined
3262
+
2993
3263
  try {
2994
3264
  let newVersion = versionId
2995
3265
  let created = false
@@ -4434,6 +4704,8 @@ class ServeCommand extends SessionCommand {
4434
4704
  title: string;
4435
4705
  slug: string;
4436
4706
  }> = []
4707
+ let repairedTranslationsInLessons = 0
4708
+ let repairedTranslationEntries = 0
4437
4709
 
4438
4710
  console.log(
4439
4711
  `📋 Checking ${syllabus.lessons.length} lessons in syllabus...`
@@ -4615,10 +4887,82 @@ class ServeCommand extends SessionCommand {
4615
4887
  })
4616
4888
  }
4617
4889
 
4618
- if (totalRemoved > 0 || addedLessons.length > 0) {
4890
+ // Fourth pass: reconcile lesson.translations from actual README files in bucket.
4891
+ // This fixes lessons that exist but lost translations metadata after renames or syncs.
4892
+ try {
4893
+ const { exercises } = await buildConfig(bucket, courseSlug)
4894
+ const translationsBySlug = new Map<string, string[]>()
4895
+
4896
+ for (const exercise of exercises) {
4897
+ const langs = Object.keys(exercise.translations || {})
4898
+ .map(lang => lang.toLowerCase())
4899
+ .filter(Boolean)
4900
+ if (langs.length > 0) {
4901
+ translationsBySlug.set(exercise.slug, [...new Set(langs)])
4902
+ }
4903
+ }
4904
+
4905
+ for (const lesson of syllabus.lessons) {
4906
+ const candidateSlugs = [
4907
+ slugify(lesson.id + "-" + lesson.title),
4908
+ lesson.uid,
4909
+ ].filter(Boolean)
4910
+ const matchedSlug = candidateSlugs.find(s =>
4911
+ translationsBySlug.has(s)
4912
+ )
4913
+ if (!matchedSlug) continue
4914
+
4915
+ const languageCodes = translationsBySlug.get(matchedSlug) || []
4916
+ if (languageCodes.length === 0) continue
4917
+
4918
+ const currentTranslations = lesson.translations || {}
4919
+ let lessonChanged = false
4920
+
4921
+ for (const lang of languageCodes) {
4922
+ const now = Date.now()
4923
+ if (!currentTranslations[lang]) {
4924
+ currentTranslations[lang] = {
4925
+ completionId: 0,
4926
+ startedAt: now,
4927
+ completedAt: now,
4928
+ }
4929
+ repairedTranslationEntries += 1
4930
+ lessonChanged = true
4931
+ } else {
4932
+ // If README exists for this language but metadata is incomplete, mark it as completed.
4933
+ if (!currentTranslations[lang].startedAt) {
4934
+ currentTranslations[lang].startedAt = now
4935
+ lessonChanged = true
4936
+ }
4937
+
4938
+ if (!currentTranslations[lang].completedAt) {
4939
+ currentTranslations[lang].completedAt = now
4940
+ repairedTranslationEntries += 1
4941
+ lessonChanged = true
4942
+ }
4943
+ }
4944
+ }
4945
+
4946
+ if (lessonChanged) {
4947
+ lesson.translations = currentTranslations
4948
+ repairedTranslationsInLessons += 1
4949
+ }
4950
+ }
4951
+ } catch (error) {
4952
+ console.error(
4953
+ "⚠️ Could not reconcile lesson translations during syllabus sync:",
4954
+ error
4955
+ )
4956
+ }
4957
+
4958
+ if (
4959
+ totalRemoved > 0 ||
4960
+ addedLessons.length > 0 ||
4961
+ repairedTranslationsInLessons > 0
4962
+ ) {
4619
4963
  await saveSyllabus(courseSlug, syllabus, bucket)
4620
4964
  console.log(
4621
- `✅ Syllabus synchronized. Removed ${removedLessons.length} non-existent, ${duplicatesRemoved.length} duplicate(s); added ${addedLessons.length} from bucket.`
4965
+ `✅ Syllabus synchronized. Removed ${removedLessons.length} non-existent, ${duplicatesRemoved.length} duplicate(s); added ${addedLessons.length} from bucket; repaired translations in ${repairedTranslationsInLessons} lesson(s).`
4622
4966
  )
4623
4967
  } else {
4624
4968
  console.log(`✅ Syllabus is already in sync. No changes.`)
@@ -4632,6 +4976,8 @@ class ServeCommand extends SessionCommand {
4632
4976
  removedLessons: removedLessons.length,
4633
4977
  duplicatesResolved: duplicatesRemoved.length,
4634
4978
  addedLessons: addedLessons.length,
4979
+ repairedTranslationsInLessons,
4980
+ repairedTranslationEntries,
4635
4981
  removed: removedLessons,
4636
4982
  duplicates: duplicatesRemoved,
4637
4983
  kept: keptLessons,
@@ -4646,6 +4992,142 @@ class ServeCommand extends SessionCommand {
4646
4992
  }
4647
4993
  })
4648
4994
 
4995
+ app.post("/actions/synchronize-lesson-files", async (req, res) => {
4996
+ const courseSlug = req.query.slug as string
4997
+ const { lessonSlug } = req.body || {}
4998
+
4999
+ if (!courseSlug || !lessonSlug) {
5000
+ return res.status(400).json({
5001
+ error: "Course slug (query) and lessonSlug (body) are required",
5002
+ })
5003
+ }
5004
+
5005
+ try {
5006
+ const configJson = await getConfigJSON(bucket, courseSlug)
5007
+ const exercises = configJson.exercises as Array<{
5008
+ slug: string;
5009
+ files: Array<{ name: string; slug?: string; hidden?: boolean }>;
5010
+ }>
5011
+ const exercise = exercises.find(ex => ex.slug === lessonSlug)
5012
+
5013
+ if (!exercise) {
5014
+ return res.status(404).json({
5015
+ error: `Lesson not found: ${lessonSlug}`,
5016
+ })
5017
+ }
5018
+
5019
+ const initialCount = exercise.files?.length ?? 0
5020
+ if (initialCount === 0) {
5021
+ return res.json({
5022
+ status: "SUCCESS",
5023
+ removedCount: 0,
5024
+ keptCount: 0,
5025
+ movedCount: 0,
5026
+ })
5027
+ }
5028
+
5029
+ const lessonPrefix = `courses/${courseSlug}/exercises/${lessonSlug}/`
5030
+ const [bucketObjects] = await bucket.getFiles({ prefix: lessonPrefix })
5031
+
5032
+ const existenceChecks = await Promise.all(
5033
+ exercise.files.map(async file => {
5034
+ const flatPath = `${lessonPrefix}${file.name}`
5035
+ const [existsAtFlat] = await bucket.file(flatPath).exists()
5036
+ return { file, flatPath, existsAtFlat }
5037
+ })
5038
+ )
5039
+
5040
+ const kept: typeof exercise.files = []
5041
+ const notFoundInBucket: string[] = []
5042
+ const toMove: Array<{
5043
+ file: typeof exercise.files[0];
5044
+ subdirPath: string;
5045
+ flatPath: string;
5046
+ }> = []
5047
+
5048
+ for (const { file, flatPath, existsAtFlat } of existenceChecks) {
5049
+ if (existsAtFlat) {
5050
+ kept.push(file)
5051
+ continue
5052
+ }
5053
+
5054
+ const subdirObject = bucketObjects.find(obj => {
5055
+ if (!obj.name.endsWith(`/${file.name}`)) return false
5056
+ const afterLesson = obj.name.slice(lessonPrefix.length)
5057
+ const segments = afterLesson.split("/")
5058
+ return (
5059
+ segments.length === 2 &&
5060
+ segments[0] !== "" &&
5061
+ segments[0] !== ".learn"
5062
+ )
5063
+ })
5064
+ if (subdirObject) {
5065
+ toMove.push({ file, subdirPath: subdirObject.name, flatPath })
5066
+ } else {
5067
+ notFoundInBucket.push(file.name)
5068
+ }
5069
+ }
5070
+
5071
+ let movedCount = 0
5072
+ if (toMove.length > 0) {
5073
+ await Promise.all(
5074
+ toMove.map(async ({ file, subdirPath, flatPath }) => {
5075
+ const srcFile = bucket.file(subdirPath)
5076
+ const destFile = bucket.file(flatPath)
5077
+ await srcFile.copy(destFile)
5078
+ await srcFile.delete()
5079
+ })
5080
+ )
5081
+ for (const { file } of toMove) {
5082
+ kept.push(file)
5083
+ }
5084
+
5085
+ movedCount = toMove.length
5086
+
5087
+ const subdirName = toMove[0].subdirPath
5088
+ .slice(lessonPrefix.length)
5089
+ .split("/")[0]
5090
+ const subdirPrefix = `${lessonPrefix}${subdirName}/`
5091
+ const [remainingInSubdir] = await bucket.getFiles({
5092
+ prefix: subdirPrefix,
5093
+ })
5094
+ if (remainingInSubdir.length > 0) {
5095
+ await Promise.all(remainingInSubdir.map(f => f.delete()))
5096
+ }
5097
+ }
5098
+
5099
+ const removedCount = initialCount - kept.length
5100
+ exercise.files = kept
5101
+
5102
+ if (removedCount > 0) {
5103
+ await uploadFileToBucket(
5104
+ bucket,
5105
+ JSON.stringify({ config: configJson.config, exercises }),
5106
+ `courses/${courseSlug}/.learn/config.json`
5107
+ )
5108
+ }
5109
+
5110
+ if (movedCount > 0 || removedCount > 0) {
5111
+ console.log(
5112
+ `[sync-lesson-files] ${lessonSlug}: ${movedCount} moved from subdir, ${removedCount} removed from config`
5113
+ )
5114
+ }
5115
+
5116
+ res.json({
5117
+ status: "SUCCESS",
5118
+ removedCount,
5119
+ keptCount: kept.length,
5120
+ movedCount,
5121
+ })
5122
+ } catch (error) {
5123
+ console.error("❌ Error synchronizing lesson files:", error)
5124
+ res.status(500).json({
5125
+ error: "Error synchronizing lesson files",
5126
+ details: (error as Error).message,
5127
+ })
5128
+ }
5129
+ })
5130
+
4649
5131
  app.get("/translations/sidebar", async (req, res) => {
4650
5132
  const { slug } = req.query
4651
5133
 
@@ -5662,6 +6144,817 @@ class ServeCommand extends SessionCommand {
5662
6144
  }
5663
6145
  })
5664
6146
 
6147
+ app.get("/actions/github/status", async (req, res) => {
6148
+ const courseSlug = req.query.slug as string
6149
+ if (!courseSlug) {
6150
+ return res
6151
+ .status(400)
6152
+ .json({ error: "slug query parameter is required" })
6153
+ }
6154
+
6155
+ const { isConfigured } = getGithubCredentials()
6156
+
6157
+ try {
6158
+ const configJson = await getConfigJSON(bucket, courseSlug)
6159
+ const github = configJson.config?.github
6160
+ const repository = github?.repository
6161
+ const linked = Boolean(repository)
6162
+
6163
+ return res.json({
6164
+ configured: isConfigured,
6165
+ linked,
6166
+ repository: linked ? repository : null,
6167
+ })
6168
+ } catch {
6169
+ return res.json({
6170
+ configured: isConfigured,
6171
+ linked: false,
6172
+ repository: null,
6173
+ })
6174
+ }
6175
+ })
6176
+
6177
+ app.post(
6178
+ "/actions/github/create-repo",
6179
+ express.json(),
6180
+ asyncHandler(async (req, res) => {
6181
+ const { courseSlug, repoName, isPrivate, description } = req.body
6182
+
6183
+ if (!courseSlug) {
6184
+ throw new ValidationError("courseSlug is required in request body")
6185
+ }
6186
+
6187
+ const { token, username, isConfigured } = getGithubCredentials()
6188
+ if (!isConfigured || !token || !username) {
6189
+ throw new ValidationError(
6190
+ "GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env"
6191
+ )
6192
+ }
6193
+
6194
+ const configJson = await getConfigJSON(bucket, courseSlug)
6195
+ if (configJson.config?.github?.repository) {
6196
+ throw new ConflictError(
6197
+ "Package already has a linked GitHub repository"
6198
+ )
6199
+ }
6200
+
6201
+ const finalRepoName = repoName || courseSlug
6202
+ const finalDescription =
6203
+ description || "Tutorial created with LearnPack"
6204
+
6205
+ const createRepoUrl = "https://api.github.com/user/repos"
6206
+ const createdRepo = await githubApiFetchWithContext(
6207
+ createRepoUrl,
6208
+ token,
6209
+ {
6210
+ operation: "create_repo",
6211
+ repoName: finalRepoName,
6212
+ },
6213
+ {
6214
+ method: "POST",
6215
+ body: {
6216
+ name: finalRepoName,
6217
+ private: Boolean(isPrivate),
6218
+ description: finalDescription,
6219
+ auto_init: true,
6220
+ },
6221
+ }
6222
+ )
6223
+
6224
+ const repository =
6225
+ createdRepo?.full_name || `${username}/${finalRepoName}`
6226
+ const defaultBranch = createdRepo?.default_branch || "main"
6227
+ const repositoryUrl =
6228
+ createdRepo?.html_url || `https://github.com/${repository}`
6229
+
6230
+ const refRes = await githubApiFetchWithRetry(
6231
+ `https://api.github.com/repos/${repository}/git/ref/heads/${defaultBranch}`,
6232
+ token,
6233
+ {
6234
+ operation: "get_base_ref",
6235
+ repository,
6236
+ repoCreated: true,
6237
+ retryOnUnavailableConflict: true,
6238
+ },
6239
+ {}
6240
+ )
6241
+ const baseSHA =
6242
+ refRes.object?.sha ?? refRes.ref?.object?.sha ?? refRes.object?.sha
6243
+ if (!baseSHA) {
6244
+ throw new InternalServerError(
6245
+ "Could not get default branch SHA from GitHub after repository creation",
6246
+ {
6247
+ failedOperation: "get_base_ref",
6248
+ repository,
6249
+ repoCreated: true,
6250
+ }
6251
+ )
6252
+ }
6253
+
6254
+ const [allFiles] = await bucket.getFiles({
6255
+ prefix: `courses/${courseSlug}/`,
6256
+ })
6257
+
6258
+ const treeEntries = await mapWithConcurrency(
6259
+ allFiles,
6260
+ 5,
6261
+ async file => {
6262
+ const relativePath = file.name.replace(
6263
+ `courses/${courseSlug}/`,
6264
+ ""
6265
+ )
6266
+ const [content] = await file.download()
6267
+ const contentBase64 = content.toString("base64")
6268
+
6269
+ const blobRes = await githubApiFetchWithRetry(
6270
+ `https://api.github.com/repos/${repository}/git/blobs`,
6271
+ token,
6272
+ {
6273
+ operation: "create_blob",
6274
+ repository,
6275
+ failedPath: relativePath,
6276
+ repoCreated: true,
6277
+ retryOnUnavailableConflict: true,
6278
+ },
6279
+ {
6280
+ method: "POST",
6281
+ body: { content: contentBase64, encoding: "base64" },
6282
+ }
6283
+ )
6284
+
6285
+ return {
6286
+ path: relativePath,
6287
+ mode: "100644",
6288
+ type: "blob",
6289
+ sha: blobRes.sha,
6290
+ }
6291
+ }
6292
+ )
6293
+
6294
+ const treeRes = await githubApiFetchWithRetry(
6295
+ `https://api.github.com/repos/${repository}/git/trees`,
6296
+ token,
6297
+ {
6298
+ operation: "create_tree",
6299
+ repository,
6300
+ repoCreated: true,
6301
+ retryOnUnavailableConflict: true,
6302
+ },
6303
+ {
6304
+ method: "POST",
6305
+ body: { tree: treeEntries },
6306
+ }
6307
+ )
6308
+
6309
+ const commitRes = await githubApiFetchWithRetry(
6310
+ `https://api.github.com/repos/${repository}/git/commits`,
6311
+ token,
6312
+ {
6313
+ operation: "create_commit",
6314
+ repository,
6315
+ repoCreated: true,
6316
+ retryOnUnavailableConflict: true,
6317
+ },
6318
+ {
6319
+ method: "POST",
6320
+ body: {
6321
+ message: "Initial commit from LearnPack",
6322
+ tree: treeRes.sha,
6323
+ parents: [baseSHA],
6324
+ },
6325
+ }
6326
+ )
6327
+
6328
+ await githubApiFetchWithRetry(
6329
+ `https://api.github.com/repos/${repository}/git/refs/heads/${defaultBranch}`,
6330
+ token,
6331
+ {
6332
+ operation: "update_ref",
6333
+ repository,
6334
+ repoCreated: true,
6335
+ retryOnUnavailableConflict: true,
6336
+ },
6337
+ {
6338
+ method: "PATCH",
6339
+ body: {
6340
+ sha: commitRes.sha,
6341
+ force: false,
6342
+ },
6343
+ }
6344
+ )
6345
+
6346
+ configJson.config = configJson.config || {}
6347
+ configJson.config.github = {
6348
+ repository,
6349
+ defaultBranch,
6350
+ lastSyncSHA: commitRes.sha,
6351
+ }
6352
+ await uploadFileToBucket(
6353
+ bucket,
6354
+ JSON.stringify(configJson),
6355
+ `courses/${courseSlug}/.learn/config.json`
6356
+ )
6357
+
6358
+ return res.json({
6359
+ success: true,
6360
+ repository,
6361
+ url: repositoryUrl,
6362
+ sha: commitRes.sha,
6363
+ })
6364
+ })
6365
+ )
6366
+
6367
+ const GITHUB_STRUCTURAL_FILES = new Set([
6368
+ "learn.json",
6369
+ ".learn/config.json",
6370
+ ".learn/initialSyllabus.json",
6371
+ ".learn/sidebar.json",
6372
+ ".learn/memory_bank.txt",
6373
+ ])
6374
+
6375
+ const isStructuralFile = (relativePath: string) =>
6376
+ GITHUB_STRUCTURAL_FILES.has(relativePath) ||
6377
+ relativePath === "learn.json" ||
6378
+ (relativePath.startsWith(".learn/") &&
6379
+ !relativePath.startsWith(".learn/assets/"))
6380
+
6381
+ const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]
6382
+
6383
+ const isImageFile = (path: string) =>
6384
+ IMAGE_EXTENSIONS.some(ext => path.toLowerCase().endsWith(ext))
6385
+
6386
+ app.get(
6387
+ "/actions/github/check-changes",
6388
+ asyncHandler(async (req, res) => {
6389
+ const courseSlug = req.query.slug as string
6390
+ if (!courseSlug) {
6391
+ throw new ValidationError("slug query parameter is required")
6392
+ }
6393
+
6394
+ const { token, isConfigured } = getGithubCredentials()
6395
+ if (!isConfigured || !token) {
6396
+ throw new ValidationError(
6397
+ "GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env"
6398
+ )
6399
+ }
6400
+
6401
+ const configJson = await getConfigJSON(bucket, courseSlug)
6402
+ const repository = configJson.config?.github?.repository
6403
+ const lastSyncSHA = configJson.config?.github?.lastSyncSHA
6404
+ const defaultBranch =
6405
+ configJson.config?.github?.defaultBranch || "main"
6406
+
6407
+ if (!repository || !lastSyncSHA) {
6408
+ throw new ValidationError(
6409
+ "Package has no linked GitHub repository. Create one first."
6410
+ )
6411
+ }
6412
+
6413
+ const refRes = await githubApiFetch(
6414
+ `https://api.github.com/repos/${repository}/git/ref/heads/${defaultBranch}`,
6415
+ token
6416
+ )
6417
+ const currentBranchSHA =
6418
+ refRes.object?.sha ?? refRes.ref?.object?.sha ?? refRes.object?.sha
6419
+ if (!currentBranchSHA) {
6420
+ throw new ValidationError(
6421
+ `Could not get ${defaultBranch} branch SHA from GitHub`
6422
+ )
6423
+ }
6424
+
6425
+ const compareRes = await githubApiFetch(
6426
+ `https://api.github.com/repos/${repository}/compare/${lastSyncSHA}...${currentBranchSHA}`,
6427
+ token
6428
+ )
6429
+
6430
+ if (
6431
+ compareRes.status === "identical" ||
6432
+ !compareRes.files ||
6433
+ compareRes.files.length === 0
6434
+ ) {
6435
+ return res.json({ hasChanges: false })
6436
+ }
6437
+
6438
+ const [allExerciseFiles] = await bucket.getFiles({
6439
+ prefix: `courses/${courseSlug}/exercises/`,
6440
+ })
6441
+
6442
+ const lessonSlugs = new Set<string>()
6443
+ const lessonFilesMap = new Map<string, Set<string>>()
6444
+
6445
+ for (const file of allExerciseFiles) {
6446
+ const match = file.name.match(
6447
+ new RegExp(`courses/${courseSlug}/exercises/([^/]+)/(.+)$`)
6448
+ )
6449
+ if (match) {
6450
+ const [, slug, filePart] = match
6451
+ lessonSlugs.add(slug)
6452
+ if (!lessonFilesMap.has(slug)) {
6453
+ lessonFilesMap.set(slug, new Set())
6454
+ }
6455
+
6456
+ lessonFilesMap.get(slug)!.add(filePart)
6457
+ }
6458
+ }
6459
+
6460
+ const syncableLessons: Array<{
6461
+ slug: string;
6462
+ files: Array<{ filename: string; status: string }>;
6463
+ }> = []
6464
+ const syncableAssets: Array<{ filename: string; status: string }> = []
6465
+ const skippedFiles: Array<{
6466
+ filename: string;
6467
+ status: string;
6468
+ reason: string;
6469
+ }> = []
6470
+
6471
+ for (const f of compareRes.files) {
6472
+ const relativePath = f.filename
6473
+ const status = f.status || "modified"
6474
+
6475
+ if (isStructuralFile(relativePath)) {
6476
+ skippedFiles.push({
6477
+ filename: relativePath,
6478
+ status,
6479
+ reason: "Structural file",
6480
+ })
6481
+ continue
6482
+ }
6483
+
6484
+ const exercisesMatch = relativePath.match(
6485
+ /^exercises\/([^/]+)\/(.+)$/
6486
+ )
6487
+ if (exercisesMatch) {
6488
+ const [, slug, filename] = exercisesMatch
6489
+ if (!lessonSlugs.has(slug)) {
6490
+ skippedFiles.push({
6491
+ filename: relativePath,
6492
+ status,
6493
+ reason: "Lesson does not exist in bucket",
6494
+ })
6495
+ continue
6496
+ }
6497
+
6498
+ const existingFiles = lessonFilesMap.get(slug)!
6499
+ if (status !== "modified" || !existingFiles.has(filename)) {
6500
+ skippedFiles.push({
6501
+ filename: relativePath,
6502
+ status,
6503
+ reason:
6504
+ status === "modified" ?
6505
+ "File does not exist in bucket (only modified existing files are synced)" :
6506
+ "Structural change in lesson (only modified files are synced)",
6507
+ })
6508
+ continue
6509
+ }
6510
+
6511
+ let lessonEntry = syncableLessons.find(l => l.slug === slug)
6512
+ if (!lessonEntry) {
6513
+ lessonEntry = { slug, files: [] }
6514
+ syncableLessons.push(lessonEntry)
6515
+ }
6516
+
6517
+ lessonEntry.files.push({ filename, status })
6518
+ continue
6519
+ }
6520
+
6521
+ if (relativePath.startsWith(".learn/assets/")) {
6522
+ syncableAssets.push({ filename: relativePath, status })
6523
+ continue
6524
+ }
6525
+
6526
+ skippedFiles.push({
6527
+ filename: relativePath,
6528
+ status,
6529
+ reason: "Unknown path",
6530
+ })
6531
+ }
6532
+
6533
+ return res.json({
6534
+ hasChanges: true,
6535
+ currentSHA: currentBranchSHA,
6536
+ lastSyncSHA,
6537
+ syncableChanges: {
6538
+ lessons: syncableLessons,
6539
+ assets: syncableAssets,
6540
+ totalFiles:
6541
+ syncableLessons.reduce((s, l) => s + l.files.length, 0) +
6542
+ syncableAssets.length,
6543
+ },
6544
+ skippedChanges: {
6545
+ files: skippedFiles,
6546
+ totalFiles: skippedFiles.length,
6547
+ },
6548
+ })
6549
+ })
6550
+ )
6551
+
6552
+ app.post(
6553
+ "/actions/github/pull",
6554
+ express.json(),
6555
+ asyncHandler(async (req, res) => {
6556
+ const { courseSlug, targetSHA, lessons } = req.body
6557
+
6558
+ if (!courseSlug || !targetSHA) {
6559
+ throw new ValidationError(
6560
+ "courseSlug and targetSHA are required in request body"
6561
+ )
6562
+ }
6563
+
6564
+ const { token, isConfigured } = getGithubCredentials()
6565
+ if (!isConfigured || !token) {
6566
+ throw new ValidationError(
6567
+ "GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env"
6568
+ )
6569
+ }
6570
+
6571
+ const configJson = await getConfigJSON(bucket, courseSlug)
6572
+ const repository = configJson.config?.github?.repository
6573
+ const lastSyncSHA = configJson.config?.github?.lastSyncSHA
6574
+
6575
+ if (!repository || !lastSyncSHA) {
6576
+ throw new ValidationError(
6577
+ "Package has no linked GitHub repository. Create one first."
6578
+ )
6579
+ }
6580
+
6581
+ const [allExerciseFiles] = await bucket.getFiles({
6582
+ prefix: `courses/${courseSlug}/exercises/`,
6583
+ })
6584
+
6585
+ const lessonSlugs = new Set<string>()
6586
+ const lessonFilesMap = new Map<string, Set<string>>()
6587
+
6588
+ for (const file of allExerciseFiles) {
6589
+ const match = file.name.match(
6590
+ new RegExp(`courses/${courseSlug}/exercises/([^/]+)/(.+)$`)
6591
+ )
6592
+ if (match) {
6593
+ const [, slug, filePart] = match
6594
+ lessonSlugs.add(slug)
6595
+ if (!lessonFilesMap.has(slug)) {
6596
+ lessonFilesMap.set(slug, new Set())
6597
+ }
6598
+
6599
+ lessonFilesMap.get(slug)!.add(filePart)
6600
+ }
6601
+ }
6602
+
6603
+ const compareRes = await githubApiFetch(
6604
+ `https://api.github.com/repos/${repository}/compare/${lastSyncSHA}...${targetSHA}`,
6605
+ token
6606
+ )
6607
+
6608
+ if (
6609
+ compareRes.status === "identical" ||
6610
+ !compareRes.files ||
6611
+ compareRes.files.length === 0
6612
+ ) {
6613
+ configJson.config = configJson.config || {}
6614
+ configJson.config.github = {
6615
+ ...configJson.config.github,
6616
+ lastSyncSHA: targetSHA,
6617
+ }
6618
+ await uploadFileToBucket(
6619
+ bucket,
6620
+ JSON.stringify(configJson),
6621
+ `courses/${courseSlug}/.learn/config.json`
6622
+ )
6623
+ return res.json({
6624
+ success: true,
6625
+ syncedLessons: [],
6626
+ syncedAssets: 0,
6627
+ syncedFiles: 0,
6628
+ removedFiles: 0,
6629
+ skippedFiles: [],
6630
+ newSHA: targetSHA,
6631
+ })
6632
+ }
6633
+
6634
+ const lessonFilter =
6635
+ Array.isArray(lessons) && lessons.length > 0 ?
6636
+ new Set(lessons as string[]) :
6637
+ null
6638
+
6639
+ const toSync: Array<{
6640
+ relativePath: string;
6641
+ status: string;
6642
+ bucketPath: string;
6643
+ isAsset: boolean;
6644
+ previousBucketPath?: string;
6645
+ }> = []
6646
+ const skippedFiles: Array<{
6647
+ filename: string;
6648
+ status: string;
6649
+ reason: string;
6650
+ }> = []
6651
+
6652
+ for (const f of compareRes.files) {
6653
+ const relativePath = f.filename
6654
+ const status = f.status || "modified"
6655
+
6656
+ if (isStructuralFile(relativePath)) {
6657
+ skippedFiles.push({
6658
+ filename: relativePath,
6659
+ status,
6660
+ reason: "Structural file",
6661
+ })
6662
+ continue
6663
+ }
6664
+
6665
+ const exercisesMatch = relativePath.match(
6666
+ /^exercises\/([^/]+)\/(.+)$/
6667
+ )
6668
+ if (exercisesMatch) {
6669
+ const [, slug, filename] = exercisesMatch
6670
+ if (lessonFilter && !lessonFilter.has(slug)) continue
6671
+ if (!lessonSlugs.has(slug)) {
6672
+ skippedFiles.push({
6673
+ filename: relativePath,
6674
+ status,
6675
+ reason: "Lesson does not exist in bucket",
6676
+ })
6677
+ continue
6678
+ }
6679
+
6680
+ if (
6681
+ status !== "modified" ||
6682
+ !lessonFilesMap.get(slug)!.has(filename)
6683
+ ) {
6684
+ skippedFiles.push({
6685
+ filename: relativePath,
6686
+ status,
6687
+ reason:
6688
+ status === "modified" ?
6689
+ "File does not exist in bucket" :
6690
+ "Structural change in lesson",
6691
+ })
6692
+ continue
6693
+ }
6694
+
6695
+ toSync.push({
6696
+ relativePath,
6697
+ status,
6698
+ bucketPath: `courses/${courseSlug}/exercises/${slug}/${filename}`,
6699
+ isAsset: false,
6700
+ })
6701
+ continue
6702
+ }
6703
+
6704
+ if (relativePath.startsWith(".learn/assets/")) {
6705
+ const bucketPath = `courses/${courseSlug}/${relativePath}`
6706
+ const previousPath = f.previous_filename
6707
+ const previousBucketPath = previousPath ?
6708
+ `courses/${courseSlug}/${previousPath}` :
6709
+ undefined
6710
+
6711
+ toSync.push({
6712
+ relativePath,
6713
+ status,
6714
+ bucketPath,
6715
+ isAsset: true,
6716
+ previousBucketPath,
6717
+ })
6718
+ }
6719
+ }
6720
+
6721
+ const syncedLessonSlugs = new Set<string>()
6722
+ let syncedLessonFilesCount = 0
6723
+ let syncedAssetsUploaded = 0
6724
+ let removedCount = 0
6725
+
6726
+ for (const item of toSync) {
6727
+ if (item.isAsset && item.status === "removed") {
6728
+ try {
6729
+ const fileRef = bucket.file(item.bucketPath)
6730
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
6731
+ await fileRef.delete()
6732
+ removedCount++
6733
+ } catch {
6734
+ // File may not exist, ignore
6735
+ }
6736
+
6737
+ continue
6738
+ }
6739
+
6740
+ if (item.isAsset && item.previousBucketPath) {
6741
+ try {
6742
+ const oldFileRef = bucket.file(item.previousBucketPath)
6743
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
6744
+ await oldFileRef.delete()
6745
+ removedCount++
6746
+ } catch {
6747
+ // Old file may not exist, ignore
6748
+ }
6749
+ }
6750
+
6751
+ if (item.isAsset || item.status === "modified") {
6752
+ let contentRes: { content?: string; encoding?: string }
6753
+ try {
6754
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
6755
+ contentRes = await githubApiFetch(
6756
+ `https://api.github.com/repos/${repository}/contents/${encodeURIComponent(
6757
+ item.relativePath
6758
+ )}?ref=${targetSHA}`,
6759
+ token
6760
+ )
6761
+ } catch (error: unknown) {
6762
+ const axErr = error as { response?: { status: number } }
6763
+ if (axErr.response?.status === 404) {
6764
+ skippedFiles.push({
6765
+ filename: item.relativePath,
6766
+ status: item.status,
6767
+ reason: "File not found in GitHub",
6768
+ })
6769
+ continue
6770
+ }
6771
+
6772
+ throw error
6773
+ }
6774
+
6775
+ const content = contentRes.content
6776
+ if (!content) {
6777
+ skippedFiles.push({
6778
+ filename: item.relativePath,
6779
+ status: item.status,
6780
+ reason: "No content in response",
6781
+ })
6782
+ continue
6783
+ }
6784
+
6785
+ const buffer = Buffer.from(content, "base64")
6786
+
6787
+ if (isImageFile(item.relativePath)) {
6788
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
6789
+ await uploadBinaryToBucket(
6790
+ bucket,
6791
+ buffer,
6792
+ item.bucketPath,
6793
+ "application/octet-stream"
6794
+ )
6795
+ } else {
6796
+ const text = buffer.toString("utf8")
6797
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
6798
+ await uploadFileToBucket(bucket, text, item.bucketPath)
6799
+ }
6800
+
6801
+ if (item.isAsset) {
6802
+ syncedAssetsUploaded++
6803
+ } else {
6804
+ syncedLessonSlugs.add(
6805
+ item.relativePath.replace(/^exercises\/([^/]+)\/.*/, "$1")
6806
+ )
6807
+ syncedLessonFilesCount++
6808
+ }
6809
+ }
6810
+ }
6811
+
6812
+ configJson.config = configJson.config || {}
6813
+ configJson.config.github = {
6814
+ ...configJson.config.github,
6815
+ lastSyncSHA: targetSHA,
6816
+ }
6817
+ await uploadFileToBucket(
6818
+ bucket,
6819
+ JSON.stringify(configJson),
6820
+ `courses/${courseSlug}/.learn/config.json`
6821
+ )
6822
+
6823
+ return res.json({
6824
+ success: true,
6825
+ syncedLessons: [...syncedLessonSlugs],
6826
+ syncedAssets: syncedAssetsUploaded + removedCount,
6827
+ syncedFiles: syncedLessonFilesCount + syncedAssetsUploaded,
6828
+ removedFiles: removedCount,
6829
+ skippedFiles,
6830
+ newSHA: targetSHA,
6831
+ })
6832
+ })
6833
+ )
6834
+
6835
+ app.post(
6836
+ "/actions/github/push",
6837
+ express.json(),
6838
+ asyncHandler(async (req, res) => {
6839
+ const { courseSlug } = req.body
6840
+
6841
+ if (!courseSlug) {
6842
+ throw new ValidationError("courseSlug is required in request body")
6843
+ }
6844
+
6845
+ const { token, username, isConfigured } = getGithubCredentials()
6846
+ if (!isConfigured || !token || !username) {
6847
+ throw new ValidationError(
6848
+ "GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env"
6849
+ )
6850
+ }
6851
+
6852
+ const configJson = await getConfigJSON(bucket, courseSlug)
6853
+ const repository = configJson.config?.github?.repository
6854
+ const lastSyncSHA = configJson.config?.github?.lastSyncSHA
6855
+ const defaultBranch =
6856
+ configJson.config?.github?.defaultBranch || "main"
6857
+
6858
+ if (!repository || !lastSyncSHA) {
6859
+ throw new ValidationError(
6860
+ "Package has no linked GitHub repository. Create one first."
6861
+ )
6862
+ }
6863
+
6864
+ const refRes = await githubApiFetch(
6865
+ `https://api.github.com/repos/${repository}/git/ref/heads/${defaultBranch}`,
6866
+ token
6867
+ )
6868
+ const currentBranchSHA =
6869
+ refRes.object?.sha ?? refRes.ref?.object?.sha ?? refRes.object?.sha
6870
+ if (!currentBranchSHA) {
6871
+ throw new ValidationError(
6872
+ `Could not get ${defaultBranch} branch SHA from GitHub`
6873
+ )
6874
+ }
6875
+
6876
+ const [allFiles] = await bucket.getFiles({
6877
+ prefix: `courses/${courseSlug}/`,
6878
+ })
6879
+
6880
+ const treeEntries = await Promise.all(
6881
+ allFiles.map(async file => {
6882
+ const [content] = await file.download()
6883
+ const contentBase64 = content.toString("base64")
6884
+
6885
+ const blobRes = await githubApiFetch(
6886
+ `https://api.github.com/repos/${repository}/git/blobs`,
6887
+ token,
6888
+ {
6889
+ method: "POST",
6890
+ body: { content: contentBase64, encoding: "base64" },
6891
+ }
6892
+ )
6893
+
6894
+ const relativePath = file.name.replace(
6895
+ `courses/${courseSlug}/`,
6896
+ ""
6897
+ )
6898
+ return {
6899
+ path: relativePath,
6900
+ mode: "100644",
6901
+ type: "blob",
6902
+ sha: blobRes.sha,
6903
+ }
6904
+ })
6905
+ )
6906
+
6907
+ const treeRes = await githubApiFetch(
6908
+ `https://api.github.com/repos/${repository}/git/trees`,
6909
+ token,
6910
+ {
6911
+ method: "POST",
6912
+ body: { tree: treeEntries },
6913
+ }
6914
+ )
6915
+
6916
+ const commitRes = await githubApiFetch(
6917
+ `https://api.github.com/repos/${repository}/git/commits`,
6918
+ token,
6919
+ {
6920
+ method: "POST",
6921
+ body: {
6922
+ message: "Sync from LearnPack bucket",
6923
+ tree: treeRes.sha,
6924
+ parents: [currentBranchSHA],
6925
+ },
6926
+ }
6927
+ )
6928
+
6929
+ await githubApiFetch(
6930
+ `https://api.github.com/repos/${repository}/git/refs/heads/${defaultBranch}`,
6931
+ token,
6932
+ {
6933
+ method: "PATCH",
6934
+ body: { sha: commitRes.sha, force: false },
6935
+ }
6936
+ )
6937
+
6938
+ configJson.config = configJson.config || {}
6939
+ configJson.config.github = {
6940
+ ...configJson.config.github,
6941
+ lastSyncSHA: commitRes.sha,
6942
+ }
6943
+ await uploadFileToBucket(
6944
+ bucket,
6945
+ JSON.stringify(configJson),
6946
+ `courses/${courseSlug}/.learn/config.json`
6947
+ )
6948
+
6949
+ return res.json({
6950
+ success: true,
6951
+ repository,
6952
+ sha: commitRes.sha,
6953
+ totalFiles: allFiles.length,
6954
+ })
6955
+ })
6956
+ )
6957
+
5665
6958
  app.get("/proxy", async (req, res) => {
5666
6959
  const { url } = req.query
5667
6960