@learnpack/learnpack 5.0.343 → 5.0.346

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.
@@ -10,7 +10,7 @@
10
10
  />
11
11
 
12
12
  <title>Learnpack Creator: Craft tutorials in seconds!</title>
13
- <script type="module" crossorigin src="/creator/assets/index-BhqDgBS9.js"></script>
13
+ <script type="module" crossorigin src="/creator/assets/index-DnthLsvb.js"></script>
14
14
  <link rel="stylesheet" crossorigin href="/creator/assets/index-CjddKHB_.css">
15
15
  </head>
16
16
  <body>
@@ -1,4 +1,4 @@
1
- export declare const RIGOBOT_HOST = "https://rigobot.herokuapp.com";
1
+ export declare const RIGOBOT_HOST: string;
2
2
  export declare const RIGOBOT_REALTIME_HOST = "https://ai.4geeks.com";
3
3
  type TConsumableSlug = "ai-conversation-message" | "ai-compilation" | "ai-tutorial-generation" | "ai-generation" | "learnpack-publish";
4
4
  export declare const countConsumables: (consumables: any, consumableSlug?: TConsumableSlug) => any;
package/lib/utils/api.js CHANGED
@@ -8,7 +8,7 @@ const axios_1 = require("axios");
8
8
  const dotenv = require("dotenv");
9
9
  dotenv.config();
10
10
  const HOST = "https://breathecode.herokuapp.com";
11
- exports.RIGOBOT_HOST = "https://rigobot.herokuapp.com";
11
+ exports.RIGOBOT_HOST = process.env.RIGOBOT_HOST || "https://rigobot.herokuapp.com";
12
12
  exports.RIGOBOT_REALTIME_HOST = "https://ai.4geeks.com";
13
13
  // export const RIGOBOT_REALTIME_HOST = "http://127.0.0.1:8003"
14
14
  // export const RIGOBOT_HOST = "https://rigobot-test-cca7d841c9d8.herokuapp.com"
@@ -278,36 +278,48 @@ const getConsumable = async (token, consumableSlug = "ai-generation") => {
278
278
  }
279
279
  };
280
280
  exports.getConsumable = getConsumable;
