@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.
- package/lib/commands/serve.js +1 -1
- 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/commands/serve.ts +1 -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,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
|
+
}
|