@learnpack/learnpack 5.0.351 → 5.0.353

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 (70) hide show
  1. package/lib/commands/publish.js +6 -0
  2. package/lib/commands/serve.js +85 -259
  3. package/lib/models/creator.d.ts +6 -0
  4. package/lib/scripts/descriptionsGcsBackfill.d.ts +1 -0
  5. package/lib/scripts/descriptionsGcsBackfill.js +141 -0
  6. package/lib/scripts/descriptionsS3Backfill.d.ts +1 -0
  7. package/lib/scripts/descriptionsS3Backfill.js +157 -0
  8. package/lib/scripts/descriptionsSweep.d.ts +1 -0
  9. package/lib/scripts/descriptionsSweep.js +142 -0
  10. package/lib/utils/api.d.ts +7 -0
  11. package/lib/utils/api.js +8 -1
  12. package/lib/utils/creatorUtilities.js +2 -1
  13. package/lib/utils/descriptionHash.d.ts +66 -0
  14. package/lib/utils/descriptionHash.js +173 -0
  15. package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
  16. package/lib/utils/descriptions/gcsStorage.js +60 -0
  17. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +59 -0
  18. package/lib/utils/descriptions/generateCourseDescriptions.js +173 -0
  19. package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
  20. package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
  21. package/lib/utils/descriptions/publishStage.d.ts +69 -0
  22. package/lib/utils/descriptions/publishStage.js +234 -0
  23. package/lib/utils/descriptions/resumePublication.d.ts +36 -0
  24. package/lib/utils/descriptions/resumePublication.js +113 -0
  25. package/lib/utils/descriptions/s3Storage.d.ts +30 -0
  26. package/lib/utils/descriptions/s3Storage.js +134 -0
  27. package/lib/utils/descriptions/workList.d.ts +75 -0
  28. package/lib/utils/descriptions/workList.js +177 -0
  29. package/lib/utils/gcsBucketName.d.ts +10 -0
  30. package/lib/utils/gcsBucketName.js +19 -0
  31. package/lib/utils/packageManifest.d.ts +18 -0
  32. package/lib/utils/packageManifest.js +81 -7
  33. package/lib/utils/publishEvents.d.ts +66 -0
  34. package/lib/utils/publishEvents.js +111 -0
  35. package/lib/utils/publishJournal.d.ts +119 -0
  36. package/lib/utils/publishJournal.js +275 -0
  37. package/lib/utils/rigoActions.d.ts +44 -0
  38. package/lib/utils/rigoActions.js +75 -1
  39. package/lib/utils/s3/packageManifestBackfill.d.ts +84 -0
  40. package/lib/utils/s3/packageManifestBackfill.js +487 -0
  41. package/lib/utils/syllabusSync.d.ts +71 -0
  42. package/lib/utils/syllabusSync.js +273 -0
  43. package/package.json +4 -1
  44. package/src/commands/publish.ts +7 -0
  45. package/src/commands/serve.ts +144 -335
  46. package/src/models/creator.ts +9 -0
  47. package/src/scripts/descriptionsGcsBackfill.ts +193 -0
  48. package/src/scripts/descriptionsS3Backfill.ts +208 -0
  49. package/src/scripts/descriptionsSweep.ts +185 -0
  50. package/src/ui/_app/app.css +1 -1
  51. package/src/ui/_app/app.js +143 -141
  52. package/src/ui/app.tar.gz +0 -0
  53. package/src/utils/api.ts +9 -0
  54. package/src/utils/creatorUtilities.ts +2 -1
  55. package/src/utils/descriptionHash.ts +196 -0
  56. package/src/utils/descriptions/gcsStorage.ts +67 -0
  57. package/src/utils/descriptions/generateCourseDescriptions.ts +301 -0
  58. package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
  59. package/src/utils/descriptions/publishStage.ts +382 -0
  60. package/src/utils/descriptions/resumePublication.ts +200 -0
  61. package/src/utils/descriptions/s3Storage.ts +206 -0
  62. package/src/utils/descriptions/workList.ts +283 -0
  63. package/src/utils/export/README.md +0 -2
  64. package/src/utils/gcsBucketName.ts +19 -0
  65. package/src/utils/packageManifest.ts +101 -10
  66. package/src/utils/publishEvents.ts +181 -0
  67. package/src/utils/publishJournal.ts +383 -0
  68. package/src/utils/rigoActions.ts +130 -0
  69. package/src/utils/s3/packageManifestBackfill.ts +776 -0
  70. package/src/utils/syllabusSync.ts +390 -0