281
- const neededPermissions = [
282
- "add_asset",
283
- "change_asset",
284
- "view_asset",
285
- "delete_asset",
286
- ];
281
+ const CRUD_ASSET_CAPABILITY_URL = `${HOST}/v1/auth/user/me/capability/crud_asset`;
287
282
  const listUserAcademies = async (breathecodeToken) => {
288
- const url = "https://breathecode.herokuapp.com/v1/auth/user/me";
283
+ const meUrl = `${HOST}/v1/auth/user/me`;
289
284
  try {
290
- const response = await axios_1.default.get(url, {
285
+ const response = await axios_1.default.get(meUrl, {
291
286
  headers: {
292
287
  Authorization: `Token ${breathecodeToken}`,
293
288
  },
294
289
  });
295
290
  const data = response.data;
296
291
  const academiesMap = new Map();
292
+ if (!Array.isArray(data.roles)) {
293
+ return [];
294
+ }
297
295
  for (const role of data.roles) {
298
296
  const academy = role.academy;
297
+ if (!academy)
298
+ continue;
299
299
  if (!academiesMap.has(academy.id)) {
300
300
  academiesMap.set(academy.id, academy);
301
301
  }
302
302
  }
303
- const permissions = new Set(data.permissions.map((p) => p.codename));
304
- // Validate if the user has ALL the needed permissions
305
- const hasAllPermissions = neededPermissions.every(permission => permissions.has(permission));
306
- if (!hasAllPermissions) {
307
- // The user does not have all the needed permissions
308
- return [];
309
- }
310
- return [...academiesMap.values()];
303
+ const academies = [...academiesMap.values()];
304
+ const allowed = await Promise.all(academies.map(async (academy) => {
305
+ const capResponse = await axios_1.default.get(CRUD_ASSET_CAPABILITY_URL, {
306
+ headers: {
307
+ Authorization: `Token ${breathecodeToken}`,
308
+ Academy: academy.id,
309
+ },
310
+ validateStatus: () => true,
311
+ });
312
+ if (capResponse.status === 200) {
313
+ return academy;
314
+ }
315
+ if (capResponse.status !== 403) {
316
+ console.warn(`listUserAcademies: unexpected status ${capResponse.status} for academy ${academy.id}`);
317
+ }
318
+ return null;
319
+ }));
320
+ const filtered = allowed.filter((a) => a !== null);
321
+ filtered.sort((a, b) => a.name.localeCompare(b.name));
322
+ return filtered;
311
323
  }
312
324
  catch (error) {
313
325
  console.error("Failed to fetch user academies:", error);
@@ -330,6 +342,7 @@ const validateToken = async (token) => {
330
342
  };
331
343
  exports.validateToken = validateToken;
332
344
  const createAsset = async (token, asset) => {
345
+ var _a;
333
346
  const body = {
334
347
  slug: asset.slug,
335
348
  title: asset.title,
@@ -367,6 +380,7 @@ const createAsset = async (token, asset) => {
367
380
  url = `https://breathecode.herokuapp.com/v1/registry/academy/asset`;
368
381
  headers.Academy = String(asset.academy_id);
369
382
  }
383
+ console.log("[BC] POST", url, "| academy_id:", (_a = asset.academy_id) !== null && _a !== void 0 ? _a : "none", "| slug:", asset.slug);
370
384
  try {
371
385
  const response = await axios_1.default.post(url, body, { headers });
372
386
  return response.data;
@@ -398,10 +412,12 @@ const doesAssetExists = async (token, assetSlug) => {
398
412
  };
399
413
  exports.doesAssetExists = doesAssetExists;
400
414
  const updateAsset = async (token, assetSlug, asset) => {
415
+ var _a;
401
416
  const url = `https://breathecode.herokuapp.com/v1/registry/asset/me/${assetSlug}`;
402
417
  const headers = {
403
418
  Authorization: `Token ${token}`,
404
419
  };
420
+ console.log("[BC] PUT", url, "| academy_id:", (_a = asset.academy_id) !== null && _a !== void 0 ? _a : "none");
405
421
  try {
406
422
  const response = await axios_1.default.put(url, asset, { headers });
407
423
  return response.data;
@@ -460,7 +476,11 @@ const createRigoPackage = async (token, slug, config) => {
460
476
  }
461
477
  };
462
478
  let technologiesCache = [];
463
- /** Strip .env comments (e.g. "token # comment") so the token is sent without spaces. */
479
+ /**
480
+ * Strip .env comments (e.g. "token # comment") so the token is sent without spaces.
481
+ * @param token - Raw token value from env, possibly with trailing comment.
482
+ * @returns Trimmed token with inline `#` comments removed, or empty string if missing.
483
+ */
464
484
  function sanitizeToken(token) {
465
485
  if (!token)
466
486
  return "";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@learnpack/learnpack",
3
3
  "description": "Seamlessly build, sell and/or take interactive & auto-graded tutorials, start learning now or build a new tutorial to your audience.",
4
- "version": "5.0.343",
4
+ "version": "5.0.346",
5
5
  "author": "Alejandro Sanchez @alesanchezr",
6
6
  "contributors": [
7
7
  {
@@ -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,7 @@ export const handleAssetCreation = async (
61
109
  learnpackDeployUrl: string,
62
110
  b64IndexReadme: string,
63
111
  academyId: number | undefined,
112
+ preflightInfo?: ExistingAssetInfo,
64
113
  all_translations: string[] = []
65
114
  ) => {
66
115
  const category = "uncategorized"
@@ -83,23 +132,9 @@ export const handleAssetCreation = async (
83
132
  let slug = slugify(assetTitle).slice(0, 47)
84
133
  slug = `${slug}-${selectedLang}`
85
134
 
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
- }
135
+ // Use pre-flight info when available to avoid an extra GET request
136
+ const { exists, academyId: existingAcademyId } =
137
+ preflightInfo ?? (await api.doesAssetExists(sessionPayload.token, slug))
103
138
 
104
139
  // const technologies: unknown[] = Array.isArray(learnJson?.technologies) ?
105
140
  // learnJson.technologies :
@@ -148,14 +183,24 @@ export const handleAssetCreation = async (
148
183
  }
149
184
 
150
185
  Console.info("Asset exists, updating it")
151
- const asset = await api.updateAsset(sessionPayload.token, slug, {
186
+ const updatePayload: any = {
152
187
  graded: true,
153
188
  learnpack_deploy_url: learnpackDeployUrl,
154
189
  title: assetTitle,
155
190
  category: category,
156
191
  description: assetDescription,
157
192
  all_translations,
158
- })
193
+ }
194
+ // Only set academy when the asset has none yet and the user selected one
195
+ if (existingAcademyId === undefined && academyId !== undefined) {
196
+ updatePayload.academy_id = academyId
197
+ }
198
+
199
+ const asset = await api.updateAsset(
200
+ sessionPayload.token,
201
+ slug,
202
+ updatePayload
203
+ )
159
204
  try {
160
205
  await api.updateRigoPackage(
161
206
  sessionPayload.rigobotToken.trim(),
@@ -179,7 +224,9 @@ export const handleAssetCreation = async (
179
224
  const createMultiLangAssetFromDisk = async (
180
225
  sessionPayload: { token: string; rigobotToken: string },
181
226
  learnJson: any,
182
- deployUrl: string
227
+ deployUrl: string,
228
+ selectedAcademyId?: number,
229
+ existingAssetsInfo: ExistingAssetInfo[] = []
183
230
  ) => {
184
231
  const availableLangs = getAvailableLangs(learnJson)
185
232
 
@@ -208,6 +255,7 @@ const createMultiLangAssetFromDisk = async (
208
255
  }
209
256
 
210
257
  const b64IndexReadme = Buffer.from(indexReadmeString).toString("base64")
258
+ const preflightInfo = existingAssetsInfo.find((a) => a.lang === lang)
211
259
 
212
260
  try {
213
261
  // eslint-disable-next-line no-await-in-loop
@@ -217,7 +265,8 @@ const createMultiLangAssetFromDisk = async (
217
265
  lang,
218
266
  deployUrl,
219
267
  b64IndexReadme,
220
- undefined,
268
+ selectedAcademyId,
269
+ preflightInfo,
221
270
  all_translations
222
271
  )
223
272
 
@@ -406,6 +455,13 @@ class BuildCommand extends SessionCommand {
406
455
  this.configManager?.buildIndex()
407
456
  }
408
457
 
458
+ const learnJsonPath = path.join(process.cwd(), "learn.json")
459
+ if (!fs.existsSync(learnJsonPath)) {
460
+ this.error("learn.json not found")
461
+ }
462
+
463
+ const learnJson = JSON.parse(fs.readFileSync(learnJsonPath, "utf-8"))
464
+
409
465
  const academies = await api.listUserAcademies(sessionPayload.token)
410
466
 
411
467
  if (academies.length === 0) {
@@ -415,17 +471,36 @@ class BuildCommand extends SessionCommand {
415
471
  process.exit(1)
416
472
  }
417
473
 
418
- const { academy, category } = await selectAcademy(
419
- academies,
420
- sessionPayload.token
474
+ Console.info("Checking existing assets...")
475
+ const existingAssetsInfo = await getExistingAssetsInfo(
476
+ sessionPayload.token,
477
+ learnJson
421
478
  )
479
+ const academyMode = determinePublishAcademyMode(existingAssetsInfo)
422
480
 
423
- const learnJsonPath = path.join(process.cwd(), "learn.json")
424
- if (!fs.existsSync(learnJsonPath)) {
425
- this.error("learn.json not found")
426
- }
481
+ let selectedAcademyId: number | undefined
427
482
 
428
- const learnJson = JSON.parse(fs.readFileSync(learnJsonPath, "utf-8"))
483
+ if (academyMode.type === "conflict") {
484
+ Console.warning(
485
+ `Some of your assets are associated with different academies ` +
486
+ `(${academyMode.academies.join(", ")}). ` +
487
+ `Academy assignment will be skipped to avoid conflicts.`
488
+ )
489
+ } else if (academyMode.type === "locked") {
490
+ const lockedAcademy = academies.find(
491
+ (a) => a.id === academyMode.academyId
492
+ )
493
+ Console.info(
494
+ `This package is associated with academy: ${
495
+ lockedAcademy?.name ?? academyMode.academyId
496
+ }. Academy cannot be changed.`
497
+ )
498
+ selectedAcademyId = academyMode.academyId
499
+ } else {
500
+ // mode === "select": all existing assets have no academy, user picks one
501
+ const { academy } = await selectAcademy(academies, sessionPayload.token)
502
+ selectedAcademyId = academy?.id
503
+ }
429
504
 
430
505
  const zipFilePath = path.join(process.cwd(), `${learnJson.slug}.zip`)
431
506
 
@@ -595,7 +670,9 @@ class BuildCommand extends SessionCommand {
595
670
  await createMultiLangAssetFromDisk(
596
671
  { token: sessionPayload.token, rigobotToken: rigoToken },
597
672
  learnJson,
598
- res.data.url
673
+ res.data.url,
674
+ selectedAcademyId,
675
+ existingAssetsInfo
599
676
  )
600
677
  } catch (error) {
601
678
  if (axios.isAxiosError(error)) {