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