@learnpack/learnpack 5.0.353 → 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/scripts/descriptionsS3Backfill.js +142 -22
- package/lib/utils/awsCredentials.d.ts +20 -0
- package/lib/utils/awsCredentials.js +43 -0
- package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
- package/lib/utils/descriptions/backfillEvents.js +107 -0
- package/lib/utils/descriptions/generateCourseDescriptions.d.ts +7 -0
- package/lib/utils/descriptions/generateCourseDescriptions.js +3 -0
- package/lib/utils/descriptions/publishStage.js +14 -3
- package/lib/utils/descriptions/resumePublication.js +17 -2
- package/lib/utils/descriptions/s3Storage.js +13 -6
- package/lib/utils/packageManifest.d.ts +8 -0
- package/lib/utils/packageManifest.js +8 -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/s3/packageManifestBackfill.js +3 -8
- package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
- package/lib/utils/s3/packageSourcesAudit.js +184 -0
- package/package.json +3 -1
- package/src/scripts/README.md +244 -0
- package/src/scripts/descriptionsS3Backfill.ts +188 -20
- package/src/utils/awsCredentials.ts +57 -0
- package/src/utils/descriptions/backfillEvents.ts +152 -0
- package/src/utils/descriptions/generateCourseDescriptions.ts +10 -0
- package/src/utils/descriptions/publishStage.ts +394 -382
- package/src/utils/descriptions/resumePublication.ts +217 -200
- package/src/utils/descriptions/s3Storage.ts +214 -206
- package/src/utils/packageManifest.ts +8 -1
- package/src/utils/repair/legacyPackageRepair.ts +731 -0
- package/src/utils/repair/repairStorage.ts +168 -0
- package/src/utils/s3/packageManifestBackfill.ts +771 -776
- package/src/utils/s3/packageSourcesAudit.ts +311 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.gcsLayout = exports.s3Layout = exports.ALL_REPAIR_TARGETS = void 0;
|
|
4
|
+
exports.createS3RepairStorage = createS3RepairStorage;
|
|
5
|
+
exports.createGcsRepairStorage = createGcsRepairStorage;
|
|
6
|
+
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
7
|
+
const packageManifestBackfill_1 = require("../s3/packageManifestBackfill");
|
|
8
|
+
exports.ALL_REPAIR_TARGETS = ["s3", "gcs"];
|
|
9
|
+
exports.s3Layout = {
|
|
10
|
+
learnJsonKey: slug => `${slug}/learn.json`,
|
|
11
|
+
configKeys: slug => [`${slug}/config.json`, `${slug}/.learn/config.json`],
|
|
12
|
+
syllabusKey: slug => `${slug}/.learn/initialSyllabus.json`,
|
|
13
|
+
// 25 of the published packages keep their sidebar at the root. It has to be
|
|
14
|
+
// resolved before writing: `fetchPackageSources` prefers the `.learn/` copy,
|
|
15
|
+
// so a synthesized one would shadow the published titles.
|
|
16
|
+
sidebarKeys: slug => [
|
|
17
|
+
`${slug}/.learn/sidebar.json`,
|
|
18
|
+
`${slug}/sidebar.json`,
|
|
19
|
+
],
|
|
20
|
+
exercisesPrefix: slug => `${slug}/exercises/`,
|
|
21
|
+
exercisesFromConfig: true,
|
|
22
|
+
};
|
|
23
|
+
exports.gcsLayout = {
|
|
24
|
+
learnJsonKey: slug => `courses/${slug}/learn.json`,
|
|
25
|
+
configKeys: slug => [
|
|
26
|
+
`courses/${slug}/.learn/config.json`,
|
|
27
|
+
`courses/${slug}/config.json`,
|
|
28
|
+
],
|
|
29
|
+
syllabusKey: slug => `courses/${slug}/.learn/initialSyllabus.json`,
|
|
30
|
+
sidebarKeys: slug => [`courses/${slug}/.learn/sidebar.json`],
|
|
31
|
+
exercisesPrefix: slug => `courses/${slug}/exercises/`,
|
|
32
|
+
exercisesFromConfig: false,
|
|
33
|
+
};
|
|
34
|
+
function createS3RepairStorage(s3, bucket) {
|
|
35
|
+
return {
|
|
36
|
+
target: "s3",
|
|
37
|
+
layout: exports.s3Layout,
|
|
38
|
+
readJson: key => (0, packageManifestBackfill_1.fetchJsonObject)(s3, bucket, key),
|
|
39
|
+
readText: key => (0, packageManifestBackfill_1.fetchTextObject)(s3, bucket, key),
|
|
40
|
+
listKeys: prefix => (0, packageManifestBackfill_1.listObjectKeys)(s3, bucket, prefix),
|
|
41
|
+
async writeJson(key, value) {
|
|
42
|
+
await (0, packageManifestBackfill_1.withRetry)(() => s3.send(new client_s3_1.PutObjectCommand({
|
|
43
|
+
Bucket: bucket,
|
|
44
|
+
Key: key,
|
|
45
|
+
Body: JSON.stringify(value, null, 2),
|
|
46
|
+
ContentType: "application/json",
|
|
47
|
+
})));
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function createGcsRepairStorage(bucket) {
|
|
52
|
+
const download = async (key) => {
|
|
53
|
+
try {
|
|
54
|
+
const [buf] = await bucket.file(key).download();
|
|
55
|
+
return buf.toString();
|
|
56
|
+
}
|
|
57
|
+
catch (_a) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
target: "gcs",
|
|
63
|
+
layout: exports.gcsLayout,
|
|
64
|
+
async readJson(key) {
|
|
65
|
+
const content = await download(key);
|
|
66
|
+
if (content === null) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(content);
|
|
71
|
+
}
|
|
72
|
+
catch (_a) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
readText: download,
|
|
77
|
+
async listKeys(prefix) {
|
|
78
|
+
const [files] = await bucket.getFiles({ prefix });
|
|
79
|
+
return files.map(file => file.name);
|
|
80
|
+
},
|
|
81
|
+
async writeJson(key, value) {
|
|
82
|
+
await bucket
|
|
83
|
+
.file(key)
|
|
84
|
+
.save(Buffer.from(JSON.stringify(value, null, 2), "utf8"), {
|
|
85
|
+
contentType: "application/json",
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -16,6 +16,7 @@ exports.getBatchExitCode = getBatchExitCode;
|
|
|
16
16
|
const tslib_1 = require("tslib");
|
|
17
17
|
const client_cloudfront_1 = require("@aws-sdk/client-cloudfront");
|
|
18
18
|
const client_s3_1 = require("@aws-sdk/client-s3");
|
|
19
|
+
const awsCredentials_1 = require("../awsCredentials");
|
|
19
20
|
const packageManifest_1 = require("../packageManifest");
|
|
20
21
|
const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i;
|
|
21
22
|
const INDEX_HTML_PATTERN = /^[^/]+\/index\.html$/;
|
|
@@ -389,18 +390,12 @@ async function runBatch(options, clients) {
|
|
|
389
390
|
const s3 = (clients === null || clients === void 0 ? void 0 : clients.s3) ||
|
|
390
391
|
new client_s3_1.S3Client({
|
|
391
392
|
region: options.region,
|
|
392
|
-
credentials:
|
|
393
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
|
394
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
|
395
|
-
},
|
|
393
|
+
credentials: (0, awsCredentials_1.requireAwsCredentials)(),
|
|
396
394
|
});
|
|
397
395
|
const cf = (clients === null || clients === void 0 ? void 0 : clients.cf) ||
|
|
398
396
|
new client_cloudfront_1.CloudFrontClient({
|
|
399
397
|
region: "us-east-1",
|
|
400
|
-
credentials:
|
|
401
|
-
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
|
|
402
|
-
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
|
|
403
|
-
},
|
|
398
|
+
credentials: (0, awsCredentials_1.requireAwsCredentials)(),
|
|
404
399
|
});
|
|
405
400
|
let slugs = options.slug ?
|
|
406
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@learnpack/learnpack",
|
|
3
3
|
"description": "Seamlessly build, sell and/or take interactive & auto-graded tutorials, start learning now or build a new tutorial to your audience.",
|
|
4
|
-
"version": "5.0.
|
|
4
|
+
"version": "5.0.354",
|
|
5
5
|
"author": "Alejandro Sanchez @alesanchezr",
|
|
6
6
|
"contributors": [
|
|
7
7
|
{
|
|
@@ -163,7 +163,9 @@
|
|
|
163
163
|
]
|
|
164
164
|
},
|
|
165
165
|
"scripts": {
|
|
166
|
+
"audit:package-sources": "ts-node --project ./tsconfig.json ./scripts/audit-package-sources.ts",
|
|
166
167
|
"backfill:package-manifest": "ts-node --project ./tsconfig.json ./scripts/backfill-package-manifest-s3.ts",
|
|
168
|
+
"repair:legacy-packages": "ts-node --project ./tsconfig.json ./scripts/repair-legacy-packages.ts",
|
|
167
169
|
"copy-assets": "npx cpy src/creatorDist/**/* lib/creatorDist --parents --verbose && npx cpy src/utils/templates/**/* lib/utils/templates --parents --verbose && npx cpy src/lua/**/* lib/lua --parents --verbose",
|
|
168
170
|
"tsc": "tsc -b",
|
|
169
171
|
"postpack": "rm -f oclif.manifest.json && eslint . --ext .ts --config .eslintrc",
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Descriptions scripts (`src/scripts/`)
|
|
2
|
+
|
|
3
|
+
Ops scripts for the **step descriptions** of published courses. They are dev/ops
|
|
4
|
+
entry points, not oclif commands: they never appear under `learnpack <something>`
|
|
5
|
+
and are run with `node` against the compiled output.
|
|
6
|
+
|
|
7
|
+
| Script | What it does | Calls the LLM |
|
|
8
|
+
| ------------------------- | ----------------------------------------------------------------------- | ------------- |
|
|
9
|
+
| `descriptionsS3Backfill` | Generates descriptions for **published** courses (S3) | Yes |
|
|
10
|
+
| `descriptionsGcsBackfill` | Copies existing descriptions from S3 into the **draft** (GCS) | No |
|
|
11
|
+
| `descriptionsSweep` | Finishes publications that stalled mid-flight; runs on Heroku Scheduler | Yes |
|
|
12
|
+
|
|
13
|
+
All three delegate the per-course work to the same service,
|
|
14
|
+
[`generateCourseDescriptions`](../utils/descriptions/generateCourseDescriptions.ts),
|
|
15
|
+
which is also what the post-publish stage uses. They differ only in **which**
|
|
16
|
+
courses they run it on and **what else** they do around it.
|
|
17
|
+
|
|
18
|
+
## Build and run
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm run tsc
|
|
22
|
+
node lib/scripts/<script>.js [flags]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
They run against `lib/`, not `src/`, so recompile after editing. There is no
|
|
26
|
+
`--help`: the flags are documented here and in each script's header.
|
|
27
|
+
|
|
28
|
+
## Environment
|
|
29
|
+
|
|
30
|
+
Copy from [`.env.example`](../../.env.example) into `learnpack-cli/.env`. These
|
|
31
|
+
scripts read `process.env` directly — they do **not** load the `.env` themselves,
|
|
32
|
+
so export it (`set -a; . ./.env; set +a`) or pass the variables inline.
|
|
33
|
+
|
|
34
|
+
| Variable | Needed by |
|
|
35
|
+
| ------------------------------------------ | ------------------------------------------------------------------------------- |
|
|
36
|
+
| `RIGOBOT_SYSTEM_TOKEN` | S3 backfill, sweep (generation) |
|
|
37
|
+
| `BREATHECODE_SYSTEM_TOKEN` | `--emit-events`, sweep (pending events) |
|
|
38
|
+
| `AWS_ACCESS_KEY_ID` / `_SECRET_ACCESS_KEY` | anything reading the published bucket |
|
|
39
|
+
| `AWS_REGION` | optional, default `us-east-1` |
|
|
40
|
+
| `S3_PACKAGES_BUCKET` | optional in the backfills (default `learnpack-paquetes`), required by the sweep |
|
|
41
|
+
| `CLOUDFRONT_DISTRIBUTION_ID` | optional; without it manifests are re-projected but not invalidated |
|
|
42
|
+
| `GCP_CREDENTIALS_JSON` | GCS backfill, sweep, `--mirror-draft` |
|
|
43
|
+
| `GCP_BUCKET_NAME` | same; no default on purpose |
|
|
44
|
+
|
|
45
|
+
Descriptions are always generated with the Rigobot **service** account, never
|
|
46
|
+
with a user token: they are platform enrichment, not something a creator is
|
|
47
|
+
billed for.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Mode 1 — targeted run (a few slugs, on demand)
|
|
52
|
+
|
|
53
|
+
The everyday case: a course was published before descriptions existed, or one
|
|
54
|
+
needs remediation. Three flags make the S3 backfill reproduce per course
|
|
55
|
+
everything a publication does, in one process:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
node lib/scripts/descriptionsS3Backfill.js --slug my-course \
|
|
59
|
+
--reproject-manifest --mirror-draft --emit-events --concurrency 2
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
| Flag | Without it |
|
|
63
|
+
| ---------------------- | --------------------------------------------------------------------------------------- |
|
|
64
|
+
| `--reproject-manifest` | `package-manifest.json` keeps the old descriptions until a later pass |
|
|
65
|
+
| `--mirror-draft` | the draft has no fingerprints, so the **next publication regenerates the whole course** |
|
|
66
|
+
| `--emit-events` | breathecode is never told the manifest changed |
|
|
67
|
+
|
|
68
|
+
All three are off by default, so the catalogue-wide run below is unaffected.
|
|
69
|
+
|
|
70
|
+
Run it once per slug. Do a `--dry-run` first if you want to see the scope —
|
|
71
|
+
it reports what it would generate and mirror without writing anything.
|
|
72
|
+
|
|
73
|
+
**During high-traffic hours**, lower `--concurrency` (default 5 completions in
|
|
74
|
+
flight per course). A single completion takes ~15s.
|
|
75
|
+
|
|
76
|
+
Notes:
|
|
77
|
+
|
|
78
|
+
- `--emit-events` **requires** `--reproject-manifest`. The event reports a
|
|
79
|
+
manifest update, so emitting it without projecting would state something
|
|
80
|
+
untrue. It is required rather than implied on purpose: writing to S3 and the
|
|
81
|
+
CDN as a side effect of another flag is the kind of surprise a backfill should
|
|
82
|
+
not have.
|
|
83
|
+
- `--emit-events` is rejected together with `--dry-run`: there is no dry run of
|
|
84
|
+
an event breathecode already received.
|
|
85
|
+
- `--mirror-draft` is independent of the other two — it touches the draft, not
|
|
86
|
+
the manifest.
|
|
87
|
+
- Prefer `--slug` over `--limit 4` when you care about _which_ courses are
|
|
88
|
+
touched: `--limit` counts **processed** courses (already-settled ones are
|
|
89
|
+
skipped without counting) and walks the catalogue by recency.
|
|
90
|
+
|
|
91
|
+
### What each course does, in order
|
|
92
|
+
|
|
93
|
+
1. Generate the missing descriptions and write them into the published
|
|
94
|
+
`initialSyllabus.json` (S3).
|
|
95
|
+
2. `--reproject-manifest`: rebuild `package-manifest.json` from that syllabus
|
|
96
|
+
(the same `processPackage` the manifest backfill runs) and invalidate its
|
|
97
|
+
CloudFront path.
|
|
98
|
+
3. `--mirror-draft`: copy the descriptions back into the GCS draft, guarded by
|
|
99
|
+
the README content hash — anything the teacher edited since publishing is
|
|
100
|
+
counted as `missed` and waits for the next publication.
|
|
101
|
+
4. `--emit-events`: send `package_manifest_updated` to breathecode.
|
|
102
|
+
|
|
103
|
+
A failure in step 3 does not change the event status: the published package and
|
|
104
|
+
its manifest are already correct, only the draft lags behind. A failure in step 2
|
|
105
|
+
**does** — see below.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Mode 2 — catalogue backfill (fases 0a / 0b)
|
|
110
|
+
|
|
111
|
+
The one-off sweep over the ~400 published courses and ~800 drafts. Here the
|
|
112
|
+
defaults are the right ones: manifests are cheaper to project in a single later
|
|
113
|
+
pass than course by course, and the draft is seeded by copy rather than by
|
|
114
|
+
re-generating.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# Fase 0a — descriptions for the published catalogue, 50 courses per run.
|
|
118
|
+
# Re-run until it stops reporting processed courses.
|
|
119
|
+
node lib/scripts/descriptionsS3Backfill.js --limit 50
|
|
120
|
+
|
|
121
|
+
# Then project every manifest in one pass (cheaper than per course).
|
|
122
|
+
npm run backfill:package-manifest
|
|
123
|
+
|
|
124
|
+
# Fase 0b — seed the drafts by copying from S3. Never calls the LLM.
|
|
125
|
+
node lib/scripts/descriptionsGcsBackfill.js --limit 200
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Order matters: 0b reads the **published** syllabus, so 0a must have written it
|
|
129
|
+
first. Whatever 0b cannot reuse (drafts edited since their last publication) is
|
|
130
|
+
left for the next publication of that course, or for a single-slug 0a run.
|
|
131
|
+
|
|
132
|
+
No events are emitted in this mode. If breathecode needs to be told about the
|
|
133
|
+
catalogue, that is a separate decision — adding `--emit-events` to a 400-course
|
|
134
|
+
run would deliver 400 events.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Flag reference
|
|
139
|
+
|
|
140
|
+
### `descriptionsS3Backfill`
|
|
141
|
+
|
|
142
|
+
| Flag | Default | Description |
|
|
143
|
+
| ---------------------- | -------------------- | ----------------------------------------------------------------- |
|
|
144
|
+
| `--slug <slug>` | — | single course (remediation / on-demand) |
|
|
145
|
+
| `--limit <n>` | 50 | courses **processed** per run, ordered by syllabus recency |
|
|
146
|
+
| `--s3-bucket <name>` | `learnpack-paquetes` | published bucket |
|
|
147
|
+
| `--gcs-bucket <name>` | `GCP_BUCKET_NAME` | draft bucket, for `--mirror-draft` |
|
|
148
|
+
| `--region <region>` | `us-east-1` | AWS region |
|
|
149
|
+
| `--concurrency <n>` | 5 | completions in flight per course |
|
|
150
|
+
| `--target-words <n>` | 25 | words per description |
|
|
151
|
+
| `--dry-run` | off | preview; no writes, no completions |
|
|
152
|
+
| `--force` | off | regenerate courses already settled at the current prompt version |
|
|
153
|
+
| `--no-reconcile` | off | skip the additive syllabus reconciliation |
|
|
154
|
+
| `--reproject-manifest` | off | rebuild + invalidate `package-manifest.json` per course |
|
|
155
|
+
| `--mirror-draft` | off | copy the descriptions into the GCS draft |
|
|
156
|
+
| `--emit-events` | off | send `package_manifest_updated` (requires `--reproject-manifest`) |
|
|
157
|
+
|
|
158
|
+
### `descriptionsGcsBackfill`
|
|
159
|
+
|
|
160
|
+
| Flag | Default | Description |
|
|
161
|
+
| --------------------- | -------------------- | -------------------------------- |
|
|
162
|
+
| `--slug <slug>` | — | single course |
|
|
163
|
+
| `--limit <n>` | 50 | courses per run |
|
|
164
|
+
| `--s3-bucket <name>` | `learnpack-paquetes` | source (published) bucket |
|
|
165
|
+
| `--gcs-bucket <name>` | `GCP_BUCKET_NAME` | target (draft) bucket |
|
|
166
|
+
| `--region <region>` | `us-east-1` | AWS region |
|
|
167
|
+
| `--dry-run` | off | preview without writing |
|
|
168
|
+
| `--no-reconcile` | off | skip the syllabus reconciliation |
|
|
169
|
+
|
|
170
|
+
### `descriptionsSweep`
|
|
171
|
+
|
|
172
|
+
No flags — it is a scheduled job, configured by env:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
node lib/scripts/descriptionsSweep.js
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Two sources of work:
|
|
179
|
+
|
|
180
|
+
1. **Publish journals** (`publish-journal/` in GCS), the primary one. Every
|
|
181
|
+
publication that stalled is resumed: missing descriptions generated, draft
|
|
182
|
+
mirrored, and the owed `package_manifest_updated` emitted. Journals are
|
|
183
|
+
dropped once complete and abandoned after a few attempts.
|
|
184
|
+
2. **A deep scan** of published courses (`DESCRIPTIONS_SWEEP_FULL=1`), for
|
|
185
|
+
packages that never got a journal at all. Expensive — it reads every course —
|
|
186
|
+
and off by default. It re-projects manifests but does **not** emit events.
|
|
187
|
+
|
|
188
|
+
Tuning: `DESCRIPTIONS_SWEEP_LIMIT` (courses per deep scan, default 25),
|
|
189
|
+
`DESCRIPTION_STALE_AFTER_MS` (how long a publication may look stalled before it
|
|
190
|
+
is resumed, default 15 min).
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## The `package_manifest_updated` event
|
|
195
|
+
|
|
196
|
+
Emitted by `--emit-events`, and by the sweep for the publications it finishes.
|
|
197
|
+
Built in [`publishEvents.ts`](../utils/publishEvents.ts), sent to the breathecode
|
|
198
|
+
telemetry endpoint with `BREATHECODE_SYSTEM_TOKEN`.
|
|
199
|
+
|
|
200
|
+
Breathecode processes it **independently of `package_published`**, so a backfill
|
|
201
|
+
event with no preceding publication is fine.
|
|
202
|
+
|
|
203
|
+
**`publish_id` in a backfill** is `backfill-{runId}-{slug}`:
|
|
204
|
+
|
|
205
|
+
- the `backfill-` prefix makes the origin readable in the webhook log and
|
|
206
|
+
discriminable in code (`isBackfillPublishId`); real publish ids are bare
|
|
207
|
+
uuids, so the two spaces cannot collide;
|
|
208
|
+
- `{slug}` keeps it unique per event;
|
|
209
|
+
- `{runId}` is one uuid per **invocation**, shared by every course it touches, so
|
|
210
|
+
"every event from the run I fired at 15:40" is a single substring query. It is
|
|
211
|
+
printed at start and in the closing summary.
|
|
212
|
+
|
|
213
|
+
Note that running four separate `--slug` commands produces four run ids. Use
|
|
214
|
+
`--limit` if you want one id for the batch — at the cost of not choosing the
|
|
215
|
+
slugs.
|
|
216
|
+
|
|
217
|
+
**Status is tied to the real outcome**, not to the script finishing:
|
|
218
|
+
|
|
219
|
+
| Outcome | `status` |
|
|
220
|
+
| ---------------------------------------- | --------- |
|
|
221
|
+
| Descriptions written, manifest projected | `success` |
|
|
222
|
+
| Generation failed outright | `failed` |
|
|
223
|
+
| Manifest projection failed | `failed` |
|
|
224
|
+
| Course unchanged (`skipped`) | no event |
|
|
225
|
+
| Course threw | no event |
|
|
226
|
+
|
|
227
|
+
A failed manifest projection is not a failed run — the syllabus, which is the
|
|
228
|
+
source of truth, is already saved — but this event reports the manifest, so it
|
|
229
|
+
cannot claim success over one that was never rewritten. That is what
|
|
230
|
+
`GenerateCourseDescriptionsResult.manifestProjected` exists for.
|
|
231
|
+
|
|
232
|
+
The last row is deliberate: unlike a publication, a backfill never _announced_ an
|
|
233
|
+
event, so it owes none. The course is simply re-run.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Related
|
|
238
|
+
|
|
239
|
+
- [`../utils/descriptions/`](../utils/descriptions/) — the shared service, the
|
|
240
|
+
mirror, the post-publish stage and the resume logic
|
|
241
|
+
- [`../../scripts/README.md`](../../scripts/README.md) — the package-manifest S3
|
|
242
|
+
backfill (`npm run backfill:package-manifest`)
|
|
243
|
+
- [`../utils/publishJournal.ts`](../utils/publishJournal.ts) — why the sweep can
|
|
244
|
+
know an event is owed
|