@learnpack/learnpack 5.0.343 → 5.0.344

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