@learnpack/learnpack 5.0.352 → 5.0.354
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands/publish.js +6 -0
- package/lib/commands/serve.js +85 -259
- package/lib/models/creator.d.ts +6 -0
- package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
- package/lib/scripts/descriptionsGcsBackfill.js +141 -0
- package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
- package/lib/scripts/descriptionsS3Backfill.js +277 -0
- package/lib/scripts/descriptionsSweep.d.ts +1 -0
- package/lib/scripts/descriptionsSweep.js +142 -0
- package/lib/utils/api.d.ts +7 -0
- package/lib/utils/api.js +8 -1
- package/lib/utils/awsCredentials.d.ts +20 -0
- package/lib/utils/awsCredentials.js +43 -0
- package/lib/utils/creatorUtilities.js +2 -1
- package/lib/utils/descriptionHash.d.ts +66 -0
- package/lib/utils/descriptionHash.js +173 -0
- package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
- package/lib/utils/descriptions/backfillEvents.js +107 -0
- package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
- package/lib/utils/descriptions/gcsStorage.js +60 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +66 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +176 -0
- package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
- package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
- package/lib/utils/descriptions/publishStage.d.ts +69 -0
- package/lib/utils/descriptions/publishStage.js +245 -0
- package/lib/utils/descriptions/resumePublication.d.ts +36 -0
- package/lib/utils/descriptions/resumePublication.js +128 -0
- package/lib/utils/descriptions/s3Storage.d.ts +30 -0
- package/lib/utils/descriptions/s3Storage.js +141 -0
- package/lib/utils/descriptions/workList.d.ts +75 -0
- package/lib/utils/descriptions/workList.js +177 -0
- package/lib/utils/gcsBucketName.d.ts +10 -0
- package/lib/utils/gcsBucketName.js +19 -0
- package/lib/utils/packageManifest.d.ts +22 -0
- package/lib/utils/packageManifest.js +57 -5
- package/lib/utils/publishEvents.d.ts +66 -0
- package/lib/utils/publishEvents.js +111 -0
- package/lib/utils/publishJournal.d.ts +119 -0
- package/lib/utils/publishJournal.js +275 -0
- package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
- package/lib/utils/repair/legacyPackageRepair.js +492 -0
- package/lib/utils/repair/repairStorage.d.ts +68 -0
- package/lib/utils/repair/repairStorage.js +89 -0
- package/lib/utils/rigoActions.d.ts +44 -0
- package/lib/utils/rigoActions.js +75 -1
- package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
- package/lib/utils/s3/packageManifestBackfill.js +5 -8
- package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
- package/lib/utils/s3/packageSourcesAudit.js +184 -0
- package/lib/utils/syllabusSync.d.ts +71 -0
- package/lib/utils/syllabusSync.js +273 -0
- package/package.json +3 -1
- package/src/commands/publish.ts +7 -0
- package/src/commands/serve.ts +144 -335
- package/src/models/creator.ts +9 -0
- package/src/scripts/README.md +244 -0
- package/src/scripts/descriptionsGcsBackfill.ts +193 -0
- package/src/scripts/descriptionsS3Backfill.ts +376 -0
- package/src/scripts/descriptionsSweep.ts +185 -0
- package/src/ui/_app/app.css +1 -1
- package/src/ui/_app/app.js +142 -140
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/api.ts +9 -0
- package/src/utils/awsCredentials.ts +57 -0
- package/src/utils/creatorUtilities.ts +2 -1
- package/src/utils/descriptionHash.ts +196 -0
- package/src/utils/descriptions/backfillEvents.ts +152 -0
- package/src/utils/descriptions/gcsStorage.ts +67 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
- package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
- package/src/utils/descriptions/publishStage.ts +394 -0
- package/src/utils/descriptions/resumePublication.ts +217 -0
- package/src/utils/descriptions/s3Storage.ts +214 -0
- package/src/utils/descriptions/workList.ts +283 -0
- package/src/utils/gcsBucketName.ts +19 -0
- package/src/utils/packageManifest.ts +62 -5
- package/src/utils/publishEvents.ts +181 -0
- package/src/utils/publishJournal.ts +383 -0
- package/src/utils/repair/legacyPackageRepair.ts +731 -0
- package/src/utils/repair/repairStorage.ts +168 -0
- package/src/utils/rigoActions.ts +130 -0
- package/src/utils/s3/packageManifestBackfill.ts +771 -776
- package/src/utils/s3/packageSourcesAudit.ts +311 -0
- package/src/utils/syllabusSync.ts +390 -0
|
@@ -99,6 +99,50 @@ type TInitialContentGeneratorInputs = {
|
|
|
99
99
|
topic_description?: string;
|
|
100
100
|
};
|
|
101
101
|
export declare const initialContentGenerator: (token: string, inputs: TInitialContentGeneratorInputs, webhookUrl?: string, endpointSlug?: string) => Promise<any>;
|
|
102
|
+
export type TGenerateStepDescriptionsParams = {
|
|
103
|
+
/** README of the step, in `sourceLanguage`. Images are stripped before sending. */
|
|
104
|
+
readmeContent: string;
|
|
105
|
+
sourceLanguage: string;
|
|
106
|
+
/** Every language to produce; may or may not include `sourceLanguage`. */
|
|
107
|
+
outputLanguages: string[];
|
|
108
|
+
lessonTitle: string;
|
|
109
|
+
courseTitle?: string;
|
|
110
|
+
targetWordCount?: number;
|
|
111
|
+
promptVersion?: number;
|
|
112
|
+
};
|
|
113
|
+
export type TGenerateStepDescriptionsResult = {
|
|
114
|
+
/**
|
|
115
|
+
* Descriptions keyed by the `language_code` Rigobot answered with — building
|
|
116
|
+
* this record IS the reassociation, the response order is not contractual.
|
|
117
|
+
* A `null` value is the deliberate fallback for content too thin to describe.
|
|
118
|
+
*/
|
|
119
|
+
descriptionsByLanguage: Record<string, string | null>;
|
|
120
|
+
/** Requested languages absent from the response (a protocol problem, not a
|
|
121
|
+
* "no description" answer: callers should retry these rather than storing a
|
|
122
|
+
* null that would look settled). */
|
|
123
|
+
missingLanguages: string[];
|
|
124
|
+
promptVersion: number;
|
|
125
|
+
/** Seconds the completion took; worth logging to watch the 30s Heroku edge. */
|
|
126
|
+
durationSeconds: number | null;
|
|
127
|
+
completionId: number | null;
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Generate one short description per language for a single step.
|
|
131
|
+
*
|
|
132
|
+
* One request per step: the eye-catching saving is on the *languages* axis (one
|
|
133
|
+
* README in, N descriptions out, ~67% fewer input tokens for a typical course),
|
|
134
|
+
* while batching several steps together would save nothing and would cost cache
|
|
135
|
+
* granularity and partial results.
|
|
136
|
+
*
|
|
137
|
+
* Synchronous by design (`execute_async: false`): the caller owns the write, so
|
|
138
|
+
* every flow — the post-publish stage, the sweep and the backfill — persists
|
|
139
|
+
* its own results instead of relying on a callback. Callers throttle with a
|
|
140
|
+
* concurrency pool.
|
|
141
|
+
*
|
|
142
|
+
* All inputs travel as strings: Rigobot fills the template with plain string
|
|
143
|
+
* replacement and a non-string raises before reaching the LLM.
|
|
144
|
+
*/
|
|
145
|
+
export declare const generateStepDescriptions: (token: string, params: TGenerateStepDescriptionsParams, endpointSlug?: string) => Promise<TGenerateStepDescriptionsResult | null>;
|
|
102
146
|
type TAddInteractivityInputs = {
|
|
103
147
|
components: string;
|
|
104
148
|
prev_lesson: string;
|
package/lib/utils/rigoActions.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.generateStepSlug = exports.generateCodeChallenge = exports.addInteractivity = exports.initialContentGenerator = exports.getLanguageCodes = exports.isPackageAuthor = exports.fillSidebarJSON = exports.generateCourseShortName = exports.isValidRigoToken = exports.translateCourseMetadata = exports.createStructuredPreviewReadme = exports.readmeCreator = exports.createCodingReadme = exports.createCodeFile = exports.interactiveCreation = exports.generateCourseIntroduction = exports.translateExercise = exports.generateImage = exports.hasCreatorPermission = exports.createReadme = void 0;
|
|
3
|
+
exports.generateStepSlug = exports.generateCodeChallenge = exports.addInteractivity = exports.generateStepDescriptions = exports.initialContentGenerator = exports.getLanguageCodes = exports.isPackageAuthor = exports.fillSidebarJSON = exports.generateCourseShortName = exports.isValidRigoToken = exports.translateCourseMetadata = exports.createStructuredPreviewReadme = exports.readmeCreator = exports.createCodingReadme = exports.createCodeFile = exports.interactiveCreation = exports.generateCourseIntroduction = exports.translateExercise = exports.generateImage = exports.hasCreatorPermission = exports.createReadme = void 0;
|
|
4
4
|
exports.downloadImage = downloadImage;
|
|
5
5
|
exports.createPreviewReadme = createPreviewReadme;
|
|
6
6
|
exports.makeReadmeReadable = makeReadmeReadable;
|
|
@@ -10,6 +10,8 @@ const console_1 = require("../utils/console");
|
|
|
10
10
|
const fs = require("fs");
|
|
11
11
|
const path = require("path");
|
|
12
12
|
const api_1 = require("./api");
|
|
13
|
+
const descriptionHash_1 = require("./descriptionHash");
|
|
14
|
+
const packageManifest_1 = require("./packageManifest");
|
|
13
15
|
const createReadme = async (token, inputs, purpose, webhookUrl) => {
|
|
14
16
|
try {
|
|
15
17
|
const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/create-sequencial-readme/`, {
|
|
@@ -304,6 +306,78 @@ const initialContentGenerator = async (token, inputs, webhookUrl, endpointSlug =
|
|
|
304
306
|
}
|
|
305
307
|
};
|
|
306
308
|
exports.initialContentGenerator = initialContentGenerator;
|
|
309
|
+
/**
|
|
310
|
+
* Generate one short description per language for a single step.
|
|
311
|
+
*
|
|
312
|
+
* One request per step: the eye-catching saving is on the *languages* axis (one
|
|
313
|
+
* README in, N descriptions out, ~67% fewer input tokens for a typical course),
|
|
314
|
+
* while batching several steps together would save nothing and would cost cache
|
|
315
|
+
* granularity and partial results.
|
|
316
|
+
*
|
|
317
|
+
* Synchronous by design (`execute_async: false`): the caller owns the write, so
|
|
318
|
+
* every flow — the post-publish stage, the sweep and the backfill — persists
|
|
319
|
+
* its own results instead of relying on a callback. Callers throttle with a
|
|
320
|
+
* concurrency pool.
|
|
321
|
+
*
|
|
322
|
+
* All inputs travel as strings: Rigobot fills the template with plain string
|
|
323
|
+
* replacement and a non-string raises before reaching the LLM.
|
|
324
|
+
*/
|
|
325
|
+
const generateStepDescriptions = async (token, params, endpointSlug = "generate-step-descriptions") => {
|
|
326
|
+
var _a, _b, _c, _d, _e;
|
|
327
|
+
const promptVersion = (_a = params.promptVersion) !== null && _a !== void 0 ? _a : packageManifest_1.DESCRIPTION_PROMPT_VERSION;
|
|
328
|
+
const targetWordCount = (_b = params.targetWordCount) !== null && _b !== void 0 ? _b : packageManifest_1.DESCRIPTION_TARGET_WORD_COUNT;
|
|
329
|
+
const inputs = {
|
|
330
|
+
// Images never help a description and their alt text may be a whole
|
|
331
|
+
// image-generation prompt; markdown structure is kept, it helps the model.
|
|
332
|
+
readme_content: (0, descriptionHash_1.stripMarkdownImages)(params.readmeContent),
|
|
333
|
+
source_language: params.sourceLanguage,
|
|
334
|
+
output_languages: params.outputLanguages.join(","),
|
|
335
|
+
lesson_title: params.lessonTitle,
|
|
336
|
+
course_title: (_c = params.courseTitle) !== null && _c !== void 0 ? _c : "",
|
|
337
|
+
target_word_count: String(targetWordCount),
|
|
338
|
+
prompt_version: String(promptVersion),
|
|
339
|
+
};
|
|
340
|
+
try {
|
|
341
|
+
const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/${endpointSlug}/`, { inputs, execute_async: false }, {
|
|
342
|
+
headers: {
|
|
343
|
+
"Content-Type": "application/json",
|
|
344
|
+
Authorization: "Token " + token.trim(),
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
const data = response.data;
|
|
348
|
+
if (!data || data.status === "ERROR") {
|
|
349
|
+
console.error("Error in generateStepDescriptions:", (data === null || data === void 0 ? void 0 : data.status_text) || "completion returned ERROR");
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
const entries = Array.isArray((_d = data.parsed) === null || _d === void 0 ? void 0 : _d.descriptions) ?
|
|
353
|
+
data.parsed.descriptions :
|
|
354
|
+
[];
|
|
355
|
+
const descriptionsByLanguage = {};
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
const code = entry === null || entry === void 0 ? void 0 : entry.language_code;
|
|
358
|
+
if (typeof code !== "string" || !code) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
descriptionsByLanguage[code] =
|
|
362
|
+
typeof entry.description === "string" ? entry.description : null;
|
|
363
|
+
}
|
|
364
|
+
const echoedVersion = Number.parseInt((_e = data.inputs) === null || _e === void 0 ? void 0 : _e.prompt_version, 10);
|
|
365
|
+
return {
|
|
366
|
+
descriptionsByLanguage,
|
|
367
|
+
missingLanguages: params.outputLanguages.filter(lang => !(lang in descriptionsByLanguage)),
|
|
368
|
+
promptVersion: Number.isFinite(echoedVersion) ?
|
|
369
|
+
echoedVersion :
|
|
370
|
+
promptVersion,
|
|
371
|
+
durationSeconds: typeof data.duration === "number" ? data.duration : null,
|
|
372
|
+
completionId: typeof data.id === "number" ? data.id : null,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
console.error("Error in generateStepDescriptions:", error);
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
exports.generateStepDescriptions = generateStepDescriptions;
|
|
307
381
|
const addInteractivity = async (token, inputs, webhookUrl) => {
|
|
308
382
|
try {
|
|
309
383
|
const response = await axios_1.default.post(`${api_1.RIGOBOT_HOST}/v1/prompting/completion/add-lesson-interactivy/`, {
|
|
@@ -64,6 +64,8 @@ export declare function withRetry<T>(fn: () => Promise<T>, attempts?: number, at
|
|
|
64
64
|
export declare function mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>;
|
|
65
65
|
export declare function listPublishedSlugs(s3: AwsClient, bucket: string): Promise<string[]>;
|
|
66
66
|
export declare function fetchJsonObject<T>(s3: AwsClient, bucket: string, key: string): Promise<T | null>;
|
|
67
|
+
export declare function fetchTextObject(s3: AwsClient, bucket: string, key: string): Promise<string | null>;
|
|
68
|
+
export declare function listObjectKeys(s3: AwsClient, bucket: string, prefix: string): Promise<string[]>;
|
|
67
69
|
export declare function fetchPackageSources(s3: AwsClient, bucket: string, slug: string): Promise<PackageSources>;
|
|
68
70
|
export declare function buildManifestFromS3Sources(slug: string, sources: PackageSources, options: {
|
|
69
71
|
publishedAt: string | null;
|
|
@@ -5,6 +5,8 @@ exports.withRetry = withRetry;
|
|
|
5
5
|
exports.mapWithConcurrency = mapWithConcurrency;
|
|
6
6
|
exports.listPublishedSlugs = listPublishedSlugs;
|
|
7
7
|
exports.fetchJsonObject = fetchJsonObject;
|
|
8
|
+
exports.fetchTextObject = fetchTextObject;
|
|
9
|
+
exports.listObjectKeys = listObjectKeys;
|
|
8
10
|
exports.fetchPackageSources = fetchPackageSources;
|
|
9
11
|
exports.buildManifestFromS3Sources = buildManifestFromS3Sources;
|
|
10
12
|
exports.processPackage = processPackage;
|
|
@@ -14,6 +16,7 @@ exports.getBatchExitCode = getBatchExitCode;
|
|
|
14
16
|
const tslib_1 = require("tslib");
|
|
15
17
|
const client_cloudfront_1 = require("@aws-sdk/client-cloudfront");
|
|
16
18
|
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
19
|
+
const awsCredentials_1 = require("../awsCredentials");
|
|
17
20
|
const packageManifest_1 = require("../packageManifest");
|
|
18
21
|
const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i;
|
|
19
22
|
const INDEX_HTML_PATTERN = /^[^/]+\/index\.html$/;
|
|
@@ -387,18 +390,12 @@ async function runBatch(options, clients) {
|
|
|
387
390
|
const s3 = (clients === null || clients === void 0 ? void 0 : clients.s3) ||
|
|
388
391
|
new client_s3_1.S3Client({
|
|
389
392
|
region: options.region,
|
|
390
|
-
credentials:
|
|
391
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
|
392
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
|
393
|
-
},
|
|
393
|
+
credentials: (0, awsCredentials_1.requireAwsCredentials)(),
|
|
394
394
|
});
|
|
395
395
|
const cf = (clients === null || clients === void 0 ? void 0 : clients.cf) ||
|
|
396
396
|
new client_cloudfront_1.CloudFrontClient({
|
|
397
397
|
region: "us-east-1",
|
|
398
|
-
credentials:
|
|
399
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
|
400
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
|
401
|
-
},
|
|
398
|
+
credentials: (0, awsCredentials_1.requireAwsCredentials)(),
|
|
402
399
|
});
|
|
403
400
|
let slugs = options.slug ?
|
|
404
401
|
[options.slug] :
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { AwsClient } from "./packageManifestBackfill";
|
|
2
|
+
/**
|
|
3
|
+
* Inventory of the files a published package needs for its
|
|
4
|
+
* `package-manifest.json` to be generated at full fidelity.
|
|
5
|
+
*
|
|
6
|
+
* This reads **key names only** — one paginated `ListObjectsV2` over the bucket,
|
|
7
|
+
* zero `GetObject`. Cheap enough to run over the whole catalogue on demand, and
|
|
8
|
+
* safe at any time: it never writes.
|
|
9
|
+
*
|
|
10
|
+
* What it answers: which packages would *fail* the manifest backfill (no
|
|
11
|
+
* `config.json`, no exercises), and which ones only produce a *degraded*
|
|
12
|
+
* manifest (no `initialSyllabus.json` → every lesson typed `READ`, ids derived
|
|
13
|
+
* from the folder name, and descriptions permanently `null`, because the
|
|
14
|
+
* descriptions pipeline builds its work list from the syllabus keys and cannot
|
|
15
|
+
* see a course that has none).
|
|
16
|
+
*
|
|
17
|
+
* Companion of `legacyPackageRepair.ts`, which writes what this reports missing.
|
|
18
|
+
*/
|
|
19
|
+
export type PackageSourcesAuditEntry = {
|
|
20
|
+
slug: string;
|
|
21
|
+
/** A published package is a prefix holding an `index.html` at its root. */
|
|
22
|
+
hasIndexHtml: boolean;
|
|
23
|
+
/** Required: without it the manifest backfill reports `failed`. */
|
|
24
|
+
hasConfigJson: boolean;
|
|
25
|
+
configJsonKey: string | null;
|
|
26
|
+
/**
|
|
27
|
+
* Optional *as a key*: `config.json` may carry the same metadata under
|
|
28
|
+
* `config`. Only a `GetObject` could tell, so `false` here means "resolves
|
|
29
|
+
* through the fallback, or not at all" — not "broken".
|
|
30
|
+
*/
|
|
31
|
+
hasLearnJson: boolean;
|
|
32
|
+
hasSyllabus: boolean;
|
|
33
|
+
hasSidebar: boolean;
|
|
34
|
+
sidebarKey: string | null;
|
|
35
|
+
hasManifest: boolean;
|
|
36
|
+
/** Folders directly under `exercises/` holding at least one object. */
|
|
37
|
+
exerciseFolders: number;
|
|
38
|
+
/** `README.md` / `README.xx.md` files found in those folders. */
|
|
39
|
+
readmeCount: number;
|
|
40
|
+
/** Missing inputs, named as in the docs, for quick reporting. */
|
|
41
|
+
missing: string[];
|
|
42
|
+
/** True when the manifest backfill cannot produce a manifest at all. */
|
|
43
|
+
blocking: boolean;
|
|
44
|
+
};
|
|
45
|
+
export type PackageSourcesAuditSummary = {
|
|
46
|
+
missingConfigJson: number;
|
|
47
|
+
missingLearnJson: number;
|
|
48
|
+
missingSyllabus: number;
|
|
49
|
+
missingSidebar: number;
|
|
50
|
+
missingManifest: number;
|
|
51
|
+
noExerciseFolders: number;
|
|
52
|
+
blocking: number;
|
|
53
|
+
};
|
|
54
|
+
export type PackageSourcesAuditReport = {
|
|
55
|
+
generatedAt: string;
|
|
56
|
+
bucket: string;
|
|
57
|
+
scannedKeys: number;
|
|
58
|
+
totalPackages: number;
|
|
59
|
+
summary: PackageSourcesAuditSummary;
|
|
60
|
+
packages: PackageSourcesAuditEntry[];
|
|
61
|
+
};
|
|
62
|
+
export type AuditOptions = {
|
|
63
|
+
/** Restrict the scan to a single package (uses a prefixed listing). */
|
|
64
|
+
slug?: string;
|
|
65
|
+
/** Keep prefixes with no `index.html` (drafts, leftovers). Default false. */
|
|
66
|
+
includeUnpublished?: boolean;
|
|
67
|
+
};
|
|
68
|
+
export declare function summarize(packages: PackageSourcesAuditEntry[]): PackageSourcesAuditSummary;
|
|
69
|
+
/**
|
|
70
|
+
* One paginated pass over the bucket, folding each page into the accumulator.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately not built on `listObjectKeys`: that one materializes every key in
|
|
73
|
+
* the catalogue before the caller sees the first one.
|
|
74
|
+
*/
|
|
75
|
+
export declare function auditPackageSources(s3: AwsClient, bucket: string, options?: AuditOptions): Promise<PackageSourcesAuditReport>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.summarize = summarize;
|
|
4
|
+
exports.auditPackageSources = auditPackageSources;
|
|
5
|
+
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
6
|
+
const packageManifestBackfill_1 = require("./packageManifestBackfill");
|
|
7
|
+
const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i;
|
|
8
|
+
const ROOT_FILES = new Set([
|
|
9
|
+
"index.html",
|
|
10
|
+
"learn.json",
|
|
11
|
+
"config.json",
|
|
12
|
+
".learn/config.json",
|
|
13
|
+
".learn/initialSyllabus.json",
|
|
14
|
+
".learn/sidebar.json",
|
|
15
|
+
"sidebar.json",
|
|
16
|
+
".learn/package-manifest.json",
|
|
17
|
+
]);
|
|
18
|
+
function emptyAccumulator(slug) {
|
|
19
|
+
return {
|
|
20
|
+
slug,
|
|
21
|
+
hasIndexHtml: false,
|
|
22
|
+
configJsonKey: null,
|
|
23
|
+
hasLearnJson: false,
|
|
24
|
+
hasSyllabus: false,
|
|
25
|
+
sidebarKey: null,
|
|
26
|
+
hasManifest: false,
|
|
27
|
+
exerciseFolders: new Set(),
|
|
28
|
+
readmeCount: 0,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Fold one object key into the accumulator map. */
|
|
32
|
+
function classifyKey(entries, key) {
|
|
33
|
+
const separatorIndex = key.indexOf("/");
|
|
34
|
+
if (separatorIndex <= 0) {
|
|
35
|
+
// Object at the bucket root: not part of any package.
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const slug = key.slice(0, separatorIndex);
|
|
39
|
+
const rest = key.slice(separatorIndex + 1);
|
|
40
|
+
if (!rest) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const exerciseParts = rest.startsWith("exercises/") ? rest.split("/") : [];
|
|
44
|
+
// `exercises/<slug>/<file>`: shorter paths are the folder marker itself.
|
|
45
|
+
const exerciseSlug = exerciseParts.length >= 3 && exerciseParts[1] ? exerciseParts[1] : null;
|
|
46
|
+
if (!ROOT_FILES.has(rest) && !exerciseSlug) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
let entry = entries.get(slug);
|
|
50
|
+
if (!entry) {
|
|
51
|
+
entry = emptyAccumulator(slug);
|
|
52
|
+
entries.set(slug, entry);
|
|
53
|
+
}
|
|
54
|
+
if (exerciseSlug) {
|
|
55
|
+
entry.exerciseFolders.add(exerciseSlug);
|
|
56
|
+
const filename = exerciseParts[exerciseParts.length - 1];
|
|
57
|
+
if (exerciseParts.length === 3 && README_FILENAME_PATTERN.test(filename)) {
|
|
58
|
+
entry.readmeCount += 1;
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
switch (rest) {
|
|
63
|
+
case "index.html":
|
|
64
|
+
entry.hasIndexHtml = true;
|
|
65
|
+
break;
|
|
66
|
+
case "learn.json":
|
|
67
|
+
entry.hasLearnJson = true;
|
|
68
|
+
break;
|
|
69
|
+
// The root copy wins over `.learn/config.json`, matching the order
|
|
70
|
+
// `fetchPackageSources` resolves them in. Same for the sidebar, inverted.
|
|
71
|
+
case "config.json":
|
|
72
|
+
entry.configJsonKey = key;
|
|
73
|
+
break;
|
|
74
|
+
case ".learn/config.json":
|
|
75
|
+
entry.configJsonKey = entry.configJsonKey || key;
|
|
76
|
+
break;
|
|
77
|
+
case ".learn/sidebar.json":
|
|
78
|
+
entry.sidebarKey = key;
|
|
79
|
+
break;
|
|
80
|
+
case "sidebar.json":
|
|
81
|
+
entry.sidebarKey = entry.sidebarKey || key;
|
|
82
|
+
break;
|
|
83
|
+
case ".learn/initialSyllabus.json":
|
|
84
|
+
entry.hasSyllabus = true;
|
|
85
|
+
break;
|
|
86
|
+
case ".learn/package-manifest.json":
|
|
87
|
+
entry.hasManifest = true;
|
|
88
|
+
break;
|
|
89
|
+
default:
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function finalizeEntry(entry) {
|
|
94
|
+
const missing = [];
|
|
95
|
+
if (!entry.configJsonKey) {
|
|
96
|
+
missing.push("config.json");
|
|
97
|
+
}
|
|
98
|
+
if (!entry.hasLearnJson) {
|
|
99
|
+
missing.push("learn.json");
|
|
100
|
+
}
|
|
101
|
+
if (!entry.hasSyllabus) {
|
|
102
|
+
missing.push("initialSyllabus.json");
|
|
103
|
+
}
|
|
104
|
+
if (!entry.sidebarKey) {
|
|
105
|
+
missing.push("sidebar.json");
|
|
106
|
+
}
|
|
107
|
+
if (entry.exerciseFolders.size === 0) {
|
|
108
|
+
missing.push("exercises/");
|
|
109
|
+
}
|
|
110
|
+
if (!entry.hasManifest) {
|
|
111
|
+
missing.push("package-manifest.json");
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
slug: entry.slug,
|
|
115
|
+
hasIndexHtml: entry.hasIndexHtml,
|
|
116
|
+
hasConfigJson: Boolean(entry.configJsonKey),
|
|
117
|
+
configJsonKey: entry.configJsonKey,
|
|
118
|
+
hasLearnJson: entry.hasLearnJson,
|
|
119
|
+
hasSyllabus: entry.hasSyllabus,
|
|
120
|
+
hasSidebar: Boolean(entry.sidebarKey),
|
|
121
|
+
sidebarKey: entry.sidebarKey,
|
|
122
|
+
hasManifest: entry.hasManifest,
|
|
123
|
+
exerciseFolders: entry.exerciseFolders.size,
|
|
124
|
+
readmeCount: entry.readmeCount,
|
|
125
|
+
missing,
|
|
126
|
+
// A missing `learn.json` is not blocking on its own: `config.config` can
|
|
127
|
+
// stand in for it, and only a download would tell.
|
|
128
|
+
blocking: !entry.configJsonKey || entry.exerciseFolders.size === 0,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function count(packages, predicate) {
|
|
132
|
+
return packages.filter(entry => predicate(entry)).length;
|
|
133
|
+
}
|
|
134
|
+
function summarize(packages) {
|
|
135
|
+
return {
|
|
136
|
+
missingConfigJson: count(packages, entry => !entry.hasConfigJson),
|
|
137
|
+
missingLearnJson: count(packages, entry => !entry.hasLearnJson),
|
|
138
|
+
missingSyllabus: count(packages, entry => !entry.hasSyllabus),
|
|
139
|
+
missingSidebar: count(packages, entry => !entry.hasSidebar),
|
|
140
|
+
missingManifest: count(packages, entry => !entry.hasManifest),
|
|
141
|
+
noExerciseFolders: count(packages, entry => entry.exerciseFolders === 0),
|
|
142
|
+
blocking: count(packages, entry => entry.blocking),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* One paginated pass over the bucket, folding each page into the accumulator.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately not built on `listObjectKeys`: that one materializes every key in
|
|
149
|
+
* the catalogue before the caller sees the first one.
|
|
150
|
+
*/
|
|
151
|
+
async function auditPackageSources(s3, bucket, options = {}) {
|
|
152
|
+
const entries = new Map();
|
|
153
|
+
const prefix = options.slug ? `${options.slug}/` : undefined;
|
|
154
|
+
let continuationToken;
|
|
155
|
+
let scannedKeys = 0;
|
|
156
|
+
do {
|
|
157
|
+
// eslint-disable-next-line no-await-in-loop -- S3 pagination is sequential
|
|
158
|
+
const response = (await (0, packageManifestBackfill_1.withRetry)(() => s3.send(new client_s3_1.ListObjectsV2Command({
|
|
159
|
+
Bucket: bucket,
|
|
160
|
+
Prefix: prefix,
|
|
161
|
+
ContinuationToken: continuationToken,
|
|
162
|
+
}))));
|
|
163
|
+
for (const item of response.Contents || []) {
|
|
164
|
+
if (!item.Key) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
scannedKeys += 1;
|
|
168
|
+
classifyKey(entries, item.Key);
|
|
169
|
+
}
|
|
170
|
+
continuationToken = response.NextContinuationToken;
|
|
171
|
+
} while (continuationToken);
|
|
172
|
+
const packages = [...entries.values()]
|
|
173
|
+
.filter(entry => options.includeUnpublished || entry.hasIndexHtml)
|
|
174
|
+
.map(entry => finalizeEntry(entry))
|
|
175
|
+
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
176
|
+
return {
|
|
177
|
+
generatedAt: new Date().toISOString(),
|
|
178
|
+
bucket,
|
|
179
|
+
scannedKeys,
|
|
180
|
+
totalPackages: packages.length,
|
|
181
|
+
summary: summarize(packages),
|
|
182
|
+
packages,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Bucket } from "@google-cloud/storage";
|
|
2
|
+
import { Syllabus } from "../models/creator";
|
|
3
|
+
/**
|
|
4
|
+
* Provisional syllabus ↔ bucket reconciliation.
|
|
5
|
+
*
|
|
6
|
+
* The `initialSyllabus.json` occasionally drifts from what actually lives in the
|
|
7
|
+
* bucket (lessons/translations present as files but missing from the syllabus,
|
|
8
|
+
* or vice versa). Because step descriptions are stored per (step, lang) in the
|
|
9
|
+
* syllabus, that drift leaves missing slots and causes skipped/spurious
|
|
10
|
+
* generations. This reconciles the two.
|
|
11
|
+
*
|
|
12
|
+
* Extracted from the inline `/actions/synchronize-syllabus` handler so it can be
|
|
13
|
+
* reused by the sweep/backfill, and generalized over a minimal storage adapter
|
|
14
|
+
* so it works for both GCS and S3.
|
|
15
|
+
*
|
|
16
|
+
* NOTE: this is a temporary patch until the root cause of the drift is fixed.
|
|
17
|
+
* Track its removal via the `DESCRIPTIONS_RECONCILE_SYLLABUS` flag on callers.
|
|
18
|
+
*/
|
|
19
|
+
export interface SyllabusSyncStorage {
|
|
20
|
+
/**
|
|
21
|
+
* Map of `folderSlug -> fileCount` for every folder directly under
|
|
22
|
+
* `exercises/`. A single authoritative listing: if it throws, callers must
|
|
23
|
+
* abort any destructive action (never delete on an errored/unknown listing).
|
|
24
|
+
*/
|
|
25
|
+
listExerciseFolderFileCounts(courseSlug: string): Promise<Map<string, number>>;
|
|
26
|
+
/** README language codes (lowercased) present per exercise folder slug. */
|
|
27
|
+
translationLangsBySlug(courseSlug: string): Promise<Map<string, string[]>>;
|
|
28
|
+
}
|
|
29
|
+
export type SyllabusSyncOptions = {
|
|
30
|
+
/**
|
|
31
|
+
* When false (default, "additive" mode) only non-destructive passes run:
|
|
32
|
+
* add-missing, repair-translations, unstick-status. Removal/dedupe are
|
|
33
|
+
* skipped. When true (manual dev button / root-cause runs) removals apply.
|
|
34
|
+
*/
|
|
35
|
+
prune?: boolean;
|
|
36
|
+
};
|
|
37
|
+
export type SyllabusSyncCounters = {
|
|
38
|
+
totalLessons: number;
|
|
39
|
+
keptLessons: number;
|
|
40
|
+
removedLessons: number;
|
|
41
|
+
duplicatesResolved: number;
|
|
42
|
+
addedLessons: number;
|
|
43
|
+
fixedLessons: number;
|
|
44
|
+
repairedTranslationsInLessons: number;
|
|
45
|
+
repairedTranslationEntries: number;
|
|
46
|
+
};
|
|
47
|
+
type LessonRef = {
|
|
48
|
+
id: string;
|
|
49
|
+
title: string;
|
|
50
|
+
slug: string;
|
|
51
|
+
};
|
|
52
|
+
type LessonRefWithCount = LessonRef & {
|
|
53
|
+
fileCount: number;
|
|
54
|
+
};
|
|
55
|
+
export type SyllabusSyncResult = {
|
|
56
|
+
syllabus: Syllabus;
|
|
57
|
+
changed: boolean;
|
|
58
|
+
counters: SyllabusSyncCounters;
|
|
59
|
+
details: {
|
|
60
|
+
removed: LessonRef[];
|
|
61
|
+
duplicates: LessonRefWithCount[];
|
|
62
|
+
kept: Array<LessonRef & {
|
|
63
|
+
fileCount?: number;
|
|
64
|
+
}>;
|
|
65
|
+
added: LessonRef[];
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
export declare function synchronizeSyllabusWithBucket(storage: SyllabusSyncStorage, courseSlug: string, syllabus: Syllabus, options?: SyllabusSyncOptions): Promise<SyllabusSyncResult>;
|
|
69
|
+
/** GCS-backed storage adapter (uses the bucket listing + buildConfig). */
|
|
70
|
+
export declare function createGcsSyllabusSyncStorage(bucket: Bucket): SyllabusSyncStorage;
|
|
71
|
+
export {};
|