@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.
@@ -39,6 +39,7 @@ const redis_1 = require("redis");
39
39
  const historyManager_1 = require("../managers/historyManager");
40
40
  const readmeHistoryService_1 = require("../managers/readmeHistoryService");
41
41
  const readmeSanitizer_1 = require("../utils/readmeSanitizer");
42
+ // eslint-disable-next-line
42
43
  const frontMatter = require("front-matter");
43
44
  if (process.env.NEW_RELIC_ENABLED === "true") {
44
45
  require("newrelic");
@@ -108,6 +109,161 @@ const uploadFileToBucket = async (bucket, file, path) => {
108
109
  const fileRef = bucket.file(path);
109
110
  await fileRef.save(buffer_1.Buffer.from(content, "utf8"));
110
111
  };
112
+ const uploadBinaryToBucket = async (bucket, buffer, path, contentType) => {
113
+ const fileRef = bucket.file(path);
114
+ await fileRef.save(buffer, Object.assign({ resumable: false }, (contentType && { contentType })));
115
+ };
116
+ const getGithubCredentials = () => {
117
+ var _a, _b;
118
+ const token = (_a = process.env.GITHUB_TOKEN) === null || _a === void 0 ? void 0 : _a.trim();
119
+ const username = (_b = process.env.GITHUB_USERNAME) === null || _b === void 0 ? void 0 : _b.trim();
120
+ return { token, username, isConfigured: Boolean(token && username) };
121
+ };
122
+ const GITHUB_RETRY_DELAYS_MS = [250, 600, 1200];
123
+ const wait = (ms) => new Promise(resolve => {
124
+ setTimeout(resolve, ms);
125
+ });
126
+ const getGithubErrorStatus = (error) => { var _a; return (_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.status; };
127
+ const getGithubErrorData = (error) => { var _a; return ((_a = error === null || error === void 0 ? void 0 : error.response) === null || _a === void 0 ? void 0 : _a.data) || {}; };
128
+ const getGithubErrorMessage = (error) => {
129
+ const data = getGithubErrorData(error);
130
+ if (typeof (data === null || data === void 0 ? void 0 : data.message) === "string") {
131
+ return data.message;
132
+ }
133
+ if (typeof (error === null || error === void 0 ? void 0 : error.message) === "string") {
134
+ return error.message;
135
+ }
136
+ return undefined;
137
+ };
138
+ const errorPayloadHasText = (payload, term) => {
139
+ if (!payload || !term)
140
+ return false;
141
+ try {
142
+ return JSON.stringify(payload).toLowerCase().includes(term.toLowerCase());
143
+ }
144
+ catch (_a) {
145
+ return false;
146
+ }
147
+ };
148
+ const isGithubPushProtectionConflict = (error) => {
149
+ if (getGithubErrorStatus(error) !== 409)
150
+ return false;
151
+ const message = (getGithubErrorMessage(error) || "").toLowerCase();
152
+ const data = getGithubErrorData(error);
153
+ return (message.includes("push protection") ||
154
+ message.includes("secret scanning") ||
155
+ message.includes("secret") ||
156
+ errorPayloadHasText(data === null || data === void 0 ? void 0 : data.errors, "push protection") ||
157
+ errorPayloadHasText(data === null || data === void 0 ? void 0 : data.errors, "secret"));
158
+ };
159
+ const isGithubRepoUnavailableConflict = (error) => {
160
+ if (getGithubErrorStatus(error) !== 409)
161
+ return false;
162
+ const message = (getGithubErrorMessage(error) || "").toLowerCase();
163
+ return (message.includes("empty or unavailable") ||
164
+ message.includes("repository is empty") ||
165
+ message.includes("repository is unavailable") ||
166
+ message.includes("currently being created"));
167
+ };
168
+ const shouldRetryGithubConflict = (error) => isGithubRepoUnavailableConflict(error) &&
169
+ !isGithubPushProtectionConflict(error);
170
+ const toGithubOperationalError = (error, context) => {
171
+ if ((error === null || error === void 0 ? void 0 : error.isOperational) === true) {
172
+ return error;
173
+ }
174
+ const status = getGithubErrorStatus(error);
175
+ const data = getGithubErrorData(error);
176
+ const githubMessage = getGithubErrorMessage(error);
177
+ const details = Object.assign(Object.assign(Object.assign(Object.assign({ failedOperation: context.operation }, (context.failedPath ? { failedPath: context.failedPath } : {})), (context.repository ? { repository: context.repository } : {})), (context.repoCreated ? { repoCreated: true } : {})), { github: {
178
+ status,
179
+ message: githubMessage,
180
+ documentation_url: data === null || data === void 0 ? void 0 : data.documentation_url,
181
+ errors: data === null || data === void 0 ? void 0 : data.errors,
182
+ } });
183
+ let message = githubMessage ||
184
+ `GitHub request failed during operation "${context.operation}"`;
185
+ if (status === 422 && context.operation === "create_repo") {
186
+ message = context.repoName ?
187
+ `Repository "${context.repoName}" may already exist or name is invalid` :
188
+ "Repository may already exist or the requested name is invalid";
189
+ }
190
+ else if (status === 409 && isGithubPushProtectionConflict(error)) {
191
+ message = context.failedPath ?
192
+ `File blocked by push protection: ${context.failedPath}` :
193
+ "GitHub push protection blocked sensitive content during the initial commit";
194
+ }
195
+ else if (context.repoCreated && context.operation !== "create_repo") {
196
+ message =
197
+ "Repo created on GitHub, but the initial commit failed. Check the details and try again.";
198
+ }
199
+ if (status === 400)
200
+ return new errorHandler_1.ValidationError(message, details);
201
+ if (status === 404)
202
+ return new errorHandler_1.NotFoundError(message, details);
203
+ if (status === 409 || status === 422)
204
+ return new errorHandler_1.ConflictError(message, details);
205
+ return new errorHandler_1.InternalServerError(message, details);
206
+ };
207
+ const githubApiFetch = async (url, token, options = {}) => {
208
+ const res = await (0, axios_1.default)({
209
+ url,
210
+ method: options.method || "GET",
211
+ headers: {
212
+ Authorization: `token ${token}`,
213
+ Accept: "application/vnd.github.v3+json",
214
+ },
215
+ data: options.body,
216
+ });
217
+ return res.data;
218
+ };
219
+ const githubApiFetchWithContext = async (url, token, context, options = {}) => {
220
+ try {
221
+ return await githubApiFetch(url, token, options);
222
+ }
223
+ catch (error) {
224
+ throw toGithubOperationalError(error, context);
225
+ }
226
+ };
227
+ const githubApiFetchWithRetry = async (url, token, context, options = {}) => {
228
+ const attempts = context.retryOnUnavailableConflict ?
229
+ GITHUB_RETRY_DELAYS_MS.length + 1 :
230
+ 1;
231
+ for (let attempt = 0; attempt < attempts; attempt++) {
232
+ try {
233
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
234
+ return await githubApiFetch(url, token, options);
235
+ }
236
+ catch (error) {
237
+ const canRetry = attempt < attempts - 1 && shouldRetryGithubConflict(error);
238
+ if (!canRetry) {
239
+ throw toGithubOperationalError(error, context);
240
+ }
241
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
242
+ await wait(GITHUB_RETRY_DELAYS_MS[attempt]);
243
+ }
244
+ }
245
+ throw new errorHandler_1.InternalServerError("GitHub request failed after retries", {
246
+ failedOperation: context.operation,
247
+ repository: context.repository,
248
+ });
249
+ };
250
+ const mapWithConcurrency = async (items, limit, mapper) => {
251
+ if (items.length === 0) {
252
+ return [];
253
+ }
254
+ const concurrency = Math.max(1, Math.min(limit, items.length));
255
+ const results = Array.from({ length: items.length });
256
+ let cursor = 0;
257
+ const worker = async () => {
258
+ let index;
259
+ while ((index = cursor++) < items.length) {
260
+ // eslint-disable-next-line no-await-in-loop -- intentional: each worker processes items sequentially
261
+ results[index] = await mapper(items[index], index);
262
+ }
263
+ };
264
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
265
+ return results;
266
+ };
111
267
  const PARAMS = {
112
268
  expected_grade_level: "8",
113
269
  max_fkgl: 10,
@@ -232,7 +388,7 @@ const createMultiLangAsset = async (bucket, rigoToken, bcToken, courseSlug, cour
232
388
  const b64IndexReadme = buffer_1.Buffer.from(indexReadmeString).toString("base64");
233
389
  try {
234
390
  // eslint-disable-next-line no-await-in-loop
235
- const asset = await (0, publish_1.handleAssetCreation)({ token: bcToken, rigobotToken: rigoToken.trim() }, courseJson, lang, deployUrl, b64IndexReadme, academyId, all_translations);
391
+ const asset = await (0, publish_1.handleAssetCreation)({ token: bcToken, rigobotToken: rigoToken.trim() }, courseJson, lang, deployUrl, b64IndexReadme, academyId, undefined, all_translations);
236
392
  if (!asset) {
237
393
  errors.push({
238
394
  lang,
@@ -361,6 +517,19 @@ const getConfigJSON = async (bucket, courseSlug) => {
361
517
  const [content] = await configFile.download();
362
518
  return JSON.parse(content.toString());
363
519
  };
520
+ async function mergeConfigPreservingGithub(bucket, courseSlug, newConfig) {
521
+ var _a;
522
+ try {
523
+ const existing = await getConfigJSON(bucket, courseSlug);
524
+ if ((_a = existing === null || existing === void 0 ? void 0 : existing.config) === null || _a === void 0 ? void 0 : _a.github) {
525
+ return Object.assign(Object.assign({}, newConfig), { github: Object.assign({}, existing.config.github) });
526
+ }
527
+ }
528
+ catch (_b) {
529
+ // No existing config or parse error - use newConfig as-is
530
+ }
531
+ return newConfig;
532
+ }
364
533
  async function getSyllabus(courseSlug, bucket) {
365
534
  const syllabus = await bucket.file(`courses/${courseSlug}/.learn/initialSyllabus.json`);
366
535
  const [content] = await syllabus.download();
@@ -1969,7 +2138,8 @@ class ServeCommand extends SessionCommand_1.default {
1969
2138
  .json({ error: "Course slug and rigo token required" });
1970
2139
  }
1971
2140
  try {
1972
- const { config, exercises } = await (0, configBuilder_1.buildConfig)(bucket, courseSlug);
2141
+ const { config: builtConfig, exercises } = await (0, configBuilder_1.buildConfig)(bucket, courseSlug);
2142
+ const config = await mergeConfigPreservingGithub(bucket, courseSlug, builtConfig);
1973
2143
  res.set("X-Creator-Web", "true");
1974
2144
  res.set("Access-Control-Expose-Headers", "X-Creator-Web");
1975
2145
  await uploadFileToBucket(bucket, JSON.stringify({ config, exercises }), `courses/${courseSlug}/.learn/config.json`);
@@ -3069,6 +3239,8 @@ class ServeCommand extends SessionCommand_1.default {
3069
3239
  const keptLessons = [];
3070
3240
  const duplicatesRemoved = [];
3071
3241
  const addedLessons = [];
3242
+ let repairedTranslationsInLessons = 0;
3243
+ let repairedTranslationEntries = 0;
3072
3244
  console.log(`📋 Checking ${syllabus.lessons.length} lessons in syllabus...`);
3073
3245
  // First pass: Check each lesson to see if it exists in the bucket and count files.
3074
3246
  // We try two possible folder names because they can differ by source:
@@ -3211,9 +3383,70 @@ class ServeCommand extends SessionCommand_1.default {
3211
3383
  return String(a.id).localeCompare(String(b.id));
3212
3384
  });
3213
3385
  }
3214
- if (totalRemoved > 0 || addedLessons.length > 0) {
3386
+ // Fourth pass: reconcile lesson.translations from actual README files in bucket.
3387
+ // This fixes lessons that exist but lost translations metadata after renames or syncs.
3388
+ try {
3389
+ const { exercises } = await (0, configBuilder_1.buildConfig)(bucket, courseSlug);
3390
+ const translationsBySlug = new Map();
3391
+ for (const exercise of exercises) {
3392
+ const langs = Object.keys(exercise.translations || {})
3393
+ .map(lang => lang.toLowerCase())
3394
+ .filter(Boolean);
3395
+ if (langs.length > 0) {
3396
+ translationsBySlug.set(exercise.slug, [...new Set(langs)]);
3397
+ }
3398
+ }
3399
+ for (const lesson of syllabus.lessons) {
3400
+ const candidateSlugs = [
3401
+ (0, creatorUtilities_2.slugify)(lesson.id + "-" + lesson.title),
3402
+ lesson.uid,
3403
+ ].filter(Boolean);
3404
+ const matchedSlug = candidateSlugs.find(s => translationsBySlug.has(s));
3405
+ if (!matchedSlug)
3406
+ continue;
3407
+ const languageCodes = translationsBySlug.get(matchedSlug) || [];
3408
+ if (languageCodes.length === 0)
3409
+ continue;
3410
+ const currentTranslations = lesson.translations || {};
3411
+ let lessonChanged = false;
3412
+ for (const lang of languageCodes) {
3413
+ const now = Date.now();
3414
+ if (!currentTranslations[lang]) {
3415
+ currentTranslations[lang] = {
3416
+ completionId: 0,
3417
+ startedAt: now,
3418
+ completedAt: now,
3419
+ };
3420
+ repairedTranslationEntries += 1;
3421
+ lessonChanged = true;
3422
+ }
3423
+ else {
3424
+ // If README exists for this language but metadata is incomplete, mark it as completed.
3425
+ if (!currentTranslations[lang].startedAt) {
3426
+ currentTranslations[lang].startedAt = now;
3427
+ lessonChanged = true;
3428
+ }
3429
+ if (!currentTranslations[lang].completedAt) {
3430
+ currentTranslations[lang].completedAt = now;
3431
+ repairedTranslationEntries += 1;
3432
+ lessonChanged = true;
3433
+ }
3434
+ }
3435
+ }
3436
+ if (lessonChanged) {
3437
+ lesson.translations = currentTranslations;
3438
+ repairedTranslationsInLessons += 1;
3439
+ }
3440
+ }
3441
+ }
3442
+ catch (error) {
3443
+ console.error("⚠️ Could not reconcile lesson translations during syllabus sync:", error);
3444
+ }
3445
+ if (totalRemoved > 0 ||
3446
+ addedLessons.length > 0 ||
3447
+ repairedTranslationsInLessons > 0) {
3215
3448
  await saveSyllabus(courseSlug, syllabus, bucket);
3216
- console.log(`✅ Syllabus synchronized. Removed ${removedLessons.length} non-existent, ${duplicatesRemoved.length} duplicate(s); added ${addedLessons.length} from bucket.`);
3449
+ console.log(`✅ Syllabus synchronized. Removed ${removedLessons.length} non-existent, ${duplicatesRemoved.length} duplicate(s); added ${addedLessons.length} from bucket; repaired translations in ${repairedTranslationsInLessons} lesson(s).`);
3217
3450
  }
3218
3451
  else {
3219
3452
  console.log(`✅ Syllabus is already in sync. No changes.`);
@@ -3226,6 +3459,8 @@ class ServeCommand extends SessionCommand_1.default {
3226
3459
  removedLessons: removedLessons.length,
3227
3460
  duplicatesResolved: duplicatesRemoved.length,
3228
3461
  addedLessons: addedLessons.length,
3462
+ repairedTranslationsInLessons,
3463
+ repairedTranslationEntries,
3229
3464
  removed: removedLessons,
3230
3465
  duplicates: duplicatesRemoved,
3231
3466
  kept: keptLessons,
@@ -3546,26 +3781,48 @@ class ServeCommand extends SessionCommand_1.default {
3546
3781
  const configJson = JSON.parse(configContent.toString());
3547
3782
  const { config } = configJson;
3548
3783
  const availableLangs = Object.keys(config.title || {});
3549
- let academyId = null;
3550
3784
  let isPublished = false;
3551
- for (const lang of availableLangs) {
3785
+ // Collect academy ids from all existing assets
3786
+ const foundAcademyIds = [];
3787
+ const slugsToCheck = availableLangs
3788
+ .map(lang => {
3552
3789
  const assetTitle = getLocalizedValue(config.title, lang);
3553
3790
  if (!assetTitle)
3554
- continue;
3555
- let assetSlug = (0, creatorUtilities_2.slugify)(assetTitle).slice(0, 47);
3556
- assetSlug = `${assetSlug}-${lang}`;
3557
- const { exists, academyId: existingAcademyId } =
3558
- // eslint-disable-next-line no-await-in-loop
3559
- await (0, api_1.doesAssetExists)(bcToken, assetSlug);
3791
+ return null;
3792
+ const assetSlug = (0, creatorUtilities_2.slugify)(assetTitle).slice(0, 47);
3793
+ return `${assetSlug}-${lang}`;
3794
+ })
3795
+ .filter((slug) => slug !== null);
3796
+ const assetChecks = await Promise.all(slugsToCheck.map(slug => (0, api_1.doesAssetExists)(bcToken, slug)));
3797
+ for (const { exists, academyId: existingAcademyId } of assetChecks) {
3560
3798
  if (exists) {
3561
3799
  isPublished = true;
3562
3800
  if (existingAcademyId !== undefined) {
3563
- academyId = existingAcademyId;
3564
- break;
3801
+ foundAcademyIds.push(existingAcademyId);
3565
3802
  }
3566
3803
  }
3567
3804
  }
3568
- return res.json({ academyId, isPublished });
3805
+ const uniqueAcademies = [...new Set(foundAcademyIds)];
3806
+ let mode;
3807
+ let lockedAcademyId;
3808
+ let conflictAcademies;
3809
+ if (uniqueAcademies.length === 0) {
3810
+ mode = "select";
3811
+ }
3812
+ else if (uniqueAcademies.length === 1) {
3813
+ mode = "locked";
3814
+ lockedAcademyId = uniqueAcademies[0];
3815
+ }
3816
+ else {
3817
+ mode = "conflict";
3818
+ conflictAcademies = uniqueAcademies;
3819
+ }
3820
+ return res.json({
3821
+ isPublished,
3822
+ mode,
3823
+ lockedAcademyId,
3824
+ conflictAcademies,
3825
+ });
3569
3826
  }
3570
3827
  catch (error) {
3571
3828
  console.error("Error fetching package academy:", error);
@@ -4067,6 +4324,544 @@ class ServeCommand extends SessionCommand_1.default {
4067
4324
  });
4068
4325
  }
4069
4326
  });
4327
+ app.get("/actions/github/status", async (req, res) => {
4328
+ var _a;
4329
+ const courseSlug = req.query.slug;
4330
+ if (!courseSlug) {
4331
+ return res
4332
+ .status(400)
4333
+ .json({ error: "slug query parameter is required" });
4334
+ }
4335
+ const { isConfigured } = getGithubCredentials();
4336
+ try {
4337
+ const configJson = await getConfigJSON(bucket, courseSlug);
4338
+ const github = (_a = configJson.config) === null || _a === void 0 ? void 0 : _a.github;
4339
+ const repository = github === null || github === void 0 ? void 0 : github.repository;
4340
+ const linked = Boolean(repository);
4341
+ return res.json({
4342
+ configured: isConfigured,
4343
+ linked,
4344
+ repository: linked ? repository : null,
4345
+ });
4346
+ }
4347
+ catch (_b) {
4348
+ return res.json({
4349
+ configured: isConfigured,
4350
+ linked: false,
4351
+ repository: null,
4352
+ });
4353
+ }
4354
+ });
4355
+ app.post("/actions/github/create-repo", express.json(), (0, errorHandler_1.asyncHandler)(async (req, res) => {
4356
+ var _a, _b, _c, _d, _e, _f, _g, _h;
4357
+ const { courseSlug, repoName, isPrivate, description } = req.body;
4358
+ if (!courseSlug) {
4359
+ throw new errorHandler_1.ValidationError("courseSlug is required in request body");
4360
+ }
4361
+ const { token, username, isConfigured } = getGithubCredentials();
4362
+ if (!isConfigured || !token || !username) {
4363
+ throw new errorHandler_1.ValidationError("GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env");
4364
+ }
4365
+ const configJson = await getConfigJSON(bucket, courseSlug);
4366
+ if ((_b = (_a = configJson.config) === null || _a === void 0 ? void 0 : _a.github) === null || _b === void 0 ? void 0 : _b.repository) {
4367
+ throw new errorHandler_1.ConflictError("Package already has a linked GitHub repository");
4368
+ }
4369
+ const finalRepoName = repoName || courseSlug;
4370
+ const finalDescription = description || "Tutorial created with LearnPack";
4371
+ const createRepoUrl = "https://api.github.com/user/repos";
4372
+ const createdRepo = await githubApiFetchWithContext(createRepoUrl, token, {
4373
+ operation: "create_repo",
4374
+ repoName: finalRepoName,
4375
+ }, {
4376
+ method: "POST",
4377
+ body: {
4378
+ name: finalRepoName,
4379
+ private: Boolean(isPrivate),
4380
+ description: finalDescription,
4381
+ auto_init: true,
4382
+ },
4383
+ });
4384
+ const repository = (createdRepo === null || createdRepo === void 0 ? void 0 : createdRepo.full_name) || `${username}/${finalRepoName}`;
4385
+ const defaultBranch = (createdRepo === null || createdRepo === void 0 ? void 0 : createdRepo.default_branch) || "main";
4386
+ const repositoryUrl = (createdRepo === null || createdRepo === void 0 ? void 0 : createdRepo.html_url) || `https://github.com/${repository}`;
4387
+ const refRes = await githubApiFetchWithRetry(`https://api.github.com/repos/${repository}/git/ref/heads/${defaultBranch}`, token, {
4388
+ operation: "get_base_ref",
4389
+ repository,
4390
+ repoCreated: true,
4391
+ retryOnUnavailableConflict: true,
4392
+ }, {});
4393
+ const baseSHA = (_g = (_d = (_c = refRes.object) === null || _c === void 0 ? void 0 : _c.sha) !== null && _d !== void 0 ? _d : (_f = (_e = refRes.ref) === null || _e === void 0 ? void 0 : _e.object) === null || _f === void 0 ? void 0 : _f.sha) !== null && _g !== void 0 ? _g : (_h = refRes.object) === null || _h === void 0 ? void 0 : _h.sha;
4394
+ if (!baseSHA) {
4395
+ throw new errorHandler_1.InternalServerError("Could not get default branch SHA from GitHub after repository creation", {
4396
+ failedOperation: "get_base_ref",
4397
+ repository,
4398
+ repoCreated: true,
4399
+ });
4400
+ }
4401
+ const [allFiles] = await bucket.getFiles({
4402
+ prefix: `courses/${courseSlug}/`,
4403
+ });
4404
+ const treeEntries = await mapWithConcurrency(allFiles, 5, async (file) => {
4405
+ const relativePath = file.name.replace(`courses/${courseSlug}/`, "");
4406
+ const [content] = await file.download();
4407
+ const contentBase64 = content.toString("base64");
4408
+ const blobRes = await githubApiFetchWithRetry(`https://api.github.com/repos/${repository}/git/blobs`, token, {
4409
+ operation: "create_blob",
4410
+ repository,
4411
+ failedPath: relativePath,
4412
+ repoCreated: true,
4413
+ retryOnUnavailableConflict: true,
4414
+ }, {
4415
+ method: "POST",
4416
+ body: { content: contentBase64, encoding: "base64" },
4417
+ });
4418
+ return {
4419
+ path: relativePath,
4420
+ mode: "100644",
4421
+ type: "blob",
4422
+ sha: blobRes.sha,
4423
+ };
4424
+ });
4425
+ const treeRes = await githubApiFetchWithRetry(`https://api.github.com/repos/${repository}/git/trees`, token, {
4426
+ operation: "create_tree",
4427
+ repository,
4428
+ repoCreated: true,
4429
+ retryOnUnavailableConflict: true,
4430
+ }, {
4431
+ method: "POST",
4432
+ body: { tree: treeEntries },
4433
+ });
4434
+ const commitRes = await githubApiFetchWithRetry(`https://api.github.com/repos/${repository}/git/commits`, token, {
4435
+ operation: "create_commit",
4436
+ repository,
4437
+ repoCreated: true,
4438
+ retryOnUnavailableConflict: true,
4439
+ }, {
4440
+ method: "POST",
4441
+ body: {
4442
+ message: "Initial commit from LearnPack",
4443
+ tree: treeRes.sha,
4444
+ parents: [baseSHA],
4445
+ },
4446
+ });
4447
+ await githubApiFetchWithRetry(`https://api.github.com/repos/${repository}/git/refs/heads/${defaultBranch}`, token, {
4448
+ operation: "update_ref",
4449
+ repository,
4450
+ repoCreated: true,
4451
+ retryOnUnavailableConflict: true,
4452
+ }, {
4453
+ method: "PATCH",
4454
+ body: {
4455
+ sha: commitRes.sha,
4456
+ force: false,
4457
+ },
4458
+ });
4459
+ configJson.config = configJson.config || {};
4460
+ configJson.config.github = {
4461
+ repository,
4462
+ defaultBranch,
4463
+ lastSyncSHA: commitRes.sha,
4464
+ };
4465
+ await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4466
+ return res.json({
4467
+ success: true,
4468
+ repository,
4469
+ url: repositoryUrl,
4470
+ sha: commitRes.sha,
4471
+ });
4472
+ }));
4473
+ const GITHUB_STRUCTURAL_FILES = new Set([
4474
+ "learn.json",
4475
+ ".learn/config.json",
4476
+ ".learn/initialSyllabus.json",
4477
+ ".learn/sidebar.json",
4478
+ ".learn/memory_bank.txt",
4479
+ ]);
4480
+ const isStructuralFile = (relativePath) => GITHUB_STRUCTURAL_FILES.has(relativePath) ||
4481
+ relativePath === "learn.json" ||
4482
+ (relativePath.startsWith(".learn/") &&
4483
+ !relativePath.startsWith(".learn/assets/"));
4484
+ const IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"];
4485
+ const isImageFile = (path) => IMAGE_EXTENSIONS.some(ext => path.toLowerCase().endsWith(ext));
4486
+ app.get("/actions/github/check-changes", (0, errorHandler_1.asyncHandler)(async (req, res) => {
4487
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
4488
+ const courseSlug = req.query.slug;
4489
+ if (!courseSlug) {
4490
+ throw new errorHandler_1.ValidationError("slug query parameter is required");
4491
+ }
4492
+ const { token, isConfigured } = getGithubCredentials();
4493
+ if (!isConfigured || !token) {
4494
+ throw new errorHandler_1.ValidationError("GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env");
4495
+ }
4496
+ const configJson = await getConfigJSON(bucket, courseSlug);
4497
+ const repository = (_b = (_a = configJson.config) === null || _a === void 0 ? void 0 : _a.github) === null || _b === void 0 ? void 0 : _b.repository;
4498
+ const lastSyncSHA = (_d = (_c = configJson.config) === null || _c === void 0 ? void 0 : _c.github) === null || _d === void 0 ? void 0 : _d.lastSyncSHA;
4499
+ const defaultBranch = ((_f = (_e = configJson.config) === null || _e === void 0 ? void 0 : _e.github) === null || _f === void 0 ? void 0 : _f.defaultBranch) || "main";
4500
+ if (!repository || !lastSyncSHA) {
4501
+ throw new errorHandler_1.ValidationError("Package has no linked GitHub repository. Create one first.");
4502
+ }
4503
+ const refRes = await githubApiFetch(`https://api.github.com/repos/${repository}/git/ref/heads/${defaultBranch}`, token);
4504
+ const currentBranchSHA = (_l = (_h = (_g = refRes.object) === null || _g === void 0 ? void 0 : _g.sha) !== null && _h !== void 0 ? _h : (_k = (_j = refRes.ref) === null || _j === void 0 ? void 0 : _j.object) === null || _k === void 0 ? void 0 : _k.sha) !== null && _l !== void 0 ? _l : (_m = refRes.object) === null || _m === void 0 ? void 0 : _m.sha;
4505
+ if (!currentBranchSHA) {
4506
+ throw new errorHandler_1.ValidationError(`Could not get ${defaultBranch} branch SHA from GitHub`);
4507
+ }
4508
+ const compareRes = await githubApiFetch(`https://api.github.com/repos/${repository}/compare/${lastSyncSHA}...${currentBranchSHA}`, token);
4509
+ if (compareRes.status === "identical" ||
4510
+ !compareRes.files ||
4511
+ compareRes.files.length === 0) {
4512
+ return res.json({ hasChanges: false });
4513
+ }
4514
+ const [allExerciseFiles] = await bucket.getFiles({
4515
+ prefix: `courses/${courseSlug}/exercises/`,
4516
+ });
4517
+ const lessonSlugs = new Set();
4518
+ const lessonFilesMap = new Map();
4519
+ for (const file of allExerciseFiles) {
4520
+ const match = file.name.match(new RegExp(`courses/${courseSlug}/exercises/([^/]+)/(.+)$`));
4521
+ if (match) {
4522
+ const [, slug, filePart] = match;
4523
+ lessonSlugs.add(slug);
4524
+ if (!lessonFilesMap.has(slug)) {
4525
+ lessonFilesMap.set(slug, new Set());
4526
+ }
4527
+ lessonFilesMap.get(slug).add(filePart);
4528
+ }
4529
+ }
4530
+ const syncableLessons = [];
4531
+ const syncableAssets = [];
4532
+ const skippedFiles = [];
4533
+ for (const f of compareRes.files) {
4534
+ const relativePath = f.filename;
4535
+ const status = f.status || "modified";
4536
+ if (isStructuralFile(relativePath)) {
4537
+ skippedFiles.push({
4538
+ filename: relativePath,
4539
+ status,
4540
+ reason: "Structural file",
4541
+ });
4542
+ continue;
4543
+ }
4544
+ const exercisesMatch = relativePath.match(/^exercises\/([^/]+)\/(.+)$/);
4545
+ if (exercisesMatch) {
4546
+ const [, slug, filename] = exercisesMatch;
4547
+ if (!lessonSlugs.has(slug)) {
4548
+ skippedFiles.push({
4549
+ filename: relativePath,
4550
+ status,
4551
+ reason: "Lesson does not exist in bucket",
4552
+ });
4553
+ continue;
4554
+ }
4555
+ const existingFiles = lessonFilesMap.get(slug);
4556
+ if (status !== "modified" || !existingFiles.has(filename)) {
4557
+ skippedFiles.push({
4558
+ filename: relativePath,
4559
+ status,
4560
+ reason: status === "modified" ?
4561
+ "File does not exist in bucket (only modified existing files are synced)" :
4562
+ "Structural change in lesson (only modified files are synced)",
4563
+ });
4564
+ continue;
4565
+ }
4566
+ let lessonEntry = syncableLessons.find(l => l.slug === slug);
4567
+ if (!lessonEntry) {
4568
+ lessonEntry = { slug, files: [] };
4569
+ syncableLessons.push(lessonEntry);
4570
+ }
4571
+ lessonEntry.files.push({ filename, status });
4572
+ continue;
4573
+ }
4574
+ if (relativePath.startsWith(".learn/assets/")) {
4575
+ syncableAssets.push({ filename: relativePath, status });
4576
+ continue;
4577
+ }
4578
+ skippedFiles.push({
4579
+ filename: relativePath,
4580
+ status,
4581
+ reason: "Unknown path",
4582
+ });
4583
+ }
4584
+ return res.json({
4585
+ hasChanges: true,
4586
+ currentSHA: currentBranchSHA,
4587
+ lastSyncSHA,
4588
+ syncableChanges: {
4589
+ lessons: syncableLessons,
4590
+ assets: syncableAssets,
4591
+ totalFiles: syncableLessons.reduce((s, l) => s + l.files.length, 0) +
4592
+ syncableAssets.length,
4593
+ },
4594
+ skippedChanges: {
4595
+ files: skippedFiles,
4596
+ totalFiles: skippedFiles.length,
4597
+ },
4598
+ });
4599
+ }));
4600
+ app.post("/actions/github/pull", express.json(), (0, errorHandler_1.asyncHandler)(async (req, res) => {
4601
+ var _a, _b, _c, _d, _e;
4602
+ const { courseSlug, targetSHA, lessons } = req.body;
4603
+ if (!courseSlug || !targetSHA) {
4604
+ throw new errorHandler_1.ValidationError("courseSlug and targetSHA are required in request body");
4605
+ }
4606
+ const { token, isConfigured } = getGithubCredentials();
4607
+ if (!isConfigured || !token) {
4608
+ throw new errorHandler_1.ValidationError("GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env");
4609
+ }
4610
+ const configJson = await getConfigJSON(bucket, courseSlug);
4611
+ const repository = (_b = (_a = configJson.config) === null || _a === void 0 ? void 0 : _a.github) === null || _b === void 0 ? void 0 : _b.repository;
4612
+ const lastSyncSHA = (_d = (_c = configJson.config) === null || _c === void 0 ? void 0 : _c.github) === null || _d === void 0 ? void 0 : _d.lastSyncSHA;
4613
+ if (!repository || !lastSyncSHA) {
4614
+ throw new errorHandler_1.ValidationError("Package has no linked GitHub repository. Create one first.");
4615
+ }
4616
+ const [allExerciseFiles] = await bucket.getFiles({
4617
+ prefix: `courses/${courseSlug}/exercises/`,
4618
+ });
4619
+ const lessonSlugs = new Set();
4620
+ const lessonFilesMap = new Map();
4621
+ for (const file of allExerciseFiles) {
4622
+ const match = file.name.match(new RegExp(`courses/${courseSlug}/exercises/([^/]+)/(.+)$`));
4623
+ if (match) {
4624
+ const [, slug, filePart] = match;
4625
+ lessonSlugs.add(slug);
4626
+ if (!lessonFilesMap.has(slug)) {
4627
+ lessonFilesMap.set(slug, new Set());
4628
+ }
4629
+ lessonFilesMap.get(slug).add(filePart);
4630
+ }
4631
+ }
4632
+ const compareRes = await githubApiFetch(`https://api.github.com/repos/${repository}/compare/${lastSyncSHA}...${targetSHA}`, token);
4633
+ if (compareRes.status === "identical" ||
4634
+ !compareRes.files ||
4635
+ compareRes.files.length === 0) {
4636
+ configJson.config = configJson.config || {};
4637
+ configJson.config.github = Object.assign(Object.assign({}, configJson.config.github), { lastSyncSHA: targetSHA });
4638
+ await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4639
+ return res.json({
4640
+ success: true,
4641
+ syncedLessons: [],
4642
+ syncedAssets: 0,
4643
+ syncedFiles: 0,
4644
+ removedFiles: 0,
4645
+ skippedFiles: [],
4646
+ newSHA: targetSHA,
4647
+ });
4648
+ }
4649
+ const lessonFilter = Array.isArray(lessons) && lessons.length > 0 ?
4650
+ new Set(lessons) :
4651
+ null;
4652
+ const toSync = [];
4653
+ const skippedFiles = [];
4654
+ for (const f of compareRes.files) {
4655
+ const relativePath = f.filename;
4656
+ const status = f.status || "modified";
4657
+ if (isStructuralFile(relativePath)) {
4658
+ skippedFiles.push({
4659
+ filename: relativePath,
4660
+ status,
4661
+ reason: "Structural file",
4662
+ });
4663
+ continue;
4664
+ }
4665
+ const exercisesMatch = relativePath.match(/^exercises\/([^/]+)\/(.+)$/);
4666
+ if (exercisesMatch) {
4667
+ const [, slug, filename] = exercisesMatch;
4668
+ if (lessonFilter && !lessonFilter.has(slug))
4669
+ continue;
4670
+ if (!lessonSlugs.has(slug)) {
4671
+ skippedFiles.push({
4672
+ filename: relativePath,
4673
+ status,
4674
+ reason: "Lesson does not exist in bucket",
4675
+ });
4676
+ continue;
4677
+ }
4678
+ if (status !== "modified" ||
4679
+ !lessonFilesMap.get(slug).has(filename)) {
4680
+ skippedFiles.push({
4681
+ filename: relativePath,
4682
+ status,
4683
+ reason: status === "modified" ?
4684
+ "File does not exist in bucket" :
4685
+ "Structural change in lesson",
4686
+ });
4687
+ continue;
4688
+ }
4689
+ toSync.push({
4690
+ relativePath,
4691
+ status,
4692
+ bucketPath: `courses/${courseSlug}/exercises/${slug}/${filename}`,
4693
+ isAsset: false,
4694
+ });
4695
+ continue;
4696
+ }
4697
+ if (relativePath.startsWith(".learn/assets/")) {
4698
+ const bucketPath = `courses/${courseSlug}/${relativePath}`;
4699
+ const previousPath = f.previous_filename;
4700
+ const previousBucketPath = previousPath ?
4701
+ `courses/${courseSlug}/${previousPath}` :
4702
+ undefined;
4703
+ toSync.push({
4704
+ relativePath,
4705
+ status,
4706
+ bucketPath,
4707
+ isAsset: true,
4708
+ previousBucketPath,
4709
+ });
4710
+ }
4711
+ }
4712
+ const syncedLessonSlugs = new Set();
4713
+ let syncedLessonFilesCount = 0;
4714
+ let syncedAssetsUploaded = 0;
4715
+ let removedCount = 0;
4716
+ for (const item of toSync) {
4717
+ if (item.isAsset && item.status === "removed") {
4718
+ try {
4719
+ const fileRef = bucket.file(item.bucketPath);
4720
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
4721
+ await fileRef.delete();
4722
+ removedCount++;
4723
+ }
4724
+ catch (_f) {
4725
+ // File may not exist, ignore
4726
+ }
4727
+ continue;
4728
+ }
4729
+ if (item.isAsset && item.previousBucketPath) {
4730
+ try {
4731
+ const oldFileRef = bucket.file(item.previousBucketPath);
4732
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
4733
+ await oldFileRef.delete();
4734
+ removedCount++;
4735
+ }
4736
+ catch (_g) {
4737
+ // Old file may not exist, ignore
4738
+ }
4739
+ }
4740
+ if (item.isAsset || item.status === "modified") {
4741
+ let contentRes;
4742
+ try {
4743
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
4744
+ contentRes = await githubApiFetch(`https://api.github.com/repos/${repository}/contents/${encodeURIComponent(item.relativePath)}?ref=${targetSHA}`, token);
4745
+ }
4746
+ catch (error) {
4747
+ const axErr = error;
4748
+ if (((_e = axErr.response) === null || _e === void 0 ? void 0 : _e.status) === 404) {
4749
+ skippedFiles.push({
4750
+ filename: item.relativePath,
4751
+ status: item.status,
4752
+ reason: "File not found in GitHub",
4753
+ });
4754
+ continue;
4755
+ }
4756
+ throw error;
4757
+ }
4758
+ const content = contentRes.content;
4759
+ if (!content) {
4760
+ skippedFiles.push({
4761
+ filename: item.relativePath,
4762
+ status: item.status,
4763
+ reason: "No content in response",
4764
+ });
4765
+ continue;
4766
+ }
4767
+ const buffer = buffer_1.Buffer.from(content, "base64");
4768
+ if (isImageFile(item.relativePath)) {
4769
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
4770
+ await uploadBinaryToBucket(bucket, buffer, item.bucketPath, "application/octet-stream");
4771
+ }
4772
+ else {
4773
+ const text = buffer.toString("utf8");
4774
+ // eslint-disable-next-line no-await-in-loop -- Sequential processing to avoid rate limits
4775
+ await uploadFileToBucket(bucket, text, item.bucketPath);
4776
+ }
4777
+ if (item.isAsset) {
4778
+ syncedAssetsUploaded++;
4779
+ }
4780
+ else {
4781
+ syncedLessonSlugs.add(item.relativePath.replace(/^exercises\/([^/]+)\/.*/, "$1"));
4782
+ syncedLessonFilesCount++;
4783
+ }
4784
+ }
4785
+ }
4786
+ configJson.config = configJson.config || {};
4787
+ configJson.config.github = Object.assign(Object.assign({}, configJson.config.github), { lastSyncSHA: targetSHA });
4788
+ await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4789
+ return res.json({
4790
+ success: true,
4791
+ syncedLessons: [...syncedLessonSlugs],
4792
+ syncedAssets: syncedAssetsUploaded + removedCount,
4793
+ syncedFiles: syncedLessonFilesCount + syncedAssetsUploaded,
4794
+ removedFiles: removedCount,
4795
+ skippedFiles,
4796
+ newSHA: targetSHA,
4797
+ });
4798
+ }));
4799
+ app.post("/actions/github/push", express.json(), (0, errorHandler_1.asyncHandler)(async (req, res) => {
4800
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
4801
+ const { courseSlug } = req.body;
4802
+ if (!courseSlug) {
4803
+ throw new errorHandler_1.ValidationError("courseSlug is required in request body");
4804
+ }
4805
+ const { token, username, isConfigured } = getGithubCredentials();
4806
+ if (!isConfigured || !token || !username) {
4807
+ throw new errorHandler_1.ValidationError("GitHub credentials not configured. Set GITHUB_TOKEN and GITHUB_USERNAME in .env");
4808
+ }
4809
+ const configJson = await getConfigJSON(bucket, courseSlug);
4810
+ const repository = (_b = (_a = configJson.config) === null || _a === void 0 ? void 0 : _a.github) === null || _b === void 0 ? void 0 : _b.repository;
4811
+ const lastSyncSHA = (_d = (_c = configJson.config) === null || _c === void 0 ? void 0 : _c.github) === null || _d === void 0 ? void 0 : _d.lastSyncSHA;
4812
+ const defaultBranch = ((_f = (_e = configJson.config) === null || _e === void 0 ? void 0 : _e.github) === null || _f === void 0 ? void 0 : _f.defaultBranch) || "main";
4813
+ if (!repository || !lastSyncSHA) {
4814
+ throw new errorHandler_1.ValidationError("Package has no linked GitHub repository. Create one first.");
4815
+ }
4816
+ const refRes = await githubApiFetch(`https://api.github.com/repos/${repository}/git/ref/heads/${defaultBranch}`, token);
4817
+ const currentBranchSHA = (_l = (_h = (_g = refRes.object) === null || _g === void 0 ? void 0 : _g.sha) !== null && _h !== void 0 ? _h : (_k = (_j = refRes.ref) === null || _j === void 0 ? void 0 : _j.object) === null || _k === void 0 ? void 0 : _k.sha) !== null && _l !== void 0 ? _l : (_m = refRes.object) === null || _m === void 0 ? void 0 : _m.sha;
4818
+ if (!currentBranchSHA) {
4819
+ throw new errorHandler_1.ValidationError(`Could not get ${defaultBranch} branch SHA from GitHub`);
4820
+ }
4821
+ const [allFiles] = await bucket.getFiles({
4822
+ prefix: `courses/${courseSlug}/`,
4823
+ });
4824
+ const treeEntries = await Promise.all(allFiles.map(async (file) => {
4825
+ const [content] = await file.download();
4826
+ const contentBase64 = content.toString("base64");
4827
+ const blobRes = await githubApiFetch(`https://api.github.com/repos/${repository}/git/blobs`, token, {
4828
+ method: "POST",
4829
+ body: { content: contentBase64, encoding: "base64" },
4830
+ });
4831
+ const relativePath = file.name.replace(`courses/${courseSlug}/`, "");
4832
+ return {
4833
+ path: relativePath,
4834
+ mode: "100644",
4835
+ type: "blob",
4836
+ sha: blobRes.sha,
4837
+ };
4838
+ }));
4839
+ const treeRes = await githubApiFetch(`https://api.github.com/repos/${repository}/git/trees`, token, {
4840
+ method: "POST",
4841
+ body: { tree: treeEntries },
4842
+ });
4843
+ const commitRes = await githubApiFetch(`https://api.github.com/repos/${repository}/git/commits`, token, {
4844
+ method: "POST",
4845
+ body: {
4846
+ message: "Sync from LearnPack bucket",
4847
+ tree: treeRes.sha,
4848
+ parents: [currentBranchSHA],
4849
+ },
4850
+ });
4851
+ await githubApiFetch(`https://api.github.com/repos/${repository}/git/refs/heads/${defaultBranch}`, token, {
4852
+ method: "PATCH",
4853
+ body: { sha: commitRes.sha, force: false },
4854
+ });
4855
+ configJson.config = configJson.config || {};
4856
+ configJson.config.github = Object.assign(Object.assign({}, configJson.config.github), { lastSyncSHA: commitRes.sha });
4857
+ await uploadFileToBucket(bucket, JSON.stringify(configJson), `courses/${courseSlug}/.learn/config.json`);
4858
+ return res.json({
4859
+ success: true,
4860
+ repository,
4861
+ sha: commitRes.sha,
4862
+ totalFiles: allFiles.length,
4863
+ });
4864
+ }));
4070
4865
  app.get("/proxy", async (req, res) => {
4071
4866
  const { url } = req.query;
4072
4867
  if (!url) {