@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.
Files changed (85) 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 +277 -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/awsCredentials.d.ts +20 -0
  13. package/lib/utils/awsCredentials.js +43 -0
  14. package/lib/utils/creatorUtilities.js +2 -1
  15. package/lib/utils/descriptionHash.d.ts +66 -0
  16. package/lib/utils/descriptionHash.js +173 -0
  17. package/lib/utils/descriptions/backfillEvents.d.ts +60 -0
  18. package/lib/utils/descriptions/backfillEvents.js +107 -0
  19. package/lib/utils/descriptions/gcsStorage.d.ts +16 -0
  20. package/lib/utils/descriptions/gcsStorage.js +60 -0
  21. package/lib/utils/descriptions/generateCourseDescriptions.d.ts +66 -0
  22. package/lib/utils/descriptions/generateCourseDescriptions.js +176 -0
  23. package/lib/utils/descriptions/mirrorDescriptions.d.ts +49 -0
  24. package/lib/utils/descriptions/mirrorDescriptions.js +109 -0
  25. package/lib/utils/descriptions/publishStage.d.ts +69 -0
  26. package/lib/utils/descriptions/publishStage.js +245 -0
  27. package/lib/utils/descriptions/resumePublication.d.ts +36 -0
  28. package/lib/utils/descriptions/resumePublication.js +128 -0
  29. package/lib/utils/descriptions/s3Storage.d.ts +30 -0
  30. package/lib/utils/descriptions/s3Storage.js +141 -0
  31. package/lib/utils/descriptions/workList.d.ts +75 -0
  32. package/lib/utils/descriptions/workList.js +177 -0
  33. package/lib/utils/gcsBucketName.d.ts +10 -0
  34. package/lib/utils/gcsBucketName.js +19 -0
  35. package/lib/utils/packageManifest.d.ts +22 -0
  36. package/lib/utils/packageManifest.js +57 -5
  37. package/lib/utils/publishEvents.d.ts +66 -0
  38. package/lib/utils/publishEvents.js +111 -0
  39. package/lib/utils/publishJournal.d.ts +119 -0
  40. package/lib/utils/publishJournal.js +275 -0
  41. package/lib/utils/repair/legacyPackageRepair.d.ts +131 -0
  42. package/lib/utils/repair/legacyPackageRepair.js +492 -0
  43. package/lib/utils/repair/repairStorage.d.ts +68 -0
  44. package/lib/utils/repair/repairStorage.js +89 -0
  45. package/lib/utils/rigoActions.d.ts +44 -0
  46. package/lib/utils/rigoActions.js +75 -1
  47. package/lib/utils/s3/packageManifestBackfill.d.ts +2 -0
  48. package/lib/utils/s3/packageManifestBackfill.js +5 -8
  49. package/lib/utils/s3/packageSourcesAudit.d.ts +75 -0
  50. package/lib/utils/s3/packageSourcesAudit.js +184 -0
  51. package/lib/utils/syllabusSync.d.ts +71 -0
  52. package/lib/utils/syllabusSync.js +273 -0
  53. package/package.json +3 -1
  54. package/src/commands/publish.ts +7 -0
  55. package/src/commands/serve.ts +144 -335
  56. package/src/models/creator.ts +9 -0
  57. package/src/scripts/README.md +244 -0
  58. package/src/scripts/descriptionsGcsBackfill.ts +193 -0
  59. package/src/scripts/descriptionsS3Backfill.ts +376 -0
  60. package/src/scripts/descriptionsSweep.ts +185 -0
  61. package/src/ui/_app/app.css +1 -1
  62. package/src/ui/_app/app.js +142 -140
  63. package/src/ui/app.tar.gz +0 -0
  64. package/src/utils/api.ts +9 -0
  65. package/src/utils/awsCredentials.ts +57 -0
  66. package/src/utils/creatorUtilities.ts +2 -1
  67. package/src/utils/descriptionHash.ts +196 -0
  68. package/src/utils/descriptions/backfillEvents.ts +152 -0
  69. package/src/utils/descriptions/gcsStorage.ts +67 -0
  70. package/src/utils/descriptions/generateCourseDescriptions.ts +311 -0
  71. package/src/utils/descriptions/mirrorDescriptions.ts +191 -0
  72. package/src/utils/descriptions/publishStage.ts +394 -0
  73. package/src/utils/descriptions/resumePublication.ts +217 -0
  74. package/src/utils/descriptions/s3Storage.ts +214 -0
  75. package/src/utils/descriptions/workList.ts +283 -0
  76. package/src/utils/gcsBucketName.ts +19 -0
  77. package/src/utils/packageManifest.ts +62 -5
  78. package/src/utils/publishEvents.ts +181 -0
  79. package/src/utils/publishJournal.ts +383 -0
  80. package/src/utils/repair/legacyPackageRepair.ts +731 -0
  81. package/src/utils/repair/repairStorage.ts +168 -0
  82. package/src/utils/rigoActions.ts +130 -0
  83. package/src/utils/s3/packageManifestBackfill.ts +771 -776
  84. package/src/utils/s3/packageSourcesAudit.ts +311 -0
  85. package/src/utils/syllabusSync.ts +390 -0