@@ -13,6 +13,14 @@ const frontMatter = require("front-matter")
13
13
  export const PACKAGE_MANIFEST_FILENAME = "package-manifest.json"
14
14
  export const PACKAGE_MANIFEST_REL_PATH = ".learn/package-manifest.json"
15
15
  export const SCHEMA_VERSION = 1
16
+ // Version of the Rigobot prompt/model used to generate step descriptions. It
17
+ // travels inside the completion inputs, so bumping it both busts Rigobot's
18
+ // cache and marks every description stored at a lower version for regeneration.
19
+ // Keep in sync with the prompt body provisioned in Rigobot.
20
+ export const DESCRIPTION_PROMPT_VERSION = 1
21
+
22
+ // Target length, in words, of a generated step description.
23
+ export const DESCRIPTION_TARGET_WORD_COUNT = 25
16
24
 
17
25
  export type LessonType = "READ" | "CODE" | "QUIZ";
18
26
 
@@ -22,6 +30,7 @@ export type PackageManifestLesson = {
22
30
  position: number;
23
31
  type: LessonType;
24
32
  title: Record<string, string>;
33
+ description: Record<string, string | null>;
25
34
  video: Record<string, string | null>;
26
35
  };
27
36
 
@@ -165,6 +174,24 @@ function resolveLessonTitle(
165
174
  return titles
166
175
  }
167
176
 
177
+ function resolveLessonDescriptions(
178
+ exercise: Exercise,
179
+ syllabusLesson: Lesson | undefined
180
+ ): Record<string, string | null> {
181
+ const description: Record<string, string | null> = {}
182
+
183
+ // Descriptions are a projection: the source of truth lives in the syllabus
184
+ // lesson (translations[lang].description), populated by the sweep/backfill.
185
+ // When absent we emit null (the frontend hides the area) rather than a
186
+ // low-quality placeholder.
187
+ for (const lang of Object.keys(exercise.translations)) {
188
+ description[lang] =
189
+ syllabusLesson?.translations?.[lang]?.description ?? null
190
+ }
191
+
192
+ return description
193
+ }
194
+
168
195
  function resolveLessonVideos(
169
196
  exercise: Exercise,
170
197
  readmeFrontmatters: Record<string, Record<string, ReadmeFrontmatter>>
@@ -226,6 +253,7 @@ export function buildPackageManifestFromSources(
226
253
  courseLang,
227
254
  readmeFrontmatters[exercise.slug] ?? {}
228
255
  ),
256
+ description: resolveLessonDescriptions(exercise, syllabusLesson),
229
257
  video: resolveLessonVideos(exercise, readmeFrontmatters),
230
258
  }
231
259
  })
@@ -263,6 +291,77 @@ async function downloadJsonFile<T>(
263
291
  }
264
292
  }
265
293
 
