@learnpack/learnpack 5.0.353 → 5.0.355

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.
Files changed (35) hide show
  1. package/lib/commands/serve.js +1 -1
  2. package/lib/scripts/descriptionsS3Backfill.js +142 -22
  3. package/lib/utils/awsCredentials.d.ts +20 -0
  4. package/lib/utils/awsCredentials.js +43 -0
  5. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  6. package/lib/utils/descriptions/backfillEvents.js +107 -0
  7. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +7 -0
  8. package/lib/utils/descriptions/generateCourseDescriptions.js +3 -0
  9. package/lib/utils/descriptions/publishStage.js +14 -3
  10. package/lib/utils/descriptions/resumePublication.js +17 -2
  11. package/lib/utils/descriptions/s3Storage.js +13 -6
  12. package/lib/utils/packageManifest.d.ts +8 -0
  13. package/lib/utils/packageManifest.js +8 -0
  14. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  15. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  16. package/lib/utils/repair/repairStorage.d.ts +68 -0
  17. package/lib/utils/repair/repairStorage.js +89 -0
  18. package/lib/utils/s3/packageManifestBackfill.js +3 -8
  19. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  20. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  21. package/package.json +3 -1
  22. package/src/commands/serve.ts +1 -1
  23. package/src/scripts/README.md +244 -0
  24. package/src/scripts/descriptionsS3Backfill.ts +188 -20
  25. package/src/utils/awsCredentials.ts +57 -0
  26. package/src/utils/descriptions/backfillEvents.ts +152 -0
  27. package/src/utils/descriptions/generateCourseDescriptions.ts +10 -0
  28. package/src/utils/descriptions/publishStage.ts +394 -382
  29. package/src/utils/descriptions/resumePublication.ts +217 -200
  30. package/src/utils/descriptions/s3Storage.ts +214 -206
  31. package/src/utils/packageManifest.ts +8 -1
  32. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  33. package/src/utils/repair/repairStorage.ts +168 -0
  34. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  35. package/src/utils/s3/packageSourcesAudit.ts +311 -0
