@learnpack/learnpack 5.0.344 → 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.
@@ -1,8 +1,14 @@
1
1
  import SessionCommand from "../utils/SessionCommand";
2
+ type ExistingAssetInfo = {
3
+ lang: string;
4
+ slug: string;
5
+ exists: boolean;
6
+ academyId?: number;
7
+ };
2
8
  export declare const handleAssetCreation: (sessionPayload: {
3
9
  token: string;
4
10
  rigobotToken: string;
5
- }, learnJson: any, selectedLang: string, learnpackDeployUrl: string, b64IndexReadme: string, academyId: number | undefined, all_translations?: string[]) => Promise<any>;
11
+ }, learnJson: any, selectedLang: string, learnpackDeployUrl: string, b64IndexReadme: string, academyId: number | undefined, preflightInfo?: ExistingAssetInfo, all_translations?: string[]) => Promise<any>;
6
12
  declare class BuildCommand extends SessionCommand {
7
13
  static description: string;
8
14
  static flags: {
@@ -45,7 +45,33 @@ const getLocalizedValue = (translations, lang, fallbackLangs = ["en", "us"]) =>
45
45
  const first = firstKey ? translations[firstKey] : "";
46
46
  return typeof first === "string" ? first : "";
47
47
  };
48
- const handleAssetCreation = async (sessionPayload, learnJson, selectedLang, learnpackDeployUrl, b64IndexReadme, academyId, all_translations = []) => {
48
+ const getExistingAssetsInfo = async (token, learnJson) => {
49
+ const availableLangs = getAvailableLangs(learnJson);
50
+ const results = [];
51
+ for (const lang of availableLangs) {
52
+ const assetTitle = getLocalizedValue(learnJson === null || learnJson === void 0 ? void 0 : learnJson.title, lang);
53
+ if (!assetTitle)
54
+ continue;
55
+ let slug = (0, creatorUtilities_1.slugify)(assetTitle).slice(0, 47);
56
+ slug = `${slug}-${lang}`;
57
+ // eslint-disable-next-line no-await-in-loop
58
+ const { exists, academyId } = await api_1.default.doesAssetExists(token, slug);
59
+ results.push({ lang, slug, exists, academyId });
60
+ }
61
+ return results;
62
+ };
63
+ const determinePublishAcademyMode = (existingAssets) => {
64
+ const academyIds = existingAssets
65
+ .filter((a) => a.exists && a.academyId !== undefined)
66
+ .map((a) => a.academyId);
67
+ const unique = [...new Set(academyIds)];
68
+ if (unique.length === 0)
69
+ return { type: "select" };
70
+ if (unique.length === 1)
71
+ return { type: "locked", academyId: unique[0] };
72
+ return { type: "conflict", academies: unique };
73
+ };
74
+ const handleAssetCreation = async (sessionPayload, learnJson, selectedLang, learnpackDeployUrl, b64IndexReadme, academyId, preflightInfo, all_translations = []) => {
49
75
  const category = "uncategorized";
50
76
  try {
51
77
  const user = await api_1.default.validateToken(sessionPayload.token);
@@ -56,15 +82,8 @@ const handleAssetCreation = async (sessionPayload, learnJson, selectedLang, lear
56
82
  }
57
83
  let slug = (0, creatorUtilities_1.slugify)(assetTitle).slice(0, 47);
58
84
  slug = `${slug}-${selectedLang}`;
59
- const { exists, academyId: existingAcademyId } = await api_1.default.doesAssetExists(sessionPayload.token, slug);
60
- // Compare academy IDs if asset exists and academyId is provided
61
- if (exists &&
62
- existingAcademyId !== undefined &&
63
- academyId !== undefined &&
64
- existingAcademyId !== academyId) {
65
- console_1.default.warning(`Asset exists in academy ${existingAcademyId}, but attempting to publish to academy ${academyId}. ` +
66
- `The asset will be updated in its current academy (${existingAcademyId}).`);
67
- }
85
+ // Use pre-flight info when available to avoid an extra GET request
86
+ const { exists, academyId: existingAcademyId } = preflightInfo !== null && preflightInfo !== void 0 ? preflightInfo : (await api_1.default.doesAssetExists(sessionPayload.token, slug));
68
87
  // const technologies: unknown[] = Array.isArray(learnJson?.technologies) ?
69
88
  // learnJson.technologies :
70
89
  // []
@@ -105,14 +124,19 @@ const handleAssetCreation = async (sessionPayload, learnJson, selectedLang, lear
105
124
  return asset;
106
125
  }
107
126
  console_1.default.info("Asset exists, updating it");
108
- const asset = await api_1.default.updateAsset(sessionPayload.token, slug, {
127
+ const updatePayload = {
109
128
  graded: true,
110
129
  learnpack_deploy_url: learnpackDeployUrl,
111
130
  title: assetTitle,
112
131
  category: category,
113
132
  description: assetDescription,
114
133
  all_translations,
115
- });
134
+ };
135
+ // Only set academy when the asset has none yet and the user selected one
136
+ if (existingAcademyId === undefined && academyId !== undefined) {
137
+ updatePayload.academy_id = academyId;
138
+ }
139
+ const asset = await api_1.default.updateAsset(sessionPayload.token, slug, updatePayload);
116
140
  try {
117
141
  await api_1.default.updateRigoPackage(sessionPayload.rigobotToken.trim(), learnJson.slug, {
118
142
  asset_id: asset.id,
@@ -130,7 +154,7 @@ const handleAssetCreation = async (sessionPayload, learnJson, selectedLang, lear
130
154
  }
131
155
  };
132
156
  exports.handleAssetCreation = handleAssetCreation;
133
- const createMultiLangAssetFromDisk = async (sessionPayload, learnJson, deployUrl) => {
157
+ const createMultiLangAssetFromDisk = async (sessionPayload, learnJson, deployUrl, selectedAcademyId, existingAssetsInfo = []) => {
134
158
  const availableLangs = getAvailableLangs(learnJson);
135
159
  if (availableLangs.length === 0) {
136
160
  console_1.default.error("No languages found in learn.json.title. Add at least one language (e.g. title.en).");
@@ -150,9 +174,10 @@ const createMultiLangAssetFromDisk = async (sessionPayload, learnJson, deployUrl
150
174
  indexReadmeString = "";
151
175
  }
152
176
  const b64IndexReadme = Buffer.from(indexReadmeString).toString("base64");
177
+ const preflightInfo = existingAssetsInfo.find((a) => a.lang === lang);
153
178
  try {
154
179
  // eslint-disable-next-line no-await-in-loop
155
- const asset = await (0, exports.handleAssetCreation)(sessionPayload, learnJson, lang, deployUrl, b64IndexReadme, undefined, all_translations);
180
+ const asset = await (0, exports.handleAssetCreation)(sessionPayload, learnJson, lang, deployUrl, b64IndexReadme, selectedAcademyId, preflightInfo, all_translations);
156
181
  if (!asset) {
157
182
  console_1.default.debug("Could not create/update asset for lang", lang);
158
183
  continue;
@@ -230,7 +255,7 @@ class BuildCommand extends SessionCommand_1.default {
230
255
  await this.initSession(flags);
231
256
  }
232
257
  async run() {
233
- var _a, _b, _c;
258
+ var _a, _b, _c, _d;
234
259
  const buildDir = path.join(process.cwd(), "build");
235
260
  const { flags } = this.parse(BuildCommand);
236
261
  const strict = flags.strict;
@@ -269,17 +294,35 @@ class BuildCommand extends SessionCommand_1.default {
269
294
  console_1.default.debug("Building exercises");
270
295
  (_c = this.configManager) === null || _c === void 0 ? void 0 : _c.buildIndex();
271
296
  }
297
+ const learnJsonPath = path.join(process.cwd(), "learn.json");
298
+ if (!fs.existsSync(learnJsonPath)) {
299
+ this.error("learn.json not found");
300
+ }
301
+ const learnJson = JSON.parse(fs.readFileSync(learnJsonPath, "utf-8"));
272
302
  const academies = await api_1.default.listUserAcademies(sessionPayload.token);
273
303
  if (academies.length === 0) {
274
304
  console_1.default.error("It seems you cannot publish tutorials. Make sure you creator subscription is up to date here: https://4geeks.com/profile/subscriptions. If you believe there is an issue you can always contact support@4geeks.com");
275
305
  process.exit(1);
276
306
  }
277
- const { academy, category } = await selectAcademy(academies, sessionPayload.token);
278
- const learnJsonPath = path.join(process.cwd(), "learn.json");
279
- if (!fs.existsSync(learnJsonPath)) {
280
- this.error("learn.json not found");
307
+ console_1.default.info("Checking existing assets...");
308
+ const existingAssetsInfo = await getExistingAssetsInfo(sessionPayload.token, learnJson);
309
+ const academyMode = determinePublishAcademyMode(existingAssetsInfo);
310
+ let selectedAcademyId;
311
+ if (academyMode.type === "conflict") {
312
+ console_1.default.warning(`Some of your assets are associated with different academies ` +
313
+ `(${academyMode.academies.join(", ")}). ` +
314
+ `Academy assignment will be skipped to avoid conflicts.`);
315
+ }
316
+ else if (academyMode.type === "locked") {
317
+ const lockedAcademy = academies.find((a) => a.id === academyMode.academyId);
318
+ console_1.default.info(`This package is associated with academy: ${(_d = lockedAcademy === null || lockedAcademy === void 0 ? void 0 : lockedAcademy.name) !== null && _d !== void 0 ? _d : academyMode.academyId}. Academy cannot be changed.`);
319
+ selectedAcademyId = academyMode.academyId;
320
+ }
321
+ else {
322
+ // mode === "select": all existing assets have no academy, user picks one
323
+ const { academy } = await selectAcademy(academies, sessionPayload.token);
324
+ selectedAcademyId = academy === null || academy === void 0 ? void 0 : academy.id;
281
325
  }
282
- const learnJson = JSON.parse(fs.readFileSync(learnJsonPath, "utf-8"));
283
326
  const zipFilePath = path.join(process.cwd(), `${learnJson.slug}.zip`);
284
327
  // Ensure build directory exists
285
328
  if (!fs.existsSync(buildDir)) {
@@ -405,7 +448,7 @@ class BuildCommand extends SessionCommand_1.default {
405
448
  console.log(res.data);
406
449
  fs.unlinkSync(zipFilePath);
407
450
  this.removeDirectory(buildDir);
408
- await createMultiLangAssetFromDisk({ token: sessionPayload.token, rigobotToken: rigoToken }, learnJson, res.data.url);
451
+ await createMultiLangAssetFromDisk({ token: sessionPayload.token, rigobotToken: rigoToken }, learnJson, res.data.url, selectedAcademyId, existingAssetsInfo);
409
452
  }
410
453
  catch (error) {
411
454
  if (axios_1.default.isAxiosError(error)) {
@@ -39,6 +39,7 @@ const redis_1 = require("redis");
39
39
  const historyManager_1 = require("../managers/historyManager");
40
40
  const readmeHistoryService_1 = require("../managers/readmeHistoryService");
41
41
  const readmeSanitizer_1 = require("../utils/readmeSanitizer");
42
+ // eslint-disable-next-line
42
43
  const frontMatter = require("front-matter");
43
44
  if (process.env.NEW_RELIC_ENABLED === "true") {
44
45
  require("newrelic");
@@ -387,7 +388,7 @@ const createMultiLangAsset = async (bucket, rigoToken, bcToken, courseSlug, cour
387
388
  const b64IndexReadme = buffer_1.Buffer.from(indexReadmeString).toString("base64");
388
389
  try {
389
390
  // eslint-disable-next-line no-await-in-loop
390
- const asset = await (0, publish_1.handleAssetCreation)({ token: bcToken, rigobotToken: rigoToken.trim() }, courseJson, lang, deployUrl, b64IndexReadme, academyId, all_translations);
391
+ const asset = await (0, publish_1.handleAssetCreation)({ token: bcToken, rigobotToken: rigoToken.trim() }, courseJson, lang, deployUrl, b64IndexReadme, academyId, undefined, all_translations);
391
392
  if (!asset) {
392
393
  errors.push({
393
394
  lang,
@@ -3780,26 +3781,48 @@ class ServeCommand extends SessionCommand_1.default {
3780
3781
  const configJson = JSON.parse(configContent.toString());
3781
3782
  const { config } = configJson;
3782
3783
  const availableLangs = Object.keys(config.title || {});
3783
- let academyId = null;
3784
3784
  let isPublished = false;
3785
- for (const lang of availableLangs) {
3785
+ // Collect academy ids from all existing assets
3786
+ const foundAcademyIds = [];
3787
+ const slugsToCheck = availableLangs
3788
+ .map(lang => {
3786
3789
  const assetTitle = getLocalizedValue(config.title, lang);
3787
3790
  if (!assetTitle)
3788
- continue;
3789
- let assetSlug = (0, creatorUtilities_2.slugify)(assetTitle).slice(0, 47);
3790
- assetSlug = `${assetSlug}-${lang}`;
3791
- const { exists, academyId: existingAcademyId } =
3792
- // eslint-disable-next-line no-await-in-loop
3793
- await (0, api_1.doesAssetExists)(bcToken, assetSlug);
3791
+ return null;
3792
+ const assetSlug = (0, creatorUtilities_2.slugify)(assetTitle).slice(0, 47);
3793
+ return `${assetSlug}-${lang}`;
3794
+ })
3795
+ .filter((slug) => slug !== null);
3796
+ const assetChecks = await Promise.all(slugsToCheck.map(slug => (0, api_1.doesAssetExists)(bcToken, slug)));
3797
+ for (const { exists, academyId: existingAcademyId } of assetChecks) {
3794
3798
  if (exists) {
3795
3799
  isPublished = true;
3796
3800
  if (existingAcademyId !== undefined) {
3797
- academyId = existingAcademyId;
3798
- break;
3801
+ foundAcademyIds.push(existingAcademyId);
3799
3802
  }
3800
3803
  }
3801
3804
  }
3802
- return res.json({ academyId, isPublished });
3805
+ const uniqueAcademies = [...new Set(foundAcademyIds)];
3806
+ let mode;
3807
+ let lockedAcademyId;
3808
+ let conflictAcademies;
3809
+ if (uniqueAcademies.length === 0) {
3810
+ mode = "select";
3811
+ }
3812
+ else if (uniqueAcademies.length === 1) {
3813
+ mode = "locked";
3814
+ lockedAcademyId = uniqueAcademies[0];
3815
+ }
3816
+ else {
3817
+ mode = "conflict";
3818
+ conflictAcademies = uniqueAcademies;
3819
+ }
3820
+ return res.json({
3821
+ isPublished,
3822
+ mode,
3823
+ lockedAcademyId,
3824
+ conflictAcademies,
3825
+ });
3803
3826
  }
3804
3827
  catch (error) {
3805
3828
  console.error("Error fetching package academy:", error);
package/lib/utils/api.js CHANGED
@@ -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.344",
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)) {
@@ -87,7 +87,8 @@ import { HistoryManager } from "../managers/historyManager"
87
87
  import { ReadmeHistoryService } from "../managers/readmeHistoryService"
88
88
  import { sanitizeReadmeNewlines } from "../utils/readmeSanitizer"
89
89
 
90
- const frontMatter = require("front-matter")
90
+ // eslint-disable-next-line
91
+ const frontMatter = require("front-matter");
91
92
 
92
93
  if (process.env.NEW_RELIC_ENABLED === "true") {
93
94
  require("newrelic")
@@ -624,6 +625,7 @@ const createMultiLangAsset = async (
624
625
  deployUrl,
625
626
  b64IndexReadme,
626
627
  academyId,
628
+ undefined,
627
629
  all_translations
628
630
  )
629
631
 
@@ -5451,30 +5453,56 @@ class ServeCommand extends SessionCommand {
5451
5453
  const { config } = configJson
5452
5454
 
5453
5455
  const availableLangs = Object.keys(config.title || {})
5454
- let academyId: number | null = null
5455
5456
  let isPublished = false
5456
5457
 
5457
- for (const lang of availableLangs) {
5458
- const assetTitle = getLocalizedValue(config.title, lang)
5459
- if (!assetTitle) continue
5458
+ // Collect academy ids from all existing assets
5459
+ const foundAcademyIds: number[] = []
5460
5460
 
5461
- let assetSlug = slugify(assetTitle).slice(0, 47)
5462
- assetSlug = `${assetSlug}-${lang}`
5461
+ const slugsToCheck = availableLangs
5462
+ .map(lang => {
5463
+ const assetTitle = getLocalizedValue(config.title, lang)
5464
+ if (!assetTitle) return null
5465
+ const assetSlug = slugify(assetTitle).slice(0, 47)
5466
+ return `${assetSlug}-${lang}`
5467
+ })
5468
+ .filter((slug): slug is string => slug !== null)
5463
5469
 
5464
- const { exists, academyId: existingAcademyId } =
5465
- // eslint-disable-next-line no-await-in-loop
5466
- await doesAssetExists(bcToken, assetSlug)
5470
+ const assetChecks = await Promise.all(
5471
+ slugsToCheck.map(slug => doesAssetExists(bcToken, slug))
5472
+ )
5467
5473
 
5474
+ for (const { exists, academyId: existingAcademyId } of assetChecks) {
5468
5475
  if (exists) {
5469
5476
  isPublished = true
5470
5477
  if (existingAcademyId !== undefined) {
5471
- academyId = existingAcademyId
5472
- break
5478
+ foundAcademyIds.push(existingAcademyId)
5473
5479
  }
5474
5480
  }
5475
5481
  }
5476
5482
 
5477
- return res.json({ academyId, isPublished })
5483
+ const uniqueAcademies = [...new Set(foundAcademyIds)]
5484
+
5485
+ type AcademyMode = "select" | "locked" | "conflict";
5486
+ let mode: AcademyMode
5487
+ let lockedAcademyId: number | undefined
5488
+ let conflictAcademies: number[] | undefined
5489
+
5490
+ if (uniqueAcademies.length === 0) {
5491
+ mode = "select"
5492
+ } else if (uniqueAcademies.length === 1) {
5493
+ mode = "locked"
5494
+ lockedAcademyId = uniqueAcademies[0]
5495
+ } else {
5496
+ mode = "conflict"
5497
+ conflictAcademies = uniqueAcademies
5498
+ }
5499
+
5500
+ return res.json({
5501
+ isPublished,
5502
+ mode,
5503
+ lockedAcademyId,
5504
+ conflictAcademies,
5505
+ })
5478
5506
  } catch (error) {
5479
5507
  console.error("Error fetching package academy:", error)
5480
5508
  return res.status(500).json({ error: (error as Error).message })