@learnpack/learnpack 5.0.358 → 5.0.359

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.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Creates or updates the Breathecode assets of a published package, one per
3
+ * language, and writes the resulting ids back to the Rigobot package.
4
+ *
5
+ * Kept free of fs, oclif and express so the whole decision table can be
6
+ * exercised with HTTP mocks alone; `publish` and `serve` only supply the
7
+ * readme of each language and the tokens.
8
+ */
9
+ import api, { AssetSyncError, BcAsset } from "./api"
10
+ import Console from "./console"
11
+ import { buildAssetSlug } from "./creatorUtilities"
12
+ import {
13
+ dedupeLangs,
14
+ describeAmbiguity,
15
+ getAvailableLangs,
16
+ PackageAssetsResolution,
17
+ resolvePackageAssets,
18
+ canonicalLang,
19
+ } from "./assetResolver"
20
+
21
+ export type AssetSyncInput = {
22
+ bcToken: string;
23
+ rigoToken: string;
24
+ packageSlug: string;
25
+ learnJson: any;
26
+ deployUrl: string;
27
+ academyId?: number;
28
+ /** Raw readme for a language ("" when there is none). Base64 encoding happens here. */
29
+ readReadme: (lang: string) => Promise<string>;
30
+ /** A resolution computed earlier (e.g. before the upload) to reuse instead of resolving again. */
31
+ resolution?: PackageAssetsResolution;
32
+ };
33
+
34
+ export type SyncedAsset = {
35
+ lang: string;
36
+ action: "created" | "updated";
37
+ asset: { id: number; slug: string };
38
+ };
39
+
40
+ export type AssetSyncResult = {
41
+ errors: AssetSyncError[];
42
+ warnings: string[];
43
+ synced: SyncedAsset[];
44
+ };
45
+
46
+ /**
47
+ * Localized value with the same fallbacks the publish flow always used.
48
+ * @param translations - A learn.json map such as `title` or `description`.
49
+ * @param lang - Preferred language key.
50
+ * @returns The value for `lang`, else "en"/"us", else the first one, else "".
51
+ */
52
+ export const getLocalizedValue = (
53
+ translations: Record<string, any> | undefined,
54
+ lang: string
55
+ ): string => {
56
+ if (!translations || typeof translations !== "object") return ""
57
+
58
+ const direct = translations[lang]
59
+ if (typeof direct === "string" && direct.trim().length > 0) return direct
60
+
61
+ for (const fb of ["en", "us"]) {
62
+ const v = translations[fb]
63
+ if (typeof v === "string" && v.trim().length > 0) return v
64
+ }
65
+
66
+ const firstKey = Object.keys(translations)[0]
67
+ const first = firstKey ? translations[firstKey] : ""
68
+ return typeof first === "string" ? first : ""
69
+ }
70
+
71
+ type LangPlan =
72
+ | { lang: string; kind: "update"; asset: BcAsset; slug: string }
73
+ | { lang: string; kind: "create"; slug: string }
74
+ | { lang: string; kind: "skip"; detail: string };
75
+
76
+ const packageError = (detail: string): AssetSyncError => ({
77
+ kind: "package_error",
78
+ error: { detail },
79
+ })
80
+
81
+ const langError = (lang: string, detail: string): AssetSyncError => ({
82
+ kind: "lang_error",
83
+ lang,
84
+ error: { detail },
85
+ })
86
+
87
+ const errorDetail = (error: unknown): string => {
88
+ const err: any = error
89
+ if (typeof err?.detail === "string") return err.detail
90
+ if (typeof err?.message === "string") return err.message
91
+ try {
92
+ return JSON.stringify(err)
93
+ } catch {
94
+ return String(err)
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Synchronises every language of a package with Breathecode.
100
+ * @param input - Tokens, package data, deploy url and readme provider.
101
+ * @returns Per-language errors (the publish itself is never blocked), warnings worth logging, and what was written.
102
+ */
103
+ export async function syncPackageAssets(
104
+ input: AssetSyncInput
105
+ ): Promise<AssetSyncResult> {
106
+ const warnings: string[] = []
107
+ const errors: AssetSyncError[] = []
108
+ const synced: SyncedAsset[] = []
109
+
110
+ const user = await api.validateToken(input.bcToken)
111
+ if (!user || typeof user.id !== "number") {
112
+ return {
113
+ errors: [packageError("Invalid Breathecode token; assets not synced.")],
114
+ warnings,
115
+ synced,
116
+ }
117
+ }
118
+
119
+ const resolution =
120
+ input.resolution ??
121
+ (await resolvePackageAssets({
122
+ bcToken: input.bcToken,
123
+ rigoToken: input.rigoToken,
124
+ packageSlug: input.packageSlug,
125
+ learnJson: input.learnJson,
126
+ }))
127
+ warnings.push(...resolution.warnings)
128
+
129
+ if (resolution.packageState === "unknown") {
130
+ return {
131
+ errors: [
132
+ packageError(
133
+ `${
134
+ resolution.packageError ?? "Existing assets could not be checked"
135
+ }; nothing was created or updated to avoid duplicates.`
136
+ ),
137
+ ],
138
+ warnings,
139
+ synced,
140
+ }
141
+ }
142
+
143
+ const learnpackId = resolution.learnpackId
144
+ if (learnpackId === null) {
145
+ return {
146
+ errors: [
147
+ packageError(
148
+ "Could not resolve Learnpack package id; assets not synced to Breathecode."
149
+ ),
150
+ ],
151
+ warnings,
152
+ synced,
153
+ }
154
+ }
155
+
156
+ // Decide every language before touching anything, so all_translations can
157
+ // name the full set up front (the PUT replaces the relation, it does not add).
158
+ const langs = dedupeLangs(getAvailableLangs(input.learnJson))
159
+ const plans: LangPlan[] = []
160
+ for (const lang of langs) {
161
+ const title = getLocalizedValue(input.learnJson?.title, lang)
162
+ if (!title) {
163
+ plans.push({
164
+ lang,
165
+ kind: "skip",
166
+ detail: `Missing learn.json title for language "${lang}"`,
167
+ })
168
+ continue
169
+ }
170
+
171
+ const entry = resolution.byLang.get(canonicalLang(lang) as string)
172
+ if (!entry || entry.status === "unknown") {
173
+ plans.push({
174
+ lang,
175
+ kind: "skip",
176
+ detail: `${
177
+ entry?.status === "unknown" ?
178
+ entry.error :
179
+ "Existing asset could not be checked"
180
+ }; nothing was created or updated for this language to avoid duplicates.`,
181
+ })
182
+ } else if (entry.status === "ambiguous") {
183
+ plans.push({
184
+ lang,
185
+ kind: "skip",
186
+ detail: describeAmbiguity(lang, entry.candidates),
187
+ })
188
+ } else if (entry.status === "found") {
189
+ if (entry.asset.ownerId !== null && entry.asset.ownerId !== user.id) {
190
+ plans.push({
191
+ lang,
192
+ kind: "skip",
193
+ detail: `Asset #${entry.asset.id} ${entry.asset.slug} belongs to another user (owner ${entry.asset.ownerId}); it was not updated and no duplicate was created.`,
194
+ })
195
+ } else {
196
+ plans.push({
197
+ lang,
198
+ kind: "update",
199
+ asset: entry.asset,
200
+ slug: entry.asset.slug,
201
+ })
202
+ }
203
+ } else {
204
+ plans.push({ lang, kind: "create", slug: buildAssetSlug(title, lang) })
205
+ }
206
+ }
207
+
208
+ const allTranslations = plans
209
+ .filter(p => p.kind !== "skip")
210
+ .map(p => (p as { slug: string }).slug)
211
+
212
+ const rigoToken = input.rigoToken.trim()
213
+ const createdOrUpdatedIds: number[] = []
214
+
215
+ for (const plan of plans) {
216
+ if (plan.kind === "skip") {
217
+ errors.push(langError(plan.lang, plan.detail))
218
+ continue
219
+ }
220
+
221
+ const title = getLocalizedValue(input.learnJson?.title, plan.lang)
222
+ const description = getLocalizedValue(
223
+ input.learnJson?.description,
224
+ plan.lang
225
+ )
226
+
227
+ try {
228
+ if (plan.kind === "update") {
229
+ // Never send lang or slug: the language of an existing asset is not
230
+ // ours to change, and a slug would rename it.
231
+ const updatePayload: any = {
232
+ graded: true,
233
+ learnpack_deploy_url: input.deployUrl,
234
+ title,
235
+ category: "uncategorized",
236
+ description,
237
+ all_translations: allTranslations,
238
+ learnpack_id: learnpackId,
239
+ }
240
+ if (plan.asset.academyId === null && input.academyId !== undefined) {
241
+ updatePayload.academy_id = input.academyId
242
+ }
243
+
244
+ Console.info(
245
+ `Asset exists for "${plan.lang}" (#${plan.asset.id} ${plan.slug}), updating it`
246
+ )
247
+ // eslint-disable-next-line no-await-in-loop
248
+ const asset = await api.updateAsset(
249
+ input.bcToken,
250
+ plan.asset.id,
251
+ updatePayload
252
+ )
253
+ const id = typeof asset?.id === "number" ? asset.id : plan.asset.id
254
+ createdOrUpdatedIds.push(id)
255
+ synced.push({
256
+ lang: plan.lang,
257
+ action: "updated",
258
+ asset: { id, slug: asset?.slug ?? plan.slug },
259
+ })
260
+ continue
261
+ }
262
+
263
+ // eslint-disable-next-line no-await-in-loop
264
+ const readme = await input.readReadme(plan.lang)
265
+ const assetPayload: any = {
266
+ slug: plan.slug,
267
+ title,
268
+ lang: plan.lang,
269
+ graded: true,
270
+ description,
271
+ learnpack_deploy_url: input.deployUrl,
272
+ technologies: [],
273
+ url: input.deployUrl,
274
+ category: "uncategorized",
275
+ owner: user.id,
276
+ author: user.id,
277
+ preview: input.learnJson?.preview,
278
+ readme_raw: Buffer.from(readme).toString("base64"),
279
+ all_translations: allTranslations,
280
+ learnpack_id: learnpackId,
281
+ }
282
+ if (input.academyId !== undefined) {
283
+ assetPayload.academy_id = input.academyId
284
+ }
285
+
286
+ Console.info(`No asset for "${plan.lang}", creating ${plan.slug}`)
287
+ // eslint-disable-next-line no-await-in-loop
288
+ const asset = await api.createAsset(input.bcToken, assetPayload)
289
+ createdOrUpdatedIds.push(asset.id)
290
+ synced.push({
291
+ lang: plan.lang,
292
+ action: "created",
293
+ asset: { id: asset.id, slug: asset.slug ?? plan.slug },
294
+ })
295
+
296
+ // Record the id right away: a stale Breathecode cache could hide the
297
+ // new slug from the next publish, but the id never lies.
298
+ try {
299
+ // eslint-disable-next-line no-await-in-loop
300
+ await api.updateRigoPackage(rigoToken, input.packageSlug, {
301
+ asset_id: asset.id,
302
+ })
303
+ } catch (error) {
304
+ warnings.push(
305
+ `Could not record asset #${
306
+ asset.id
307
+ } in the Rigobot package: ${errorDetail(error)}`
308
+ )
309
+ }
310
+ } catch (error) {
311
+ const errorData =
312
+ error && typeof error === "object" && "response" in error ?
313
+ (error as any).response?.data || error :
314
+ error
315
+ Console.error(`Error syncing asset for language ${plan.lang}:`, error)
316
+ errors.push(langError(plan.lang, errorDetail(errorData)))
317
+ }
318
+ }
319
+
320
+ // Final list: everything Rigobot knew that still exists and is ours, plus
321
+ // whatever this run touched. Ids that 404 or belong to another package are
322
+ // the only ones that fall off; duplicates awaiting a manual decision stay.
323
+ const finalIds = [
324
+ ...new Set([
325
+ ...resolution.knownAssetIds,
326
+ ...resolution.orphanIds,
327
+ ...createdOrUpdatedIds,
328
+ ]),
329
+ ]
330
+ try {
331
+ await api.updateRigoPackage(rigoToken, input.packageSlug, {
332
+ asset_ids: finalIds,
333
+ })
334
+ } catch (error) {
335
+ warnings.push(
336
+ `Could not write asset_ids ${JSON.stringify(
337
+ finalIds
338
+ )} to the Rigobot package: ${errorDetail(error)}`
339
+ )
340
+ }
341
+
342
+ return { errors, warnings, synced }
343
+ }
@@ -1,57 +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
+ /**
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
+ }
@@ -134,6 +134,22 @@ export const slugify = (text: string) => {
134
134
  .replace(/^-+|-+$/g, "") // Trim hyphens from start/end
135
135
  }
136
136
 
137
+ // Builds the slug of the Breathecode asset for a given title and language.
138
+ // The backend only accepts letters, numbers, underscores and hyphens, so the
139
+ // periods that slugify() keeps (e.g. "Next.js") are turned into hyphens here.
140
+ // Hyphens are collapsed and trimmed BEFORE the 47-char cut on purpose: for a
141
+ // title without periods this must yield exactly slugify(title).slice(0, 47),
142
+ // which is the slug every existing asset was created with (a trailing hyphen
143
+ // left by the cut is part of that historical slug and is kept).
144
+ export const buildAssetSlug = (title: string, lang: string): string => {
145
+ const slug = slugify(title)
146
+ .replace(/\./g, "-")
147
+ .replace(/-+/g, "-")
148
+ .replace(/^-+|-+$/g, "")
149
+ .slice(0, 47)
150
+ return `${slug}-${lang}`
151
+ }
152
+
137
153
  export const getExInfo = (title: string) => {
138
154
  // Example title: '1.0 - Introduction to AI [READ: Small introduction to important concepts such as AI, machine learning, and their applications]'
139
155
  let [exNumber, exTitle] = title.split(" - ")