@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.
- package/lib/commands/serve.js +895 -21
- 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 +1312 -19
- 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
|
|
@@ -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}/${
|
|
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 =
|
|
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 = `${
|
|
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(
|
|
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
|
-
//
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
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
|
-
|
|
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
|
|