@learnpack/learnpack 5.0.343 → 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.
- package/lib/commands/serve.js +775 -3
- package/lib/creatorDist/assets/{index-BhqDgBS9.js → index-DnthLsvb.js} +4731 -4730
- package/lib/creatorDist/index.html +1 -1
- package/lib/utils/api.d.ts +1 -1
- package/lib/utils/api.js +1 -1
- package/package.json +1 -1
- package/src/commands/serve.ts +1153 -3
- package/src/creator/src/components/FileUploader.tsx +1 -2
- package/src/creator/src/utils/rigo.ts +1 -2
- package/src/creatorDist/assets/{index-BhqDgBS9.js → index-DnthLsvb.js} +4731 -4730
- package/src/creatorDist/index.html +1 -1
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +2103 -2101
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +2 -1
package/src/commands/serve.ts
CHANGED
|
@@ -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
|
|
@@ -2826,7 +3081,15 @@ class ServeCommand extends SessionCommand {
|
|
|
2826
3081
|
}
|
|
2827
3082
|
|
|
2828
3083
|
try {
|
|
2829
|
-
const { config, exercises } = await buildConfig(
|
|
3084
|
+
const { config: builtConfig, exercises } = await buildConfig(
|
|
3085
|
+
bucket,
|
|
3086
|
+
courseSlug
|
|
3087
|
+
)
|
|
3088
|
+
const config = await mergeConfigPreservingGithub(
|
|
3089
|
+
bucket,
|
|
3090
|
+
courseSlug,
|
|
3091
|
+
builtConfig
|
|
3092
|
+
)
|
|
2830
3093
|
|
|
2831
3094
|
res.set("X-Creator-Web", "true")
|
|
2832
3095
|
res.set("Access-Control-Expose-Headers", "X-Creator-Web")
|
|
@@ -4441,6 +4704,8 @@ class ServeCommand extends SessionCommand {
|
|
|
4441
4704
|
title: string;
|
|
4442
4705
|
slug: string;
|
|
4443
4706
|
}> = []
|
|
4707
|
+
let repairedTranslationsInLessons = 0
|
|
4708
|
+
let repairedTranslationEntries = 0
|
|
4444
4709
|
|
|
4445
4710
|
console.log(
|
|
4446
4711
|
`📋 Checking ${syllabus.lessons.length} lessons in syllabus...`
|
|
@@ -4622,10 +4887,82 @@ class ServeCommand extends SessionCommand {
|
|
|
4622
4887
|
})
|
|
4623
4888
|
}
|
|
4624
4889
|
|
|
4625
|
-
|
|
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
|
+
) {
|
|
4626
4963
|
await saveSyllabus(courseSlug, syllabus, bucket)
|
|
4627
4964
|
console.log(
|
|
4628
|
-
`✅ 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).`
|
|
4629
4966
|
)
|
|
4630
4967
|
} else {
|
|
4631
4968
|
console.log(`✅ Syllabus is already in sync. No changes.`)
|
|
@@ -4639,6 +4976,8 @@ class ServeCommand extends SessionCommand {
|
|
|
4639
4976
|
removedLessons: removedLessons.length,
|
|
4640
4977
|
duplicatesResolved: duplicatesRemoved.length,
|
|
4641
4978
|
addedLessons: addedLessons.length,
|
|
4979
|
+
repairedTranslationsInLessons,
|
|
4980
|
+
repairedTranslationEntries,
|
|
4642
4981
|
removed: removedLessons,
|
|
4643
4982
|
duplicates: duplicatesRemoved,
|
|
4644
4983
|
kept: keptLessons,
|
|
@@ -5805,6 +6144,817 @@ class ServeCommand extends SessionCommand {
|
|
|
5805
6144
|
}
|
|
5806
6145
|
})
|
|
5807
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
|
+
|
|
5808
6958
|
app.get("/proxy", async (req, res) => {
|
|
5809
6959
|
const { url } = req.query
|
|
5810
6960
|
|