@learnpack/learnpack 5.0.342 → 5.0.344
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands/serve.js +895 -21
- package/lib/creatorDist/assets/{index-BhqDgBS9.js → index-DnthLsvb.js} +4731 -4730
- package/lib/creatorDist/index.html +1 -1
- package/lib/utils/api.d.ts +1 -1
- package/lib/utils/api.js +1 -1
- package/package.json +1 -1
- package/src/commands/serve.ts +1312 -19
- package/src/creator/src/components/FileUploader.tsx +1 -2
- package/src/creator/src/utils/rigo.ts +1 -2
- package/src/creatorDist/assets/{index-BhqDgBS9.js → index-DnthLsvb.js} +4731 -4730
- package/src/creatorDist/index.html +1 -1
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +2103 -2101
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +2 -1
package/lib/commands/serve.js
CHANGED
|
@@ -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();
|
|
@@ -1091,16 +1259,18 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1091
1259
|
for (const fileObj of files) {
|
|
1092
1260
|
try {
|
|
1093
1261
|
console.log(`📄 Processing file: ${fileObj.name}`);
|
|
1262
|
+
// Flatten path: use only the file name so files are stored directly in the exercise folder
|
|
1263
|
+
const flatFileName = path.basename(fileObj.name);
|
|
1094
1264
|
// Save the main file with content
|
|
1095
1265
|
if (fileObj.name && fileObj.content) {
|
|
1096
|
-
const filePath = `${exerciseDir}/${
|
|
1266
|
+
const filePath = `${exerciseDir}/${flatFileName}`;
|
|
1097
1267
|
// eslint-disable-next-line no-await-in-loop
|
|
1098
1268
|
await uploadFileToBucket(bucket, fileObj.content, filePath);
|
|
1099
1269
|
console.log(`✅ Saved file: ${filePath}`);
|
|
1100
1270
|
}
|
|
1101
1271
|
// Save the solution file if it exists
|
|
1102
1272
|
if (fileObj.name && fileObj.solution) {
|
|
1103
|
-
const nameParts =
|
|
1273
|
+
const nameParts = flatFileName.split(".");
|
|
1104
1274
|
if (nameParts.length > 1) {
|
|
1105
1275
|
const extension = nameParts.pop();
|
|
1106
1276
|
const baseName = nameParts.join(".");
|
|
@@ -1112,7 +1282,7 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1112
1282
|
}
|
|
1113
1283
|
else {
|
|
1114
1284
|
// If no extension, just add .solution.hide
|
|
1115
|
-
const solutionFileName = `${
|
|
1285
|
+
const solutionFileName = `${flatFileName}.solution.hide`;
|
|
1116
1286
|
const solutionFilePath = `${exerciseDir}/${solutionFileName}`;
|
|
1117
1287
|
// eslint-disable-next-line no-await-in-loop
|
|
1118
1288
|
await uploadFileToBucket(bucket, fileObj.solution, solutionFilePath);
|
|
@@ -1967,7 +2137,8 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
1967
2137
|
.json({ error: "Course slug and rigo token required" });
|
|
1968
2138
|
}
|
|
1969
2139
|
try {
|
|
1970
|
-
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);
|
|
1971
2142
|
res.set("X-Creator-Web", "true");
|
|
1972
2143
|
res.set("Access-Control-Expose-Headers", "X-Creator-Web");
|
|
1973
2144
|
await uploadFileToBucket(bucket, JSON.stringify({ config, exercises }), `courses/${courseSlug}/.learn/config.json`);
|
|
@@ -2086,21 +2257,17 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
2086
2257
|
if (!fileName || !slug) {
|
|
2087
2258
|
throw new errorHandler_1.ValidationError("File name and exercise slug are required");
|
|
2088
2259
|
}
|
|
2089
|
-
//
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
}
|
|
2101
|
-
else {
|
|
2102
|
-
throw new errorHandler_1.ValidationError("Invalid request body format");
|
|
2103
|
-
}
|
|
2260
|
+
// Require JSON body with content field; return 400 with clear message otherwise
|
|
2261
|
+
if (!req.body ||
|
|
2262
|
+
typeof req.body !== "object" ||
|
|
2263
|
+
req.body.content === undefined) {
|
|
2264
|
+
throw new errorHandler_1.ValidationError('Request body must be JSON with a \'content\' field. Use Content-Type: application/json and body: { "content": "..." }.');
|
|
2265
|
+
}
|
|
2266
|
+
const fileContent = String(req.body.content);
|
|
2267
|
+
const contentToSaveInHistory = req.body.historyContent !== null &&
|
|
2268
|
+
req.body.historyContent !== undefined ?
|
|
2269
|
+
String(req.body.historyContent) :
|
|
2270
|
+
undefined;
|
|
2104
2271
|
try {
|
|
2105
2272
|
let newVersion = versionId;
|
|
2106
2273
|
let created = false;
|
|
@@ -3071,6 +3238,8 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3071
3238
|
const keptLessons = [];
|
|
3072
3239
|
const duplicatesRemoved = [];
|
|
3073
3240
|
const addedLessons = [];
|
|
3241
|
+
let repairedTranslationsInLessons = 0;
|
|
3242
|
+
let repairedTranslationEntries = 0;
|
|
3074
3243
|
console.log(`📋 Checking ${syllabus.lessons.length} lessons in syllabus...`);
|
|
3075
3244
|
// First pass: Check each lesson to see if it exists in the bucket and count files.
|
|
3076
3245
|
// We try two possible folder names because they can differ by source:
|
|
@@ -3213,9 +3382,70 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3213
3382
|
return String(a.id).localeCompare(String(b.id));
|
|
3214
3383
|
});
|
|
3215
3384
|
}
|
|
3216
|
-
|
|
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) {
|
|
3217
3447
|
await saveSyllabus(courseSlug, syllabus, bucket);
|
|
3218
|
-
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).`);
|
|
3219
3449
|
}
|
|
3220
3450
|
else {
|
|
3221
3451
|
console.log(`✅ Syllabus is already in sync. No changes.`);
|
|
@@ -3228,6 +3458,8 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3228
3458
|
removedLessons: removedLessons.length,
|
|
3229
3459
|
duplicatesResolved: duplicatesRemoved.length,
|
|
3230
3460
|
addedLessons: addedLessons.length,
|
|
3461
|
+
repairedTranslationsInLessons,
|
|
3462
|
+
repairedTranslationEntries,
|
|
3231
3463
|
removed: removedLessons,
|
|
3232
3464
|
duplicates: duplicatesRemoved,
|
|
3233
3465
|
kept: keptLessons,
|
|
@@ -3242,6 +3474,110 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3242
3474
|
});
|
|
3243
3475
|
}
|
|
3244
3476
|
});
|
|
3477
|
+
app.post("/actions/synchronize-lesson-files", async (req, res) => {
|
|
3478
|
+
var _a, _b;
|
|
3479
|
+
const courseSlug = req.query.slug;
|
|
3480
|
+
const { lessonSlug } = req.body || {};
|
|
3481
|
+
if (!courseSlug || !lessonSlug) {
|
|
3482
|
+
return res.status(400).json({
|
|
3483
|
+
error: "Course slug (query) and lessonSlug (body) are required",
|
|
3484
|
+
});
|
|
3485
|
+
}
|
|
3486
|
+
try {
|
|
3487
|
+
const configJson = await getConfigJSON(bucket, courseSlug);
|
|
3488
|
+
const exercises = configJson.exercises;
|
|
3489
|
+
const exercise = exercises.find(ex => ex.slug === lessonSlug);
|
|
3490
|
+
if (!exercise) {
|
|
3491
|
+
return res.status(404).json({
|
|
3492
|
+
error: `Lesson not found: ${lessonSlug}`,
|
|
3493
|
+
});
|
|
3494
|
+
}
|
|
3495
|
+
const initialCount = (_b = (_a = exercise.files) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0;
|
|
3496
|
+
if (initialCount === 0) {
|
|
3497
|
+
return res.json({
|
|
3498
|
+
status: "SUCCESS",
|
|
3499
|
+
removedCount: 0,
|
|
3500
|
+
keptCount: 0,
|
|
3501
|
+
movedCount: 0,
|
|
3502
|
+
});
|
|
3503
|
+
}
|
|
3504
|
+
const lessonPrefix = `courses/${courseSlug}/exercises/${lessonSlug}/`;
|
|
3505
|
+
const [bucketObjects] = await bucket.getFiles({ prefix: lessonPrefix });
|
|
3506
|
+
const existenceChecks = await Promise.all(exercise.files.map(async (file) => {
|
|
3507
|
+
const flatPath = `${lessonPrefix}${file.name}`;
|
|
3508
|
+
const [existsAtFlat] = await bucket.file(flatPath).exists();
|
|
3509
|
+
return { file, flatPath, existsAtFlat };
|
|
3510
|
+
}));
|
|
3511
|
+
const kept = [];
|
|
3512
|
+
const notFoundInBucket = [];
|
|
3513
|
+
const toMove = [];
|
|
3514
|
+
for (const { file, flatPath, existsAtFlat } of existenceChecks) {
|
|
3515
|
+
if (existsAtFlat) {
|
|
3516
|
+
kept.push(file);
|
|
3517
|
+
continue;
|
|
3518
|
+
}
|
|
3519
|
+
const subdirObject = bucketObjects.find(obj => {
|
|
3520
|
+
if (!obj.name.endsWith(`/${file.name}`))
|
|
3521
|
+
return false;
|
|
3522
|
+
const afterLesson = obj.name.slice(lessonPrefix.length);
|
|
3523
|
+
const segments = afterLesson.split("/");
|
|
3524
|
+
return (segments.length === 2 &&
|
|
3525
|
+
segments[0] !== "" &&
|
|
3526
|
+
segments[0] !== ".learn");
|
|
3527
|
+
});
|
|
3528
|
+
if (subdirObject) {
|
|
3529
|
+
toMove.push({ file, subdirPath: subdirObject.name, flatPath });
|
|
3530
|
+
}
|
|
3531
|
+
else {
|
|
3532
|
+
notFoundInBucket.push(file.name);
|
|
3533
|
+
}
|
|
3534
|
+
}
|
|
3535
|
+
let movedCount = 0;
|
|
3536
|
+
if (toMove.length > 0) {
|
|
3537
|
+
await Promise.all(toMove.map(async ({ file, subdirPath, flatPath }) => {
|
|
3538
|
+
const srcFile = bucket.file(subdirPath);
|
|
3539
|
+
const destFile = bucket.file(flatPath);
|
|
3540
|
+
await srcFile.copy(destFile);
|
|
3541
|
+
await srcFile.delete();
|
|
3542
|
+
}));
|
|
3543
|
+
for (const { file } of toMove) {
|
|
3544
|
+
kept.push(file);
|
|
3545
|
+
}
|
|
3546
|
+
movedCount = toMove.length;
|
|
3547
|
+
const subdirName = toMove[0].subdirPath
|
|
3548
|
+
.slice(lessonPrefix.length)
|
|
3549
|
+
.split("/")[0];
|
|
3550
|
+
const subdirPrefix = `${lessonPrefix}${subdirName}/`;
|
|
3551
|
+
const [remainingInSubdir] = await bucket.getFiles({
|
|
3552
|
+
prefix: subdirPrefix,
|
|
3553
|
+
});
|
|
3554
|
+
if (remainingInSubdir.length > 0) {
|
|
3555
|
+
await Promise.all(remainingInSubdir.map(f => f.delete()));
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
const removedCount = initialCount - kept.length;
|
|
3559
|
+
exercise.files = kept;
|
|
3560
|
+
if (removedCount > 0) {
|
|
3561
|
+
await uploadFileToBucket(bucket, JSON.stringify({ config: configJson.config, exercises }), `courses/${courseSlug}/.learn/config.json`);
|
|
3562
|
+
}
|
|
3563
|
+
if (movedCount > 0 || removedCount > 0) {
|
|
3564
|
+
console.log(`[sync-lesson-files] ${lessonSlug}: ${movedCount} moved from subdir, ${removedCount} removed from config`);
|
|
3565
|
+
}
|
|
3566
|
+
res.json({
|
|
3567
|
+
status: "SUCCESS",
|
|
3568
|
+
removedCount,
|
|
3569
|
+
keptCount: kept.length,
|
|
3570
|
+
movedCount,
|
|
3571
|
+
});
|
|
3572
|
+
}
|
|
3573
|
+
catch (error) {
|
|
3574
|
+
console.error("❌ Error synchronizing lesson files:", error);
|
|
3575
|
+
res.status(500).json({
|
|
3576
|
+
error: "Error synchronizing lesson files",
|
|
3577
|
+
details: error.message,
|
|
3578
|
+
});
|
|
3579
|
+
}
|
|
3580
|
+
});
|
|
3245
3581
|
app.get("/translations/sidebar", async (req, res) => {
|
|
3246
3582
|
const { slug } = req.query;
|
|
3247
3583
|
const rigoToken = req.header("x-rigo-token");
|
|
@@ -3965,6 +4301,544 @@ class ServeCommand extends SessionCommand_1.default {
|
|
|
3965
4301
|
});
|
|
3966
4302
|
}
|
|
3967
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
|
+
}));
|
|
3968
4842
|
app.get("/proxy", async (req, res) => {
|
|
3969
4843
|
const { url } = req.query;
|
|
3970
4844
|
if (!url) {
|