@@ -0,0 +1,311 @@
1
+ import { ListObjectsV2Command } from "@aws-sdk/client-s3"
2
+ import { AwsClient, withRetry } from "./packageManifestBackfill"
3
+
4
+ /**
5
+ * Inventory of the files a published package needs for its
6
+ * `package-manifest.json` to be generated at full fidelity.
7
+ *
8
+ * This reads **key names only** — one paginated `ListObjectsV2` over the bucket,
9
+ * zero `GetObject`. Cheap enough to run over the whole catalogue on demand, and
10
+ * safe at any time: it never writes.
11
+ *
12
+ * What it answers: which packages would *fail* the manifest backfill (no
13
+ * `config.json`, no exercises), and which ones only produce a *degraded*
14
+ * manifest (no `initialSyllabus.json` → every lesson typed `READ`, ids derived
15
+ * from the folder name, and descriptions permanently `null`, because the
16
+ * descriptions pipeline builds its work list from the syllabus keys and cannot
17
+ * see a course that has none).
18
+ *
19
+ * Companion of `legacyPackageRepair.ts`, which writes what this reports missing.
20
+ */
21
+
22
+ export type PackageSourcesAuditEntry = {
23
+ slug: string;
24
+ /** A published package is a prefix holding an `index.html` at its root. */
25
+ hasIndexHtml: boolean;
26
+ /** Required: without it the manifest backfill reports `failed`. */
27
+ hasConfigJson: boolean;
28
+ configJsonKey: string | null;
29
+ /**
30
+ * Optional *as a key*: `config.json` may carry the same metadata under
31
+ * `config`. Only a `GetObject` could tell, so `false` here means "resolves
32
+ * through the fallback, or not at all" — not "broken".
33
+ */
34
+ hasLearnJson: boolean;
35
+ hasSyllabus: boolean;
36
+ hasSidebar: boolean;
37
+ sidebarKey: string | null;
38
+ hasManifest: boolean;
39
+ /** Folders directly under `exercises/` holding at least one object. */
40
+ exerciseFolders: number;
41
+ /** `README.md` / `README.xx.md` files found in those folders. */
42
+ readmeCount: number;
43
+ /** Missing inputs, named as in the docs, for quick reporting. */
44
+ missing: string[];
45
+ /** True when the manifest backfill cannot produce a manifest at all. */
46
+ blocking: boolean;
47
+ };
48
+
49
+ export type PackageSourcesAuditSummary = {
50
+ missingConfigJson: number;
51
+ missingLearnJson: number;
52
+ missingSyllabus: number;
53
+ missingSidebar: number;
54
+ missingManifest: number;
55
+ noExerciseFolders: number;
56
+ blocking: number;
57
+ };
58
+
59
+ export type PackageSourcesAuditReport = {
60
+ generatedAt: string;
61
+ bucket: string;
62
+ scannedKeys: number;
63
+ totalPackages: number;
64
+ summary: PackageSourcesAuditSummary;
65
+ packages: PackageSourcesAuditEntry[];
66
+ };
67
+
68
+ export type AuditOptions = {
69
+ /** Restrict the scan to a single package (uses a prefixed listing). */
70
+ slug?: string;
71
+ /** Keep prefixes with no `index.html` (drafts, leftovers). Default false. */
72
+ includeUnpublished?: boolean;
73
+ };
74
+
75
+ const README_FILENAME_PATTERN = /^readme(?:\.[a-z]{2})?\.md$/i
76
+
77
+ const ROOT_FILES = new Set([
78
+ "index.html",
79
+ "learn.json",
80
+ "config.json",
81
+ ".learn/config.json",
82
+ ".learn/initialSyllabus.json",
83
+ ".learn/sidebar.json",
84
+ "sidebar.json",
85
+ ".learn/package-manifest.json",
86
+ ])
87
+
88
+ type PackageAccumulator = {
89
+ slug: string;
90
+ hasIndexHtml: boolean;
91
+ configJsonKey: string | null;
92
+ hasLearnJson: boolean;
93
+ hasSyllabus: boolean;
94
+ sidebarKey: string | null;
95
+ hasManifest: boolean;
96
+ exerciseFolders: Set<string>;
97
+ readmeCount: number;
98
+ };
99
+
100
+ function emptyAccumulator(slug: string): PackageAccumulator {
101
+ return {
102
+ slug,
103
+ hasIndexHtml: false,
104
+ configJsonKey: null,
105
+ hasLearnJson: false,
106
+ hasSyllabus: false,
107
+ sidebarKey: null,
108
+ hasManifest: false,
109
+ exerciseFolders: new Set<string>(),
110
+ readmeCount: 0,
111
+ }
112
+ }
113
+
114
+ /** Fold one object key into the accumulator map. */
115
+ function classifyKey(
116
+ entries: Map<string, PackageAccumulator>,
117
+ key: string
118
+ ): void {
119
+ const separatorIndex = key.indexOf("/")
120
+ if (separatorIndex <= 0) {
121
+ // Object at the bucket root: not part of any package.
122
+ return
123
+ }
124
+
125
+ const slug = key.slice(0, separatorIndex)
126
+ const rest = key.slice(separatorIndex + 1)
127
+ if (!rest) {
128
+ return
129
+ }
130
+
131
+ const exerciseParts = rest.startsWith("exercises/") ? rest.split("/") : []
132
+ // `exercises/<slug>/<file>`: shorter paths are the folder marker itself.
133
+ const exerciseSlug =
134
+ exerciseParts.length >= 3 && exerciseParts[1] ? exerciseParts[1] : null
135
+
136
+ if (!ROOT_FILES.has(rest) && !exerciseSlug) {
137
+ return
138
+ }
139
+
140
+ let entry = entries.get(slug)
141
+ if (!entry) {
142
+ entry = emptyAccumulator(slug)
143
+ entries.set(slug, entry)
144
+ }
145
+
146
+ if (exerciseSlug) {
147
+ entry.exerciseFolders.add(exerciseSlug)
148
+ const filename = exerciseParts[exerciseParts.length - 1]
149
+ if (exerciseParts.length === 3 && README_FILENAME_PATTERN.test(filename)) {
150
+ entry.readmeCount += 1
151
+ }
152
+
153
+ return
154
+ }
155
+
156
+ switch (rest) {
157
+ case "index.html":
158
+ entry.hasIndexHtml = true
159
+ break
160
+ case "learn.json":
161
+ entry.hasLearnJson = true
162
+ break
163
+ // The root copy wins over `.learn/config.json`, matching the order
164
+ // `fetchPackageSources` resolves them in. Same for the sidebar, inverted.
165
+ case "config.json":
166
+ entry.configJsonKey = key
167
+ break
168
+ case ".learn/config.json":
169
+ entry.configJsonKey = entry.configJsonKey || key
170
+ break
171
+ case ".learn/sidebar.json":
172
+ entry.sidebarKey = key
173
+ break
174
+ case "sidebar.json":
175
+ entry.sidebarKey = entry.sidebarKey || key
176
+ break
177
+ case ".learn/initialSyllabus.json":
178
+ entry.hasSyllabus = true
179
+ break
180
+ case ".learn/package-manifest.json":
181
+ entry.hasManifest = true
182
+ break
183
+ default:
184
+ break
185
+ }
186
+ }
187
+
188
+ function finalizeEntry(entry: PackageAccumulator): PackageSourcesAuditEntry {
189
+ const missing: string[] = []
190
+
191
+ if (!entry.configJsonKey) {
192
+ missing.push("config.json")
193
+ }
194
+
195
+ if (!entry.hasLearnJson) {
196
+ missing.push("learn.json")
197
+ }
198
+
199
+ if (!entry.hasSyllabus) {
200
+ missing.push("initialSyllabus.json")
201
+ }
202
+
203
+ if (!entry.sidebarKey) {
204
+ missing.push("sidebar.json")
205
+ }
206
+
207
+ if (entry.exerciseFolders.size === 0) {
208
+ missing.push("exercises/")
209
+ }
210
+
211
+ if (!entry.hasManifest) {
212
+ missing.push("package-manifest.json")
213
+ }
214
+
215
+ return {
216
+ slug: entry.slug,
217
+ hasIndexHtml: entry.hasIndexHtml,
218
+ hasConfigJson: Boolean(entry.configJsonKey),
219
+ configJsonKey: entry.configJsonKey,
220
+ hasLearnJson: entry.hasLearnJson,
221
+ hasSyllabus: entry.hasSyllabus,
222
+ hasSidebar: Boolean(entry.sidebarKey),
223
+ sidebarKey: entry.sidebarKey,
224
+ hasManifest: entry.hasManifest,
225
+ exerciseFolders: entry.exerciseFolders.size,
226
+ readmeCount: entry.readmeCount,
227
+ missing,
228
+ // A missing `learn.json` is not blocking on its own: `config.config` can
229
+ // stand in for it, and only a download would tell.
230
+ blocking: !entry.configJsonKey || entry.exerciseFolders.size === 0,
231
+ }
232
+ }
233
+
234
+ function count(
235
+ packages: PackageSourcesAuditEntry[],
236
+ predicate: (entry: PackageSourcesAuditEntry) => boolean
237
+ ): number {
238
+ return packages.filter(entry => predicate(entry)).length
239
+ }
240
+
241
+ export function summarize(
242
+ packages: PackageSourcesAuditEntry[]
243
+ ): PackageSourcesAuditSummary {
244
+ return {
245
+ missingConfigJson: count(packages, entry => !entry.hasConfigJson),
246
+ missingLearnJson: count(packages, entry => !entry.hasLearnJson),
247
+ missingSyllabus: count(packages, entry => !entry.hasSyllabus),
248
+ missingSidebar: count(packages, entry => !entry.hasSidebar),
249
+ missingManifest: count(packages, entry => !entry.hasManifest),
250
+ noExerciseFolders: count(packages, entry => entry.exerciseFolders === 0),
251
+ blocking: count(packages, entry => entry.blocking),
252
+ }
253
+ }
254
+
255
+ /**
256
+ * One paginated pass over the bucket, folding each page into the accumulator.
257
+ *
258
+ * Deliberately not built on `listObjectKeys`: that one materializes every key in
259
+ * the catalogue before the caller sees the first one.
260
+ */
261
+ export async function auditPackageSources(
262
+ s3: AwsClient,
263
+ bucket: string,
264
+ options: AuditOptions = {}
265
+ ): Promise<PackageSourcesAuditReport> {
266
+ const entries = new Map<string, PackageAccumulator>()
267
+ const prefix = options.slug ? `${options.slug}/` : undefined
268
+ let continuationToken: string | undefined
269
+ let scannedKeys = 0
270
+
271
+ do {
272
+ // eslint-disable-next-line no-await-in-loop -- S3 pagination is sequential
273
+ const response = (await withRetry(() =>
274
+ s3.send(
275
+ new ListObjectsV2Command({
276
+ Bucket: bucket,
277
+ Prefix: prefix,
278
+ ContinuationToken: continuationToken,
279
+ })
280
+ )
281
+ )) as {
282
+ Contents?: Array<{ Key?: string }>;
283
+ NextContinuationToken?: string;
284
+ }
285
+
286
+ for (const item of response.Contents || []) {
287
+ if (!item.Key) {
288
+ continue
289
+ }
290
+
291
+ scannedKeys += 1
292
+ classifyKey(entries, item.Key)
293
+ }
294
+
295
+ continuationToken = response.NextContinuationToken
296
+ } while (continuationToken)
297
+
298
+ const packages = [...entries.values()]
299
+ .filter(entry => options.includeUnpublished || entry.hasIndexHtml)
300
+ .map(entry => finalizeEntry(entry))
301
+ .sort((a, b) => a.slug.localeCompare(b.slug))
302
+
303
+ return {
304
+ generatedAt: new Date().toISOString(),
305
+ bucket,
306
+ scannedKeys,
307
+ totalPackages: packages.length,
308
+ summary: summarize(packages),
309
+ packages,
310
+ }
311
+ }