294
+ export function parseReadmeContent(content: string): ReadmeFrontmatter {
295
+ const parsed = frontMatter(content)
296
+ const attributes = parsed.attributes || {}
297
+ const h1 = extractReadmeH1(parsed.body || "")
298
+ const title = h1 ? normalizeReadmeTitle(h1) : null
299
+ return {
300
+ ...(attributes.tutorial ? { tutorial: attributes.tutorial } : {}),
301
+ ...(attributes.intro ? { intro: attributes.intro } : {}),
302
+ ...(title ? { title } : {}),
303
+ }
304
+ }
305
+
306
+ export function comparePackageManifests(
307
+ existing: PackageManifest | null,
308
+ next: PackageManifest
309
+ ): "created" | "updated" | "skipped" {
310
+ if (existing === null) {
311
+ return "created"
312
+ }
313
+
314
+ const { generatedAt: _existingGeneratedAt, ...existingRest } = existing
315
+ const { generatedAt: _nextGeneratedAt, ...nextRest } = next
316
+
317
+ if (JSON.stringify(existingRest) === JSON.stringify(nextRest)) {
318
+ return "skipped"
319
+ }
320
+
321
+ return "updated"
322
+ }
323
+
324
+ export function serializePackageManifest(manifest: PackageManifest): string {
325
+ return JSON.stringify(manifest, null, 2)
326
+ }
327
+
328
+ /** The manifest as it travels inside a publication event. */
329
+ export type PackageManifestEventView = Omit<PackageManifest, "generatedAt">;
330
+
331
+ /**
332
+ * Project a manifest into the shape sent to breathecode.
333
+ *
334
+ * Today this is nearly the identity — only `generatedAt`, an internal detail of
335
+ * the projection, is dropped. It exists as an explicit pick so that fields
336
+ * added to the manifest later (hashes, generation flags) do not silently become
337
+ * part of an external contract: joining the event payload has to be a decision
338
+ * visible in a diff.
339
+ */
340
+ export function serializePackageManifestForEvent(
341
+ manifest: PackageManifest
342
+ ): PackageManifestEventView {
343
+ return {
344
+ schemaVersion: manifest.schemaVersion,
345
+ publishedAt: manifest.publishedAt,
346
+ slug: manifest.slug,
347
+ title: manifest.title,
348
+ description: manifest.description,
349
+ preview: manifest.preview,
350
+ technologies: manifest.technologies,
351
+ difficulty: manifest.difficulty,
352
+ duration: manifest.duration,
353
+ lessons: manifest.lessons.map(lesson => ({
354
+ id: lesson.id,
355
+ slug: lesson.slug,
356
+ position: lesson.position,
357
+ type: lesson.type,
358
+ title: lesson.title,
359
+ description: lesson.description,
360
+ video: lesson.video,
361
+ })),
362
+ }
363
+ }
364
+
266
365
  async function readReadmeFrontmatter(
267
366
  bucket: Bucket,
268
367
  exerciseSlug: string,
@@ -275,18 +374,10 @@ async function readReadmeFrontmatter(
275
374
  }> {
276
375
  try {
277
376
  const [buf] = await bucket.file(readmePath).download()
278
- const parsed = frontMatter(buf.toString())
279
- const attributes = parsed.attributes || {}
280
- const h1 = extractReadmeH1(parsed.body || "")
281
- const title = h1 ? normalizeReadmeTitle(h1) : null
282
377
  return {
283
378
  exerciseSlug,
284
379
  lang,
285
- frontmatter: {
286
- tutorial: attributes.tutorial,
287
- intro: attributes.intro,
288
- ...(title ? { title } : {}),
289
- },
380
+ frontmatter: parseReadmeContent(buf.toString()),
290
381
  }
291
382
  } catch (error) {
292
383
  console.warn(
@@ -328,7 +419,7 @@ function writeManifestJson(
328
419
  manifest: PackageManifest,
329
420
  localDir?: string
330
421
  ): string {
331
- const json = JSON.stringify(manifest, null, 2)
422
+ const json = serializePackageManifest(manifest)
332
423
 
333
424
  if (localDir) {
334
425
  const learnDir = path.join(localDir, ".learn")
@@ -0,0 +1,181 @@
1
+ import axios from "axios"
2
+ import { BREATHECODE_EVENTS_URL, RIGOBOT_HOST } from "./api"
3
+ import {
4
+ PackageManifest,
5
+ PackageManifestEventView,
6
+ serializePackageManifestForEvent,
7
+ } from "./packageManifest"
8
+
9
+ /**
10
+ * Publication events sent to breathecode.
11
+ *
12
+ * They are emitted from here, and not from Rigobot, because learnpack-cli is
13
+ * the only party that witnesses the facts: Rigobot's part ends when the zip is
14
+ * deployed, before the breathecode asset sync, and it never sees the end of the
15
+ * asynchronous description stage.
16
+ *
17
+ * Transport is the existing telemetry endpoint. The discriminator is a
18
+ * top-level `event` key, which is what breathecode's webhook log already looks
19
+ * for; a body without it keeps being processed as plain telemetry.
20
+ *
21
+ * `package_slug` and `package_id` are duplicated at the top level on purpose:
22
+ * `LearnPack.add_webhook_to_log` fills the indexed columns from there, so
23
+ * without them the events would land as rows nobody can filter in the webhook
24
+ * viewer.
25
+ */
26
+
27
+ export const PACKAGE_PUBLISHED_EVENT = "package_published"
28
+ export const PACKAGE_MANIFEST_UPDATED_EVENT = "package_manifest_updated"
29
+
30
+ /** Whether a second event is coming for this publication. */
31
+ export type DescriptionsOutcome = "queued" | "up_to_date";
32
+
33
+ export type PublishEventEnvelope = {
34
+ event: string;
35
+ package_slug: string;
36
+ package_id?: number;
37
+ payload: Record<string, unknown>;
38
+ };
39
+
40
+ export type PackagePublishedPayload = {
41
+ publish_id: string;
42
+ package: Record<string, unknown> | null;
43
+ manifest: PackageManifestEventView | null;
44
+ descriptions: DescriptionsOutcome;
45
+ };
46
+
47
+ export type PackageManifestUpdatedPayload = {
48
+ publish_id: string;
49
+ status: "success" | "failed";
50
+ package: Record<string, unknown> | null;
51
+ manifest: PackageManifestEventView | null;
52
+ stats?: {
53
+ generated: number;
54
+ failed: number;
55
+ missing: number;
56
+ };
57
+ };
58
+
59
+ export type PublishEventContext = {
60
+ publishId: string;
61
+ courseSlug: string;
62
+ /** `GET /v1/learnpack/package/{slug}/` from Rigobot. */
63
+ packageInfo: Record<string, unknown> | null;
64
+ manifest: PackageManifest | null;
65
+ };
66
+
67
+ function packageIdOf(
68
+ packageInfo: Record<string, unknown> | null
69
+ ): number | undefined {
70
+ const id = packageInfo?.id
71
+ return typeof id === "number" ? id : undefined
72
+ }
73
+
74
+ export function buildPackagePublishedEvent(
75
+ context: PublishEventContext,
76
+ descriptions: DescriptionsOutcome
77
+ ): PublishEventEnvelope {
78
+ const payload: PackagePublishedPayload = {
79
+ publish_id: context.publishId,
80
+ package: context.packageInfo,
81
+ manifest: context.manifest ?
82
+ serializePackageManifestForEvent(context.manifest) :
83
+ null,
84
+ descriptions,
85
+ }
86
+
87
+ return {
88
+ event: PACKAGE_PUBLISHED_EVENT,
89
+ package_slug: context.courseSlug,
90
+ package_id: packageIdOf(context.packageInfo),
91
+ payload: payload as unknown as Record<string, unknown>,
92
+ }
93
+ }
94
+
95
+ export function buildPackageManifestUpdatedEvent(
96
+ context: PublishEventContext,
97
+ status: "success" | "failed",
98
+ stats?: PackageManifestUpdatedPayload["stats"]
99
+ ): PublishEventEnvelope {
100
+ const payload: PackageManifestUpdatedPayload = {
101
+ publish_id: context.publishId,
102
+ status,
103
+ package: context.packageInfo,
104
+ manifest: context.manifest ?
105
+ serializePackageManifestForEvent(context.manifest) :
106
+ null,
107
+ }
108
+
109
+ if (stats) {
110
+ payload.stats = stats
111
+ }
112
+
113
+ return {
114
+ event: PACKAGE_MANIFEST_UPDATED_EVENT,
115
+ package_slug: context.courseSlug,
116
+ package_id: packageIdOf(context.packageInfo),
117
+ payload: payload as unknown as Record<string, unknown>,
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Fire and forget: a publication must never fail because breathecode is down,
123
+ * so every error is swallowed and logged. Returns whether it was delivered, for
124
+ * the journal, not for control flow.
125
+ */
126
+ export async function sendPublishEvent(
127
+ envelope: PublishEventEnvelope,
128
+ breathecodeToken: string,
129
+ timeoutMs = 10_000
130
+ ): Promise<boolean> {
131
+ if (!breathecodeToken) {
132
+ console.warn(
133
+ `[publish-events] Skipping "${envelope.event}": no breathecode token`
134
+ )
135
+ return false
136
+ }
137
+
138
+ try {
139
+ await axios.post(BREATHECODE_EVENTS_URL, envelope, {
140
+ timeout: timeoutMs,
141
+ headers: {
142
+ "Content-Type": "application/json",
143
+ Authorization: "Token " + breathecodeToken.trim(),
144
+ },
145
+ })
146
+ return true
147
+ } catch (error) {
148
+ console.error(
149
+ `[publish-events] Could not deliver "${envelope.event}" for "${envelope.package_slug}":`,
150
+ (error as Error).message
151
+ )
152
+ return false
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Package information for the event payload, straight from Rigobot's own
158
+ * serializer so the shape stays owned by the system that owns the model.
159
+ * Returns null on failure: an event with less context still beats no event.
160
+ */
161
+ export async function fetchPackageInfo(
162
+ courseSlug: string,
163
+ rigobotToken: string
164
+ ): Promise<Record<string, unknown> | null> {
165
+ try {
166
+ const response = await axios.get(
167
+ `${RIGOBOT_HOST}/v1/learnpack/package/${courseSlug}/`,
168
+ {
169
+ timeout: 10_000,
170
+ headers: { Authorization: "Token " + rigobotToken.trim() },
171
+ }
172
+ )
173
+ return response.data as Record<string, unknown>
174
+ } catch (error) {
175
+ console.error(
176
+ `[publish-events] Could not read package "${courseSlug}" from Rigobot:`,
177
+ (error as Error).message
178
+ )
179
+ return null
180
+ }
181
+ }