package/src/ui/app.tar.gz CHANGED
Binary file
package/src/utils/api.ts CHANGED
@@ -7,6 +7,15 @@ import * as dotenv from "dotenv"
7
7
  dotenv.config()
8
8
 
9
9
  const HOST = "https://breathecode.herokuapp.com"
10
+
11
+ /**
12
+ * Student telemetry, and publication events. Both share the endpoint: a body
13
+ * carrying a top-level `event` key is dispatched as an event, anything else
14
+ * keeps being processed as telemetry.
15
+ */
16
+ export const BREATHECODE_TELEMETRY_URL = `${HOST}/v1/assignment/me/telemetry`
17
+ export const BREATHECODE_EVENTS_URL = BREATHECODE_TELEMETRY_URL
18
+
10
19
  export const RIGOBOT_HOST =
11
20
  process.env.RIGOBOT_HOST || "https://rigobot.herokuapp.com"
12
21
  export const RIGOBOT_REALTIME_HOST = "https://ai.4geeks.com"
@@ -0,0 +1,57 @@
1
+ /**
2
+ * AWS credentials for the published bucket, required rather than implicit.
3
+ *
4
+ * Building an `S3Client` with no credentials succeeds: the SDK defers to its
5
+ * provider chain and only fails at the first request, with
6
+ * "Could not load credentials from any providers". Inside a background job that
7
+ * surfaces minutes later, detached from the cause. Validating up front turns it
8
+ * into an obvious misconfiguration.
9
+ *
10
+ * Note this deliberately rejects the empty-string fallback pattern
11
+ * (`process.env.X || ""`): passing empty credentials defeats the provider chain
12
+ * instead of failing, which is how the opaque error appeared in the first place.
13
+ */
14
+
15
+ export type AwsCredentials = {
16
+ accessKeyId: string;
17
+ secretAccessKey: string;
18
+ };
19
+
20
+ export function requireAwsCredentials(): AwsCredentials {
21
+ const accessKeyId = (process.env.AWS_ACCESS_KEY_ID || "").trim()
22
+ const secretAccessKey = (process.env.AWS_SECRET_ACCESS_KEY || "").trim()
23
+
24
+ const missing: string[] = []
25
+ if (!accessKeyId) {
26
+ missing.push("AWS_ACCESS_KEY_ID")
27
+ }
28
+
29
+ if (!secretAccessKey) {
30
+ missing.push("AWS_SECRET_ACCESS_KEY")
31
+ }
32
+
33
+ if (missing.length > 0) {
34
+ throw new Error(
35
+ `${missing.join(" and ")} (env) ${
36
+ missing.length > 1 ? "are" : "is"
37
+ } required to reach the published package bucket`
38
+ )
39
+ }
40
+
41
+ return { accessKeyId, secretAccessKey }
42
+ }
43
+
44
+ export function requireS3PackagesBucket(): string {
45
+ const bucket = (process.env.S3_PACKAGES_BUCKET || "").trim()
46
+ if (!bucket) {
47
+ throw new Error(
48
+ "S3_PACKAGES_BUCKET (env) is required: it names the bucket holding published packages"
49
+ )
50
+ }
51
+
52
+ return bucket
53
+ }
54
+
55
+ export function awsRegion(): string {
56
+ return (process.env.AWS_REGION || "").trim() || "us-east-1"
57
+ }
@@ -1,4 +1,5 @@
1
1
  import Console from "./console"
2
+ import { BREATHECODE_TELEMETRY_URL } from "./api"
2
3
 
3
4
  const frontMatter = require("front-matter")
4
5
  import * as MarkdownIt from "markdown-it"
