@learnpack/learnpack 5.0.344 → 5.0.347

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.
@@ -54,6 +54,54 @@ const getLocalizedValue = (
54
54
  return typeof first === "string" ? first : ""
55
55
  }
56
56
 
57
+ type ExistingAssetInfo = {
58
+ lang: string;
59
+ slug: string;
60
+ exists: boolean;
61
+ academyId?: number;
62
+ };
63
+
64
+ type AcademyMode =
65
+ | { type: "select" }
66
+ | { type: "locked"; academyId: number }
67
+ | { type: "conflict"; academies: number[] };
68
+
69
+ const getExistingAssetsInfo = async (
70
+ token: string,
71
+ learnJson: any
72
+ ): Promise<ExistingAssetInfo[]> => {
73
+ const availableLangs = getAvailableLangs(learnJson)
74
+ const results: ExistingAssetInfo[] = []
75
+
76
+ for (const lang of availableLangs) {
77
+ const assetTitle = getLocalizedValue(learnJson?.title, lang)
78
+ if (!assetTitle) continue
79
+
80
+ let slug = slugify(assetTitle).slice(0, 47)
81
+ slug = `${slug}-${lang}`
82
+
83
+ // eslint-disable-next-line no-await-in-loop
84
+ const { exists, academyId } = await api.doesAssetExists(token, slug)
85
+ results.push({ lang, slug, exists, academyId })
86
+ }
87
+
88
+ return results
89
+ }
90
+
91
+ const determinePublishAcademyMode = (
92
+ existingAssets: ExistingAssetInfo[]
93
+ ): AcademyMode => {
94
+ const academyIds = existingAssets
95
+ .filter((a) => a.exists && a.academyId !== undefined)
96
+ .map((a) => a.academyId as number)
97
+
98
+ const unique = [...new Set(academyIds)]
99
+
100
+ if (unique.length === 0) return { type: "select" }
101
+ if (unique.length === 1) return { type: "locked", academyId: unique[0] }
102
+ return { type: "conflict", academies: unique }
103
+ }
104
+
57
105
  export const handleAssetCreation = async (
58
106
  sessionPayload: { token: string; rigobotToken: string },
59
107
  learnJson: any,
@@ -61,6 +109,8 @@ export const handleAssetCreation = async (
61
109
  learnpackDeployUrl: string,
62
110
  b64IndexReadme: string,
63
111
  academyId: number | undefined,
112
+ learnpackId: number,
113
+ preflightInfo?: ExistingAssetInfo,
64
114
  all_translations: string[] = []
65
115
  ) => {
66
116
  const category = "uncategorized"
@@ -83,23 +133,9 @@ export const handleAssetCreation = async (
83
133
  let slug = slugify(assetTitle).slice(0, 47)
84
134
  slug = `${slug}-${selectedLang}`
85
135
 
86
- const { exists, academyId: existingAcademyId } = await api.doesAssetExists(
87
- sessionPayload.token,
88
- slug
89
- )
90
-
91
- // Compare academy IDs if asset exists and academyId is provided
92
- if (
93
- exists &&
94
- existingAcademyId !== undefined &&
95
- academyId !== undefined &&
96
- existingAcademyId !== academyId
97
- ) {
98
- Console.warning(
99
- `Asset exists in academy ${existingAcademyId}, but attempting to publish to academy ${academyId}. ` +
100
- `The asset will be updated in its current academy (${existingAcademyId}).`
101
- )
102
- }
136
+ // Use pre-flight info when available to avoid an extra GET request
137
+ const { exists, academyId: existingAcademyId } =
138
+ preflightInfo ?? (await api.doesAssetExists(sessionPayload.token, slug))
103
139
 
104
140
  // const technologies: unknown[] = Array.isArray(learnJson?.technologies) ?
105
141
  // learnJson.technologies :
@@ -125,6 +161,7 @@ export const handleAssetCreation = async (
125
161
  preview: learnJson.preview,
126
162
  readme_raw: b64IndexReadme,
127
163
  all_translations,
164
+ learnpack_id: learnpackId,
128
165
  }
129
166
  if (academyId !== undefined) {
130
167
  assetPayload.academy_id = academyId
@@ -148,14 +185,25 @@ export const handleAssetCreation = async (
148
185
  }
149
186
 
150
187
  Console.info("Asset exists, updating it")
151
- const asset = await api.updateAsset(sessionPayload.token, slug, {
188
+ const updatePayload: any = {
152
189
  graded: true,
153
190
  learnpack_deploy_url: learnpackDeployUrl,
154
191
  title: assetTitle,
155
192
  category: category,
156
193
  description: assetDescription,
157
194
  all_translations,
158
- })
195
+ learnpack_id: learnpackId,
196
+ }
197
+ // Only set academy when the asset has none yet and the user selected one
198
+ if (existingAcademyId === undefined && academyId !== undefined) {
199
+ updatePayload.academy_id = academyId
200
+ }
201
+
202
+ const asset = await api.updateAsset(
203
+ sessionPayload.token,
204
+ slug,
205
+ updatePayload
206
+ )
159
207
  try {
160
208
  await api.updateRigoPackage(
161
209
  sessionPayload.rigobotToken.trim(),
@@ -179,7 +227,9 @@ export const handleAssetCreation = async (
179
227
  const createMultiLangAssetFromDisk = async (
180
228
  sessionPayload: { token: string; rigobotToken: string },
181
229
  learnJson: any,
182
- deployUrl: string
230
+ deployUrl: string,
231
+ selectedAcademyId?: number,
232
+ existingAssetsInfo: ExistingAssetInfo[] = []
183
233
  ) => {
184
234
  const availableLangs = getAvailableLangs(learnJson)
185
235
 
@@ -190,6 +240,17 @@ const createMultiLangAssetFromDisk = async (
190
240
  return
191
241
  }
192
242
 
243
+ const learnpackId = await api.resolveLearnpackPackageId(
244
+ sessionPayload.rigobotToken,
245
+ learnJson.slug
246
+ )
247
+ if (learnpackId === null) {
248
+ Console.warning(
249
+ "Breathecode assets skipped: could not resolve Learnpack package id"
250
+ )
251
+ return
252
+ }
253
+
193
254
  const all_translations: string[] = []
194
255
  for (const lang of availableLangs) {
195
256
  const readmePath = path.join(
@@ -208,6 +269,7 @@ const createMultiLangAssetFromDisk = async (
208
269
  }
209
270
 
210
271
  const b64IndexReadme = Buffer.from(indexReadmeString).toString("base64")
272
+ const preflightInfo = existingAssetsInfo.find((a) => a.lang === lang)
211
273
 
212
274
  try {
213
275
  // eslint-disable-next-line no-await-in-loop
@@ -217,7 +279,9 @@ const createMultiLangAssetFromDisk = async (
217
279
  lang,
218
280
  deployUrl,
219
281
  b64IndexReadme,
220
- undefined,
282
+ selectedAcademyId,
283
+ learnpackId,
284
+ preflightInfo,
221
285
  all_translations
222
286
  )
223
287
 
@@ -406,6 +470,13 @@ class BuildCommand extends SessionCommand {
406
470
  this.configManager?.buildIndex()
407
471
  }
408
472
 
473
+ const learnJsonPath = path.join(process.cwd(), "learn.json")
474
+ if (!fs.existsSync(learnJsonPath)) {
475
+ this.error("learn.json not found")
476
+ }
477
+
478
+ const learnJson = JSON.parse(fs.readFileSync(learnJsonPath, "utf-8"))
479
+
409
480
  const academies = await api.listUserAcademies(sessionPayload.token)
410
481
 
411
482
  if (academies.length === 0) {
@@ -415,17 +486,36 @@ class BuildCommand extends SessionCommand {
415
486
  process.exit(1)
416
487
  }
417
488
 
418
- const { academy, category } = await selectAcademy(
419
- academies,
420
- sessionPayload.token
489
+ Console.info("Checking existing assets...")
490
+ const existingAssetsInfo = await getExistingAssetsInfo(
491
+ sessionPayload.token,
492
+ learnJson
421
493
  )
494
+ const academyMode = determinePublishAcademyMode(existingAssetsInfo)
422
495
 
423
- const learnJsonPath = path.join(process.cwd(), "learn.json")
424
- if (!fs.existsSync(learnJsonPath)) {
425
- this.error("learn.json not found")
426
- }
496
+ let selectedAcademyId: number | undefined
427
497
 
428
- const learnJson = JSON.parse(fs.readFileSync(learnJsonPath, "utf-8"))
498
+ if (academyMode.type === "conflict") {
499
+ Console.warning(
500
+ `Some of your assets are associated with different academies ` +
501
+ `(${academyMode.academies.join(", ")}). ` +
502
+ `Academy assignment will be skipped to avoid conflicts.`
503
+ )
504
+ } else if (academyMode.type === "locked") {
505
+ const lockedAcademy = academies.find(
506
+ (a) => a.id === academyMode.academyId
507
+ )
508
+ Console.info(
509
+ `This package is associated with academy: ${
510
+ lockedAcademy?.name ?? academyMode.academyId
511
+ }. Academy cannot be changed.`
512
+ )
513
+ selectedAcademyId = academyMode.academyId
514
+ } else {
515
+ // mode === "select": all existing assets have no academy, user picks one
516
+ const { academy } = await selectAcademy(academies, sessionPayload.token)
517
+ selectedAcademyId = academy?.id
518
+ }
429
519
 
430
520
  const zipFilePath = path.join(process.cwd(), `${learnJson.slug}.zip`)
431
521
 
@@ -595,7 +685,9 @@ class BuildCommand extends SessionCommand {
595
685
  await createMultiLangAssetFromDisk(
596
686
  { token: sessionPayload.token, rigobotToken: rigoToken },
597
687
  learnJson,
598
- res.data.url
688
+ res.data.url,
689
+ selectedAcademyId,
690
+ existingAssetsInfo
599
691
  )
600
692
  } catch (error) {
601
693
  if (axios.isAxiosError(error)) {
@@ -55,6 +55,8 @@ import api, {
55
55
  RIGOBOT_REALTIME_HOST,
56
56
  listUserAcademies,
57
57
  doesAssetExists,
58
+ type AssetSyncError,
59
+ resolveLearnpackPackageId,
58
60
  } from "../utils/api"
59
61
  import {
60
62
  createUploadMiddleware,
@@ -87,7 +89,8 @@ import { HistoryManager } from "../managers/historyManager"
87
89
  import { ReadmeHistoryService } from "../managers/readmeHistoryService"
88
90
  import { sanitizeReadmeNewlines } from "../utils/readmeSanitizer"
89
91
 
90
- const frontMatter = require("front-matter")
92
+ // eslint-disable-next-line
93
+ const frontMatter = require("front-matter");
91
94
 
92
95
  if (process.env.NEW_RELIC_ENABLED === "true") {
93
96
  require("newrelic")
@@ -582,6 +585,16 @@ const getLocalizedValue = (
582
585
  return typeof first === "string" ? first : ""
583
586
  }
584
587
 
588
+ function assetSyncErrorDetail(err: any): string {
589
+ if (typeof err?.detail === "string") return err.detail
590
+ if (typeof err?.message === "string") return err.message
591
+ try {
592
+ return JSON.stringify(err)
593
+ } catch {
594
+ return String(err)
595
+ }
596
+ }
597
+
585
598
  const createMultiLangAsset = async (
586
599
  bucket: Bucket,
587
600
  rigoToken: string,
@@ -590,12 +603,27 @@ const createMultiLangAsset = async (
590
603
  courseJson: any,
591
604
  deployUrl: string,
592
605
  academyId?: number
593
- ): Promise<{ errors: Array<{ lang: string; error: any }> }> => {
606
+ ): Promise<{ errors: AssetSyncError[] }> => {
607
+ const learnpackId = await resolveLearnpackPackageId(rigoToken, courseSlug)
608
+ if (learnpackId === null) {
609
+ return {
610
+ errors: [
611
+ {
612
+ kind: "package_error",
613
+ error: {
614
+ detail:
615
+ "Could not resolve Learnpack package id; assets not synced to Breathecode.",
616
+ },
617
+ },
618
+ ],
619
+ }
620
+ }
621
+
594
622
  const availableLangs = Object.keys(courseJson.title)
595
623
  console.log("AVAILABLE LANGUAGES to upload asset", availableLangs)
596
624
 
597
625
  const all_translations: string[] = []
598
- const errors: Array<{ lang: string; error: any }> = []
626
+ const errors: AssetSyncError[] = []
599
627
 
600
628
  for (const lang of availableLangs) {
601
629
  // eslint-disable-next-line no-await-in-loop
@@ -624,13 +652,16 @@ const createMultiLangAsset = async (
624
652
  deployUrl,
625
653
  b64IndexReadme,
626
654
  academyId,
655
+ learnpackId,
656
+ undefined,
627
657
  all_translations
628
658
  )
629
659
 
630
660
  if (!asset) {
631
661
  errors.push({
662
+ kind: "lang_error",
632
663
  lang,
633
- error: { detail: "Failed to create asset", status_code: 500 },
664
+ error: { detail: "Failed to create asset" },
634
665
  })
635
666
  console.log("No se pudo crear el asset, saltando idioma", lang)
636
667
  continue
@@ -642,7 +673,11 @@ const createMultiLangAsset = async (
642
673
  error && typeof error === "object" && "response" in error ?
643
674
  (error as any).response?.data || error :
644
675
  error
645
- errors.push({ lang, error: errorData })
676
+ errors.push({
677
+ kind: "lang_error",
678
+ lang,
679
+ error: { detail: assetSyncErrorDetail(errorData) },
680
+ })
646
681
  console.error(`Error creating asset for language ${lang}:`, error)
647
682
  }
648
683
  }
@@ -5451,30 +5486,56 @@ class ServeCommand extends SessionCommand {
5451
5486
  const { config } = configJson
5452
5487
 
5453
5488
  const availableLangs = Object.keys(config.title || {})
5454
- let academyId: number | null = null
5455
5489
  let isPublished = false
5456
5490
 
5457
- for (const lang of availableLangs) {
5458
- const assetTitle = getLocalizedValue(config.title, lang)
5459
- if (!assetTitle) continue
5491
+ // Collect academy ids from all existing assets
5492
+ const foundAcademyIds: number[] = []
5460
5493
 
5461
- let assetSlug = slugify(assetTitle).slice(0, 47)
5462
- assetSlug = `${assetSlug}-${lang}`
5494
+ const slugsToCheck = availableLangs
5495
+ .map(lang => {
5496
+ const assetTitle = getLocalizedValue(config.title, lang)
5497
+ if (!assetTitle) return null
5498
+ const assetSlug = slugify(assetTitle).slice(0, 47)
5499
+ return `${assetSlug}-${lang}`
5500
+ })
5501
+ .filter((slug): slug is string => slug !== null)
5463
5502
 
5464
- const { exists, academyId: existingAcademyId } =
5465
- // eslint-disable-next-line no-await-in-loop
5466
- await doesAssetExists(bcToken, assetSlug)
5503
+ const assetChecks = await Promise.all(
5504
+ slugsToCheck.map(slug => doesAssetExists(bcToken, slug))
5505
+ )
5467
5506
 
5507
+ for (const { exists, academyId: existingAcademyId } of assetChecks) {
5468
5508
  if (exists) {
5469
5509
  isPublished = true
5470
5510
  if (existingAcademyId !== undefined) {
5471
- academyId = existingAcademyId
5472
- break
5511
+ foundAcademyIds.push(existingAcademyId)
5473
5512
  }
5474
5513
  }
5475
5514
  }
5476
5515
 
5477
- return res.json({ academyId, isPublished })
5516
+ const uniqueAcademies = [...new Set(foundAcademyIds)]
5517
+
5518
+ type AcademyMode = "select" | "locked" | "conflict";
5519
+ let mode: AcademyMode
5520
+ let lockedAcademyId: number | undefined
5521
+ let conflictAcademies: number[] | undefined
5522
+
5523
+ if (uniqueAcademies.length === 0) {
5524
+ mode = "select"
5525
+ } else if (uniqueAcademies.length === 1) {
5526
+ mode = "locked"
5527
+ lockedAcademyId = uniqueAcademies[0]
5528
+ } else {
5529
+ mode = "conflict"
5530
+ conflictAcademies = uniqueAcademies
5531
+ }
5532
+
5533
+ return res.json({
5534
+ isPublished,
5535
+ mode,
5536
+ lockedAcademyId,
5537
+ conflictAcademies,
5538
+ })
5478
5539
  } catch (error) {
5479
5540
  console.error("Error fetching package academy:", error)
5480
5541
  return res.status(500).json({ error: (error as Error).message })
@@ -5592,38 +5653,76 @@ class ServeCommand extends SessionCommand {
5592
5653
  const archive = archiver("zip", { zlib: { level: 9 } })
5593
5654
 
5594
5655
  output.on("close", async () => {
5595
- // 10) Subir ZIP a RigoBot
5596
- const form = new FormData()
5597
- form.append("file", fs.createReadStream(zipPath))
5598
- form.append("config", JSON.stringify(config))
5599
-
5600
- const rigoRes = await axios.post(
5601
- `${RIGOBOT_HOST}/v1/learnpack/upload`,
5602
- form,
5603
- {
5604
- headers: {
5605
- ...form.getHeaders(),
5606
- Authorization: "Token " + rigoToken.trim(),
5607
- },
5608
- }
5609
- )
5656
+ let rigoPublishUrl: string | undefined
5657
+ try {
5658
+ // 10) Subir ZIP a RigoBot
5659
+ const form = new FormData()
5660
+ form.append("file", fs.createReadStream(zipPath))
5661
+ form.append("config", JSON.stringify(config))
5662
+
5663
+ const rigoRes = await axios.post(
5664
+ `${RIGOBOT_HOST}/v1/learnpack/upload`,
5665
+ form,
5666
+ {
5667
+ headers: {
5668
+ ...form.getHeaders(),
5669
+ Authorization: "Token " + rigoToken.trim(),
5670
+ },
5671
+ }
5672
+ )
5673
+ rigoPublishUrl = rigoRes.data.url
5610
5674
 
5611
- const assetResults = await createMultiLangAsset(
5612
- bucket,
5613
- rigoToken,
5614
- bcToken,
5615
- slug,
5616
- fullConfig.config,
5617
- rigoRes.data.url,
5618
- academyId
5619
- )
5675
+ let errors: AssetSyncError[]
5676
+ try {
5677
+ const assetResults = await createMultiLangAsset(
5678
+ bucket,
5679
+ rigoToken,
5680
+ bcToken,
5681
+ slug,
5682
+ fullConfig.config,
5683
+ rigoRes.data.url,
5684
+ academyId
5685
+ )
5686
+ errors = assetResults.errors
5687
+ } catch (error) {
5688
+ console.error("Asset sync failed unexpectedly:", error)
5689
+ errors = [
5690
+ {
5691
+ kind: "package_error",
5692
+ error: { detail: "Asset sync failed unexpectedly." },
5693
+ },
5694
+ ]
5695
+ }
5620
5696
 
5621
- rimraf.sync(tmpRoot)
5622
- console.log("RigoRes", rigoRes.data)
5623
- return res.json({
5624
- url: rigoRes.data.url,
5625
- errors: assetResults.errors,
5626
- })
5697
+ if (res.headersSent) return
5698
+ console.log("RigoRes", rigoRes.data)
5699
+ res.json({
5700
+ url: rigoPublishUrl,
5701
+ errors,
5702
+ })
5703
+ } catch (error) {
5704
+ console.error(error)
5705
+ if (res.headersSent) return
5706
+ if (rigoPublishUrl !== undefined) {
5707
+ res.json({
5708
+ url: rigoPublishUrl,
5709
+ errors: [
5710
+ {
5711
+ kind: "package_error",
5712
+ error: { detail: "Asset sync failed unexpectedly." },
5713
+ },
5714
+ ],
5715
+ })
5716
+ } else {
5717
+ res.status(500).json({ error: (error as Error).message })
5718
+ }
5719
+ } finally {
5720
+ try {
5721
+ rimraf.sync(tmpRoot)
5722
+ } catch (error) {
5723
+ console.error("rimraf tmpRoot:", error)
5724
+ }
5725
+ }
5627
5726
  })
5628
5727
 
5629
5728
  archive.on("error", err => {