@@ -189,7 +190,7 @@ export const makePackageInfo = (choices: any) => {
189
190
  .replace(/ /g, "-")
190
191
  .replace(/[^\w-]+/g, ""),
191
192
  telemetry: {
192
- batch: "https://breathecode.herokuapp.com/v1/assignment/me/telemetry",
193
+ batch: BREATHECODE_TELEMETRY_URL,
193
194
  },
194
195
  }
195
196
  return packageInfo
@@ -0,0 +1,196 @@
1
+ import * as crypto from "crypto"
2
+
3
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
4
+ const frontMatter = require("front-matter")
5
+
6
+ /**
7
+ * Utilities to detect whether a step description is stale relative to its
8
+ * README content.
9
+ *
10
+ * Two signals are stored per (step, lang) at generation time:
11
+ * - sha256(normalizeReadme(content)) -> exact fast-path + content-addressing
12
+ * - simhash64(normalizeReadme(content)) -> near-duplicate significance filter
13
+ *
14
+ * The sweep re-hashes the current README and compares against the stored
15
+ * values: exact SHA-256 match => no-op; otherwise if the SimHash Hamming
16
+ * distance to the *generation baseline* is <= k the change is cosmetic and is
17
+ * ignored, else the description is regenerated. The H1 title is part of the
18
+ * normalized content (a deliberate product decision).
19
+ */
20
+
21
+ /**
22
+ * Remove every image from markdown: inline `![alt](url)`, reference-style
23
+ * `![alt][ref]` and raw `<img>` tags. Regular links `[text](url)` are kept.
24
+ *
25
+ * Images are dropped from BOTH sides of the description pipeline — here for
26
+ * fingerprinting, and again before sending the README to the LLM. Adding,
27
+ * replacing or removing an illustration does not change what the lesson
28
+ * teaches, so it must not trigger a regeneration; and the alt text of generated
29
+ * illustrations is a full image-generation prompt (hundreds of tokens) that
30
+ * only invites the model to describe the picture instead of the lesson.
31
+ *
32
+ * Note the alt text may legitimately contain parentheses, so the alt is matched
33
+ * as "anything up to the closing bracket" rather than as balanced text.
34
+ */
35
+ export function stripMarkdownImages(content: string): string {
36
+ return (
37
+ content
38
+ // ![alt](url) — alt may span lines and contain parentheses
39
+ .replace(/!\[[^\]]*]\([^)]*\)/g, "")
40
+ // ![alt][ref] — i.e. `!` followed by two bracketed groups
41
+ .replace(/!(?:\[[^\]]*]){2}/g, "")
42
+ // <img ...> / <img ... />
43
+ .replace(/<img\b[^>]*>/gi, "")
44
+ // [](url) left behind by linked images such as badges
45
+ .replace(/\[\s*]\([^)]*\)/g, "")
46
+ )
47
+ }
48
+
49
+ /**
50
+ * Canonicalize a README for hashing. Strips the frontmatter (metadata such as
51
+ * tutorial/intro video URLs, which do not describe the lesson), removes images
52
+ * (see `stripMarkdownImages`) and collapses all whitespace runs so that
53
+ * reformatting, blank lines and trailing spaces do not register as content
54
+ * changes. The H1 and the prose are preserved.
55
+ */
56
+ export function normalizeReadme(content: string): string {
57
+ let body = content
58
+ try {
59
+ const parsed = frontMatter(content)
60
+ body = typeof parsed.body === "string" ? parsed.body : content
61
+ } catch {
62
+ body = content
63
+ }
64
+
65
+ return stripMarkdownImages(body)
66
+ .replace(/\r/g, "")
67
+ .replace(/\s+/g, " ")
68
+ .trim()
69
+ }
70
+
71
+ export function sha256(input: string): string {
72
+ return crypto.createHash("sha256").update(input).digest("hex")
73
+ }
74
+
75
+ // The build targets < ES2020, so BigInt literals are unavailable. The 64-bit
76
+ // SimHash is therefore represented as two unsigned 32-bit halves { hi, lo } and
77
+ // serialized as a 16-char hex string (hi then lo).
78
+
79
+ function toHex32(x: number): string {
80
+ return (x >>> 0).toString(16).padStart(8, "0")
81
+ }
82
+
83
+ function popcount32(input: number): number {
84
+ let x = input >>> 0
85
+ let count = 0
86
+ while (x !== 0) {
87
+ count += x & 1
88
+ x >>>= 1
89
+ }
90
+
91
+ return count
92
+ }
93
+
94
+ function hashToken64(token: string): { hi: number; lo: number } {
95
+ // First 8 bytes (16 hex chars) of an md5 digest -> two 32-bit halves.
96
+ const hex = crypto.createHash("md5").update(token).digest("hex").slice(0, 16)
97
+ return {
98
+ hi: Number.parseInt(hex.slice(0, 8), 16) >>> 0,
99
+ lo: Number.parseInt(hex.slice(8, 16), 16) >>> 0,
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 64-bit SimHash (Charikar) over whitespace-delimited tokens, returned as a
105
+ * zero-padded 16-char hex string.
106
+ */
107
+ export function simhash64(input: string): string {
108
+ const tokens = input.split(/\s+/).filter(Boolean)
109
+ if (tokens.length === 0) {
110
+ return "0".repeat(16)
111
+ }
112
+
113
+ const weights = Array.from({ length: 64 }, () => 0)
114
+ for (const token of tokens) {
115
+ const { hi, lo } = hashToken64(token)
116
+ for (let i = 0; i < 32; i++) {
117
+ weights[i] += ((lo >>> i) & 1) === 1 ? 1 : -1
118
+ weights[32 + i] += ((hi >>> i) & 1) === 1 ? 1 : -1
119
+ }
120
+ }
121
+
122
+ let lo = 0
123
+ let hi = 0
124
+ for (let i = 0; i < 32; i++) {
125
+ if (weights[i] > 0) {
126
+ lo |= 1 << i
127
+ }
128
+
129
+ if (weights[32 + i] > 0) {
130
+ hi |= 1 << i
131
+ }
132
+ }
133
+
134
+ return toHex32(hi) + toHex32(lo)
135
+ }
136
+
137
+ /** Hamming distance between two 64-bit hex fingerprints. */
138
+ export function hammingHex(a: string, b: string): number {
139
+ const hiA = Number.parseInt(a.slice(0, 8), 16) >>> 0
140
+ const loA = Number.parseInt(a.slice(8, 16), 16) >>> 0
141
+ const hiB = Number.parseInt(b.slice(0, 8), 16) >>> 0
142
+ const loB = Number.parseInt(b.slice(8, 16), 16) >>> 0
143
+ return popcount32((hiA ^ hiB) >>> 0) + popcount32((loA ^ loB) >>> 0)
144
+ }
145
+
146
+ export type ContentFingerprint = {
147
+ sha256: string;
148
+ simhash: string;
149
+ };
150
+
151
+ /** Convenience: normalize + both fingerprints in one call. */
152
+ export function fingerprintReadme(content: string): ContentFingerprint {
153
+ const normalized = normalizeReadme(content)
154
+ return {
155
+ sha256: sha256(normalized),
156
+ simhash: simhash64(normalized),
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Default significance threshold (Hamming distance over the 64-bit SimHash).
162
+ * k=3 is the classic near-duplicate threshold for 64-bit SimHash
163
+ * (Charikar 2002; Manku, Jain & Das Sarma, WWW 2007). Overridable via env.
164
+ */
165
+ export function getSimhashThreshold(): number {
166
+ const raw = process.env.DESCRIPTION_SIMHASH_THRESHOLD
167
+ const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN
168
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 3
169
+ }
170
+
171
+ /**
172
+ * Decide whether a description generated from `previous` is stale given the
173
+ * `current` README content. Exact match => not stale; else stale only if the
174
+ * SimHash Hamming distance exceeds the threshold (cosmetic edits are ignored).
175
+ */
176
+ export function isDescriptionStale(
177
+ previous: { sha256?: string; simhash?: string } | undefined,
178
+ current: ContentFingerprint,
179
+ threshold: number = getSimhashThreshold()
180
+ ): boolean {
181
+ if (!previous || !previous.sha256) {
182
+ // Never generated -> treat as needing generation (i.e. "stale").
183
+ return true
184
+ }
185
+
186
+ if (previous.sha256 === current.sha256) {
187
+ return false
188
+ }
189
+
190
+ if (!previous.simhash) {
191
+ // No baseline fingerprint to compare against -> be conservative.
192
+ return true
193
+ }
194
+
195
+ return hammingHex(previous.simhash, current.simhash) > threshold
196
+ }
@@ -0,0 +1,152 @@
1
+ import { v4 as uuidv4 } from "uuid"
2
+ import { PackageManifest } from "../packageManifest"
3
+ import {
4
+ buildPackageManifestUpdatedEvent,
5
+ fetchPackageInfo,
6
+ sendPublishEvent,
7
+ } from "../publishEvents"
8
+ import {
9
+ CourseDescriptionsStorage,
10
+ GenerateCourseDescriptionsResult,
11
+ } from "./generateCourseDescriptions"
12
+
13
+ /**
14
+ * `package_manifest_updated` for courses reconciled by the backfill.
15
+ *
16
+ * The backfill regenerates descriptions of packages that are already published,
17
+ * so breathecode needs the same notification a publication sends — but there is
18
+ * no publication here: no `package_published` precedes this event, and no
19
+ * journal tracks it (the sweep resumes publications, and a backfill is simply
20
+ * re-run instead).
21
+ *
22
+ * That is what makes `publish_id` awkward. The field exists to correlate the two
23
+ * events of one publication, a pair this event has no half of. Since it is
24
+ * required, the value has to be something that cannot be mistaken for a real
25
+ * publication: a bare uuid — the obvious choice — would be exactly that mistake,
26
+ * and if breathecode ever starts pairing events it would search forever for a
27
+ * `package_published` that was never sent.
28
+ *
29
+ * Hence `backfill-{runId}-{slug}`:
30
+ *
31
+ * - the `backfill-` prefix makes the origin readable in the webhook log and
32
+ * discriminable in code (`isBackfillPublishId`); real ids are bare uuids from
33
+ * `createJournal`, so the two spaces cannot collide;
34
+ * - `{slug}` keeps it unique per event, which a per-run id alone would not be;
35
+ * - a `{runId}` shared by every course of one invocation makes "every event from
36
+ * the run I fired at 15:40" a single substring query, which is the question
37
+ * you actually ask when the catalogue is backfilled in small batches.
38
+ *
39
+ * Encoding this in `publish_id`, rather than adding an `origin` field, keeps the
40
+ * event contract untouched: breathecode ignores `publish_id` today, so this
41
+ * costs them nothing and needs no coordination. If they ever start consuming the
42
+ * provenance, it can be promoted to a field of its own.
43
+ */
44
+
45
+ export const BACKFILL_PUBLISH_ID_PREFIX = "backfill-"
46
+
47
+ /** One per script invocation, shared by every course it touches. */
48
+ export function newBackfillRunId(): string {
49
+ return uuidv4()
50
+ }
51
+
52
+ export function backfillPublishId(runId: string, courseSlug: string): string {
53
+ return `${BACKFILL_PUBLISH_ID_PREFIX}${runId}-${courseSlug}`
54
+ }
55
+
56
+ export function isBackfillPublishId(publishId: string): boolean {
57
+ return publishId.startsWith(BACKFILL_PUBLISH_ID_PREFIX)
58
+ }
59
+
60
+ /**
61
+ * `skipped` means the event was never attempted, which is not a failure: a
62
+ * course the backfill did not change has nothing to announce.
63
+ */
64
+ export type BackfillEventOutcome = "skipped" | "delivered" | "failed";
65
+
66
+ export type EmitBackfillManifestEventParams = {
67
+ courseSlug: string;
68
+ runId: string;
69
+ result: GenerateCourseDescriptionsResult;
70
+ storage: CourseDescriptionsStorage;
71
+ rigobotToken: string;
72
+ breathecodeToken: string;
73
+ /** Injection points for tests. */
74
+ emit?: typeof sendPublishEvent;
75
+ readPackageInfo?: typeof fetchPackageInfo;
76
+ };
77
+
78
+ /**
79
+ * Announce one reconciled course. Never throws: the descriptions and the
80
+ * manifest are already written by the time this runs, and losing the
81
+ * notification must not turn a successful course into a failed one.
82
+ */
83
+ export async function emitBackfillManifestEvent(
84
+ params: EmitBackfillManifestEventParams
85
+ ): Promise<BackfillEventOutcome> {
86
+ const { courseSlug, result } = params
87
+
88
+ if (result.status === "skipped") {
89
+ return "skipped"
90
+ }
91
+
92
+ const emit = params.emit ?? sendPublishEvent
93
+ const readPackageInfo = params.readPackageInfo ?? fetchPackageInfo
94
+
95
+ // Enriching the payload must never prevent the delivery: both the manifest and
96
+ // the package info are optional in the contract, and an event with less
97
+ // context beats an event that never arrives.
98
+ let manifest: PackageManifest | null = null
99
+ try {
100
+ if (params.storage.readManifest) {
101
+ manifest = await params.storage.readManifest(courseSlug)
102
+ }
103
+ } catch (error) {
104
+ console.error(
105
+ `[backfill-events] Could not read the manifest of "${courseSlug}", reporting without it:`,
106
+ (error as Error).message
107
+ )
108
+ }
109
+
110
+ let packageInfo: Record<string, unknown> | null = null
111
+ try {
112
+ packageInfo = await readPackageInfo(courseSlug, params.rigobotToken)
113
+ } catch (error) {
114
+ console.error(
115
+ `[backfill-events] Could not read the package "${courseSlug}", reporting without it:`,
116
+ (error as Error).message
117
+ )
118
+ }
119
+
120
+ try {
121
+ const delivered = await emit(
122
+ buildPackageManifestUpdatedEvent(
123
+ {
124
+ publishId: backfillPublishId(params.runId, courseSlug),
125
+ courseSlug,
126
+ packageInfo,
127
+ manifest,
128
+ },
129
+ // A failed projection does not fail the run — the syllabus is saved
130
+ // either way — but this event reports the manifest, so it cannot claim
131
+ // success over one that was never rewritten.
132
+ result.status === "failed" || result.manifestProjected === false ?
133
+ "failed" :
134
+ "success",
135
+ {
136
+ generated: result.generated,
137
+ failed: result.failed,
138
+ missing: result.missing,
139
+ }
140
+ ),
141
+ params.breathecodeToken
142
+ )
143
+
144
+ return delivered ? "delivered" : "failed"
145
+ } catch (error) {
146
+ console.error(
147
+ `[backfill-events] Could not deliver the manifest event for "${courseSlug}":`,
148
+ (error as Error).message
149
+ )
150
+ return "failed"
151
+ }
152
+ }
@@ -0,0 +1,67 @@
1
+ import { Bucket } from "@google-cloud/storage"
2
+ import { Syllabus } from "../../models/creator"
3
+ import { buildConfig } from "../configBuilder"
4
+ import { createGcsSyllabusSyncStorage } from "../syllabusSync"
5
+ import { CourseDescriptionsStorage } from "./generateCourseDescriptions"
6
+ import { CourseExercise } from "./workList"
7
+
8
+ /**
9
+ * Draft-bucket (GCS) adapter.
10
+ *
11
+ * Descriptions are never generated against this bucket — it keeps moving while
12
+ * the teacher edits, so it is not a stable snapshot. It is only written by the
13
+ * mirror-back, which copies descriptions generated from the published package
14
+ * whenever the draft content still matches.
15
+ *
16
+ * Note there is no `reprojectManifest`: the manifest is only rebuilt when
17
+ * publishing.
18
+ */
19
+
20
+ export const gcsSyllabusPath = (courseSlug: string) =>
21
+ `courses/${courseSlug}/.learn/initialSyllabus.json`
22
+
23
+ export const gcsReadmePath = (
24
+ courseSlug: string,
25
+ exerciseSlug: string,
26
+ fileName: string
27
+ ) => `courses/${courseSlug}/exercises/${exerciseSlug}/${fileName}`
28
+
29
+ export function createGcsDescriptionsStorage(
30
+ bucket: Bucket
31
+ ): CourseDescriptionsStorage {
32
+ return {
33
+ async readSyllabus(courseSlug) {
34
+ try {
35
+ const [buf] = await bucket.file(gcsSyllabusPath(courseSlug)).download()
36
+ return JSON.parse(buf.toString()) as Syllabus
37
+ } catch {
38
+ return null
39
+ }
40
+ },
41
+ async writeSyllabus(courseSlug, syllabus) {
42
+ await bucket
43
+ .file(gcsSyllabusPath(courseSlug))
44
+ .save(Buffer.from(JSON.stringify(syllabus), "utf8"), {
45
+ contentType: "application/json",
46
+ })
47
+ },
48
+ async listExercises(courseSlug): Promise<CourseExercise[]> {
49
+ const { exercises } = await buildConfig(bucket, courseSlug)
50
+ return exercises.map(exercise => ({
51
+ slug: exercise.slug,
52
+ translations: exercise.translations || {},
53
+ }))
54
+ },
55
+ async readReadme(courseSlug, exerciseSlug, fileName) {
56
+ try {
57
+ const [buf] = await bucket
58
+ .file(gcsReadmePath(courseSlug, exerciseSlug, fileName))
59
+ .download()
60
+ return buf.toString()
61
+ } catch {
62
+ return null
63
+ }
64
+ },
65
+ syllabusSyncStorage: createGcsSyllabusSyncStorage(bucket),
66
+ }
67
+ }