@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.
package/lib/utils/api.js CHANGED
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getCurrentTechnologies = exports.fetchTechnologies = exports.doesAssetExists = exports.createAsset = exports.validateToken = exports.listUserAcademies = exports.getConsumable = exports.countConsumables = exports.RIGOBOT_REALTIME_HOST = exports.RIGOBOT_HOST = exports.BREATHECODE_EVENTS_URL = exports.BREATHECODE_TELEMETRY_URL = void 0;
4
- exports.resolveLearnpackPackageId = resolveLearnpackPackageId;
3
+ exports.getCurrentTechnologies = exports.fetchTechnologies = exports.getAssetBySlug = exports.getAssetById = exports.createAsset = exports.validateToken = exports.listUserAcademies = exports.getConsumable = exports.countConsumables = exports.RIGOBOT_REALTIME_HOST = exports.RIGOBOT_HOST = exports.BREATHECODE_EVENTS_URL = exports.BREATHECODE_TELEMETRY_URL = exports.BREATHECODE_HOST = void 0;
4
+ exports.describeRequestError = describeRequestError;
5
+ exports.getRigoPackage = getRigoPackage;
5
6
  const console_1 = require("../utils/console");
6
7
  const storage = require("node-persist");
7
8
  const cli_ux_1 = require("cli-ux");
@@ -9,6 +10,7 @@ const axios_1 = require("axios");
9
10
  const dotenv = require("dotenv");
10
11
  dotenv.config();
11
12
  const HOST = "https://breathecode.herokuapp.com";
13
+ exports.BREATHECODE_HOST = HOST;
12
14
  /**
13
15
  * Student telemetry, and publication events. Both share the endpoint: a body
14
16
  * carrying a top-level `event` key is dispatched as an event, anything else
@@ -349,33 +351,53 @@ const validateToken = async (token) => {
349
351
  }
350
352
  };
351
353
  exports.validateToken = validateToken;
352
- function parseLearnpackPackageId(raw) {
353
- if (raw === undefined || raw === null)
354
- return null;
355
- const n = typeof raw === "number" ? raw : Number(raw);
356
- if (!Number.isFinite(n) || !Number.isInteger(n))
357
- return null;
358
- return n;
354
+ /**
355
+ * Human-readable summary of a failed axios call, for warnings and error payloads.
356
+ * @param error - Whatever the request rejected with.
357
+ * @returns "HTTP <status>" when the server answered, the error message otherwise.
358
+ */
359
+ function describeRequestError(error) {
360
+ var _a, _b, _c, _d;
361
+ if (axios_1.default.isAxiosError(error)) {
362
+ if (error.response) {
363
+ const detail = (_d = (_b = (_a = error.response.data) === null || _a === void 0 ? void 0 : _a.detail) !== null && _b !== void 0 ? _b : (_c = error.response.data) === null || _c === void 0 ? void 0 : _c.error) !== null && _d !== void 0 ? _d : "";
364
+ return `HTTP ${error.response.status}${detail ? ` (${detail})` : ""}`;
365
+ }
366
+ return error.message;
367
+ }
368
+ return error instanceof Error ? error.message : String(error);
359
369
  }
360
370
  /**
361
- * GET Rigobot package by slug after a successful upload. Does not throw.
362
- * @param rigoToken Rigobot API token (Bearer-style `Token` header value).
371
+ * GET a Rigobot package by slug. Distinguishes "no such package" from
372
+ * "could not check", so callers can fail closed on the latter.
373
+ * @param rigoToken Rigobot API token (`Token` header value).
363
374
  * @param courseSlug Learnpack package slug used in the Rigobot URL path.
364
- * @returns Resolved numeric package id from Rigobot, or `null` if the request fails, the id is missing, or it is not a finite integer.
375
+ * @returns The package id and its asset_ids, `missing` on 404, `error` otherwise (403 included: a package owned by somebody else is not ours to read).
365
376
  */
366
- async function resolveLearnpackPackageId(rigoToken, courseSlug) {
367
- var _a;
368
- if (!(rigoToken === null || rigoToken === void 0 ? void 0 : rigoToken.trim()) || !courseSlug)
369
- return null;
377
+ async function getRigoPackage(rigoToken, courseSlug) {
378
+ var _a, _b, _c;
379
+ const cleanToken = rigoToken.replace(/[\n\r]/g, "").trim();
370
380
  const url = `${exports.RIGOBOT_HOST}/v1/learnpack/package/${encodeURIComponent(courseSlug)}/`;
371
381
  try {
372
382
  const response = await axios_1.default.get(url, {
373
- headers: { Authorization: `Token ${rigoToken.trim()}` },
383
+ headers: { Authorization: `Token ${cleanToken}` },
374
384
  });
375
- return parseLearnpackPackageId((_a = response.data) === null || _a === void 0 ? void 0 : _a.id);
385
+ const rawId = (_a = response.data) === null || _a === void 0 ? void 0 : _a.id;
386
+ const id = typeof rawId === "number" ? rawId : Number(rawId);
387
+ if (!Number.isInteger(id)) {
388
+ return { kind: "error", error: "Rigobot package has no numeric id" };
389
+ }
390
+ const rawIds = (_b = response.data) === null || _b === void 0 ? void 0 : _b.asset_ids;
391
+ const assetIds = Array.isArray(rawIds) ?
392
+ rawIds.filter((x) => Number.isInteger(x)) :
393
+ [];
394
+ return { kind: "found", id, assetIds };
376
395
  }
377
- catch (_b) {
378
- return null;
396
+ catch (error) {
397
+ if (axios_1.default.isAxiosError(error) && ((_c = error.response) === null || _c === void 0 ? void 0 : _c.status) === 404) {
398
+ return { kind: "missing" };
399
+ }
400
+ return { kind: "error", error: describeRequestError(error) };
379
401
  }
380
402
  }
381
403
  const createAsset = async (token, asset) => {
@@ -429,43 +451,91 @@ const createAsset = async (token, asset) => {
429
451
  }
430
452
  };
431
453
  exports.createAsset = createAsset;
432
- const doesAssetExists = async (token, assetSlug) => {
433
- var _a, _b, _c;
434
- const url = `https://breathecode.herokuapp.com/v1/registry/asset/${assetSlug}`;
435
- const headers = {
436
- Authorization: `Token ${token}`,
454
+ const asIntegerOrNull = (raw) => {
455
+ if (raw === null || raw === undefined || raw === "")
456
+ return null;
457
+ const n = typeof raw === "number" ? raw : Number(raw);
458
+ return Number.isInteger(n) ? n : null;
459
+ };
460
+ const toBcAsset = (data) => {
461
+ var _a, _b, _c, _d;
462
+ if (!data || typeof data !== "object")
463
+ return null;
464
+ const id = asIntegerOrNull(data.id);
465
+ if (id === null || typeof data.slug !== "string")
466
+ return null;
467
+ return {
468
+ id,
469
+ slug: data.slug,
470
+ lang: typeof data.lang === "string" && data.lang ? data.lang : null,
471
+ status: typeof data.status === "string" ? data.status : null,
472
+ learnpackId: asIntegerOrNull(data.learnpack_id),
473
+ learnpackDeployUrl: typeof data.learnpack_deploy_url === "string" ?
474
+ data.learnpack_deploy_url :
475
+ null,
476
+ ownerId: asIntegerOrNull((_b = (_a = data.owner) === null || _a === void 0 ? void 0 : _a.id) !== null && _b !== void 0 ? _b : data.owner),
477
+ academyId: asIntegerOrNull((_d = (_c = data.academy) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : data.academy_id),
437
478
  };
479
+ };
480
+ const lookupAsset = async (token, slugOrId) => {
481
+ var _a;
482
+ const url = `${HOST}/v1/registry/asset/${encodeURIComponent(slugOrId)}`;
438
483
  try {
439
- const response = await axios_1.default.get(url, { headers });
440
- if (response.status === 200) {
441
- const academyId = ((_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.academy) === null || _b === void 0 ? void 0 : _b.id) || ((_c = response.data) === null || _c === void 0 ? void 0 : _c.academy_id);
442
- return { exists: true, academyId };
484
+ const response = await axios_1.default.get(url, {
485
+ headers: { Authorization: `Token ${token}` },
486
+ });
487
+ const asset = toBcAsset(response.data);
488
+ if (asset === null) {
489
+ // A 200 that is not an asset (e.g. the readme renderer answering for a
490
+ // path with a period) proves nothing either way: fail closed.
491
+ return {
492
+ kind: "error",
493
+ error: `Unexpected response for asset ${slugOrId}`,
494
+ };
443
495
  }
444
- return { exists: false };
496
+ return { kind: "found", asset };
445
497
  }
446
- catch (_d) {
447
- // console.error("Failed to get asset:", error)
448
- return { exists: false };
498
+ catch (error) {
499
+ if (axios_1.default.isAxiosError(error) && ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) === 404) {
500
+ return { kind: "missing" };
501
+ }
502
+ return { kind: "error", error: describeRequestError(error) };
449
503
  }
450
504
  };
451
- exports.doesAssetExists = doesAssetExists;
452
- const updateAsset = async (token, assetSlug, asset) => {
453
- var _a;
454
- const url = `https://breathecode.herokuapp.com/v1/registry/asset/me/${assetSlug}`;
505
+ /**
506
+ * GET a Breathecode asset by numeric id. The endpoint applies no status or
507
+ * visibility filter for a single asset, so this is the stable anchor.
508
+ * @param token Breathecode token.
509
+ * @param assetId Asset primary key.
510
+ * @returns `found` with the asset, `missing` on 404, `error` when it could not be checked.
511
+ */
512
+ const getAssetById = (token, assetId) => lookupAsset(token, String(assetId));
513
+ exports.getAssetById = getAssetById;
514
+ /**
515
+ * GET a Breathecode asset by slug.
516
+ * @param token Breathecode token.
517
+ * @param assetSlug Asset slug.
518
+ * @returns `found` with the asset, `missing` on 404, `error` when it could not be checked.
519
+ */
520
+ const getAssetBySlug = (token, assetSlug) => lookupAsset(token, assetSlug);
521
+ exports.getAssetBySlug = getAssetBySlug;
522
+ const updateAsset = async (token, assetId, asset) => {
523
+ var _a, _b, _c;
524
+ // The list form of PUT /asset/me updates by id, so a slug rename or an
525
+ // alias never sends the update to the wrong asset.
526
+ const url = `${HOST}/v1/registry/asset/me`;
455
527
  const headers = {
456
528
  Authorization: `Token ${token}`,
457
529
  };
458
- const body = Object.assign(Object.assign({}, asset), { learnpack_id: asset.learnpack_id });
459
- console.log("[BC] PUT", url, "| academy_id:", (_a = asset.academy_id) !== null && _a !== void 0 ? _a : "none");
530
+ const body = [Object.assign(Object.assign({}, asset), { id: assetId, learnpack_id: asset.learnpack_id })];
531
+ console.log("[BC] PUT", url, "| id:", assetId, "| academy_id:", (_a = asset.academy_id) !== null && _a !== void 0 ? _a : "none");
460
532
  try {
461
533
  const response = await axios_1.default.put(url, body, { headers });
462
- return response.data;
534
+ return Array.isArray(response.data) ? response.data[0] : response.data;
463
535
  }
464
536
  catch (error) {
465
537
  console.error("Failed to update asset:", error);
466
- // Try to print the data
467
- // console.log(error.response.data)
468
- throw error.response.data;
538
+ throw (_c = (_b = error.response) === null || _b === void 0 ? void 0 : _b.data) !== null && _c !== void 0 ? _c : error;
469
539
  }
470
540
  };
471
541
  const getCategories = async (token) => {
@@ -482,6 +552,14 @@ const getCategories = async (token) => {
482
552
  throw error;
483
553
  }
484
554
  };
555
+ /**
556
+ * PUT on the Rigobot package. `asset_id` appends one id to asset_ids (Rigobot
557
+ * side), `asset_ids` replaces the whole list.
558
+ * @param token Rigobot token.
559
+ * @param slug Package slug.
560
+ * @param updates Fields to write.
561
+ * @returns The updated package payload.
562
+ */
485
563
  const updateRigoPackage = async (token, slug, updates) => {
486
564
  const cleanToken = token.replace(/[\n\r]/g, "");
487
565
  const url = `${exports.RIGOBOT_HOST}/v1/learnpack/package/${slug}/`;
@@ -588,9 +666,10 @@ exports.default = {
588
666
  listUserAcademies: exports.listUserAcademies,
589
667
  validateToken: exports.validateToken,
590
668
  createAsset: exports.createAsset,
591
- doesAssetExists: exports.doesAssetExists,
669
+ getAssetById: exports.getAssetById,
670
+ getAssetBySlug: exports.getAssetBySlug,
592
671
  updateAsset,
593
- resolveLearnpackPackageId,
672
+ getRigoPackage,
594
673
  getCategories,
595
674
  updateRigoPackage,
596
675
  createRigoPackage,
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Resolves which Breathecode assets already represent a Learnpack package,
3
+ * one per language, before publish decides whether to create or update.
4
+ *
5
+ * The anchor is the package's `asset_ids` in Rigobot, read back by id from
6
+ * Breathecode. The title-derived slug is only a fallback for packages that
7
+ * predate `asset_ids`; it is never the criterion for a package whose ids are
8
+ * known. Every "could not check" outcome is reported as such, never as "does
9
+ * not exist", so a network hiccup cannot turn into a duplicate asset.
10
+ */
11
+ import api, { BcAsset } from "./api";
12
+ export type LangResolution = {
13
+ status: "found";
14
+ asset: BcAsset;
15
+ via: "id" | "slug";
16
+ } | {
17
+ status: "missing";
18
+ } | {
19
+ status: "ambiguous";
20
+ candidates: BcAsset[];
21
+ } | {
22
+ status: "unknown";
23
+ error: string;
24
+ };
25
+ export type PackageAssetsResolution = {
26
+ packageSlug: string;
27
+ /** Rigobot package id, or null when the package does not exist yet (or no Rigobot token was given). */
28
+ learnpackId: number | null;
29
+ /** `unknown` when Rigobot or Breathecode could not be read: nothing may be created or written. */
30
+ packageState: "ok" | "unknown";
31
+ packageError?: string;
32
+ /** Ids from Rigobot that still resolve to an asset of this package. */
33
+ knownAssetIds: number[];
34
+ /** Ids from Rigobot whose asset has no language: never adopted, but never dropped either. */
35
+ orphanIds: number[];
36
+ /** Keyed by canonical language (see canonicalLang). */
37
+ byLang: Map<string, LangResolution>;
38
+ warnings: string[];
39
+ };
40
+ export type AcademyMode = {
41
+ type: "select";
42
+ } | {
43
+ type: "locked";
44
+ academyId: number;
45
+ } | {
46
+ type: "conflict";
47
+ academies: number[];
48
+ };
49
+ /**
50
+ * Breathecode stores "en" as "us"; both name the same language.
51
+ * @param lang - Language code as found in learn.json or on an asset.
52
+ * @returns The canonical code, or null when there is none.
53
+ */
54
+ export declare const canonicalLang: (lang?: string | null) => string | null;
55
+ /**
56
+ * Language keys of learn.json.title, in declaration order.
57
+ * @param learnJson - Parsed learn.json.
58
+ * @returns Non-empty string keys of `title`.
59
+ */
60
+ export declare const getAvailableLangs: (learnJson: any) => string[];
61
+ /**
62
+ * Drops keys that name the same language ("en" and "us"), keeping "en" when
63
+ * both are present and the first one declared otherwise.
64
+ * @param langs - Language keys as declared in learn.json.
65
+ * @returns One key per canonical language.
66
+ */
67
+ export declare const dedupeLangs: (langs: string[]) => string[];
68
+ /**
69
+ * Slugs an asset for this language may have been created under.
70
+ *
71
+ * One per learn.json title naming this language (title.en and title.us can
72
+ * differ, and a historical asset came from whichever existed at the time),
73
+ * each with both suffixes when the language is "us"/"en".
74
+ * @param learnJson - Parsed learn.json.
75
+ * @param canonical - Canonical language.
76
+ * @returns Distinct candidate slugs, most likely first.
77
+ */
78
+ export declare const slugCandidatesForLang: (learnJson: any, canonical: string) => string[];
79
+ /**
80
+ * Text for the error a language gets when more than one asset claims it.
81
+ * @param lang - Language key from learn.json.
82
+ * @param candidates - The competing assets.
83
+ * @returns An actionable message naming every candidate.
84
+ */
85
+ export declare const describeAmbiguity: (lang: string, candidates: BcAsset[]) => string;
86
+ export type ResolverApi = {
87
+ getRigoPackage: typeof api.getRigoPackage;
88
+ getAssetById: typeof api.getAssetById;
89
+ getAssetBySlug: typeof api.getAssetBySlug;
90
+ };
91
+ export type ResolveInput = {
92
+ bcToken: string;
93
+ /** Optional: without it only the slug fallback runs (older IDEs do not send it). */
94
+ rigoToken?: string;
95
+ packageSlug: string;
96
+ learnJson: any;
97
+ deps?: ResolverApi;
98
+ };
99
+ /**
100
+ * Resolves the existing Breathecode asset for every language of a package.
101
+ * @param input - Tokens, package slug and parsed learn.json.
102
+ * @returns Per-language resolution plus the package-level facts publish needs.
103
+ */
104
+ export declare function resolvePackageAssets(input: ResolveInput): Promise<PackageAssetsResolution>;
105
+ /**
106
+ * Academy handling for the publish prompt, from the assets that were found.
107
+ * @param resolution - Output of resolvePackageAssets.
108
+ * @returns `select` when no found asset has an academy, `locked` when they all share one, `conflict` otherwise.
109
+ */
110
+ export declare const determineAcademyMode: (resolution: PackageAssetsResolution) => AcademyMode;
111
+ /**
112
+ * Whether at least one language already has an asset.
113
+ * @param resolution - Output of resolvePackageAssets.
114
+ * @returns True when any language resolved to `found`.
115
+ */
116
+ export declare const hasPublishedAsset: (resolution: PackageAssetsResolution) => boolean;
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasPublishedAsset = exports.determineAcademyMode = exports.describeAmbiguity = exports.slugCandidatesForLang = exports.dedupeLangs = exports.getAvailableLangs = exports.canonicalLang = void 0;
4
+ exports.resolvePackageAssets = resolvePackageAssets;
5
+ /**
6
+ * Resolves which Breathecode assets already represent a Learnpack package,
7
+ * one per language, before publish decides whether to create or update.
8
+ *
9
+ * The anchor is the package's `asset_ids` in Rigobot, read back by id from
10
+ * Breathecode. The title-derived slug is only a fallback for packages that
11
+ * predate `asset_ids`; it is never the criterion for a package whose ids are
12
+ * known. Every "could not check" outcome is reported as such, never as "does
13
+ * not exist", so a network hiccup cannot turn into a duplicate asset.
14
+ */
15
+ const api_1 = require("./api");
16
+ const creatorUtilities_1 = require("./creatorUtilities");
17
+ /**
18
+ * Breathecode stores "en" as "us"; both name the same language.
19
+ * @param lang - Language code as found in learn.json or on an asset.
20
+ * @returns The canonical code, or null when there is none.
21
+ */
22
+ const canonicalLang = (lang) => {
23
+ if (typeof lang !== "string")
24
+ return null;
25
+ const lower = lang.trim().toLowerCase();
26
+ if (!lower)
27
+ return null;
28
+ return lower === "en" ? "us" : lower;
29
+ };
30
+ exports.canonicalLang = canonicalLang;
31
+ /**
32
+ * Language keys of learn.json.title, in declaration order.
33
+ * @param learnJson - Parsed learn.json.
34
+ * @returns Non-empty string keys of `title`.
35
+ */
36
+ const getAvailableLangs = (learnJson) => {
37
+ const langs = Object.keys((learnJson === null || learnJson === void 0 ? void 0 : learnJson.title) || {});
38
+ return langs.filter(l => typeof l === "string" && l.length > 0);
39
+ };
40
+ exports.getAvailableLangs = getAvailableLangs;
41
+ /**
42
+ * Drops keys that name the same language ("en" and "us"), keeping "en" when
43
+ * both are present and the first one declared otherwise.
44
+ * @param langs - Language keys as declared in learn.json.
45
+ * @returns One key per canonical language.
46
+ */
47
+ const dedupeLangs = (langs) => {
48
+ const byCanonical = new Map();
49
+ for (const lang of langs) {
50
+ const canonical = (0, exports.canonicalLang)(lang);
51
+ if (canonical === null)
52
+ continue;
53
+ const current = byCanonical.get(canonical);
54
+ if (current === undefined || (lang === "en" && current === "us")) {
55
+ byCanonical.set(canonical, lang);
56
+ }
57
+ }
58
+ return [...byCanonical.values()];
59
+ };
60
+ exports.dedupeLangs = dedupeLangs;
61
+ /**
62
+ * Slugs an asset for this language may have been created under.
63
+ *
64
+ * One per learn.json title naming this language (title.en and title.us can
65
+ * differ, and a historical asset came from whichever existed at the time),
66
+ * each with both suffixes when the language is "us"/"en".
67
+ * @param learnJson - Parsed learn.json.
68
+ * @param canonical - Canonical language.
69
+ * @returns Distinct candidate slugs, most likely first.
70
+ */
71
+ const slugCandidatesForLang = (learnJson, canonical) => {
72
+ const suffixes = canonical === "us" ? ["en", "us"] : [canonical];
73
+ const seen = new Set();
74
+ const out = [];
75
+ for (const key of (0, exports.getAvailableLangs)(learnJson)) {
76
+ if ((0, exports.canonicalLang)(key) !== canonical)
77
+ continue;
78
+ const title = learnJson.title[key];
79
+ if (typeof title !== "string" || !title.trim())
80
+ continue;
81
+ for (const suffix of suffixes) {
82
+ const slug = (0, creatorUtilities_1.buildAssetSlug)(title, suffix);
83
+ if (!seen.has(slug)) {
84
+ seen.add(slug);
85
+ out.push(slug);
86
+ }
87
+ }
88
+ }
89
+ return out;
90
+ };
91
+ exports.slugCandidatesForLang = slugCandidatesForLang;
92
+ const describeAsset = (asset) => {
93
+ var _a;
94
+ return `#${asset.id} ${asset.slug} (${(_a = asset.status) !== null && _a !== void 0 ? _a : "?"}${asset.academyId === null ? "" : `, academy ${asset.academyId}`})`;
95
+ };
96
+ /**
97
+ * Text for the error a language gets when more than one asset claims it.
98
+ * @param lang - Language key from learn.json.
99
+ * @param candidates - The competing assets.
100
+ * @returns An actionable message naming every candidate.
101
+ */
102
+ const describeAmbiguity = (lang, candidates) => `Found ${candidates.length} Breathecode assets for language "${lang}": ${candidates
103
+ .map(describeAsset)
104
+ .join("; ")}. Nothing was created or updated for this language. Keep only the definitive ids in the Rigobot package (asset_ids) and publish again.`;
105
+ exports.describeAmbiguity = describeAmbiguity;
106
+ /**
107
+ * Resolves the existing Breathecode asset for every language of a package.
108
+ * @param input - Tokens, package slug and parsed learn.json.
109
+ * @returns Per-language resolution plus the package-level facts publish needs.
110
+ */
111
+ async function resolvePackageAssets(input) {
112
+ var _a, _b, _c, _d;
113
+ const deps = (_a = input.deps) !== null && _a !== void 0 ? _a : api_1.default;
114
+ const warnings = [];
115
+ const langs = (0, exports.dedupeLangs)((0, exports.getAvailableLangs)(input.learnJson));
116
+ const byLang = new Map();
117
+ const unknownEverywhere = (error, learnpackId) => {
118
+ for (const lang of langs) {
119
+ byLang.set((0, exports.canonicalLang)(lang), { status: "unknown", error });
120
+ }
121
+ return {
122
+ packageSlug: input.packageSlug,
123
+ learnpackId,
124
+ packageState: "unknown",
125
+ packageError: error,
126
+ knownAssetIds: [],
127
+ orphanIds: [],
128
+ byLang,
129
+ warnings,
130
+ };
131
+ };
132
+ // Step 0: the package in Rigobot, and the asset ids it already knows.
133
+ let learnpackId = null;
134
+ let previousIds = [];
135
+ if ((_b = input.rigoToken) === null || _b === void 0 ? void 0 : _b.trim()) {
136
+ const pkg = await deps.getRigoPackage(input.rigoToken, input.packageSlug);
137
+ if (pkg.kind === "error") {
138
+ return unknownEverywhere(`Could not read the Rigobot package "${input.packageSlug}": ${pkg.error}`, null);
139
+ }
140
+ if (pkg.kind === "found") {
141
+ learnpackId = pkg.id;
142
+ previousIds = [...new Set(pkg.assetIds)];
143
+ }
144
+ }
145
+ else {
146
+ warnings.push("No Rigobot token: existing assets are looked up by slug only.");
147
+ }
148
+ // Step 1: read every known id back from Breathecode.
149
+ const knownAssetIds = [];
150
+ const orphanIds = [];
151
+ const candidatesByLang = new Map();
152
+ for (const id of previousIds) {
153
+ // eslint-disable-next-line no-await-in-loop
154
+ const lookup = await deps.getAssetById(input.bcToken, id);
155
+ if (lookup.kind === "error") {
156
+ return unknownEverywhere(`Could not read Breathecode asset #${id}: ${lookup.error}`, learnpackId);
157
+ }
158
+ if (lookup.kind === "missing") {
159
+ warnings.push(`Asset #${id} listed in the Rigobot package no longer exists in Breathecode; dropped.`);
160
+ continue;
161
+ }
162
+ const asset = lookup.asset;
163
+ if (asset.learnpackId !== null &&
164
+ learnpackId !== null &&
165
+ asset.learnpackId !== learnpackId) {
166
+ warnings.push(`Asset ${describeAsset(asset)} belongs to Learnpack package ${asset.learnpackId}, not ${learnpackId}; ignored.`);
167
+ continue;
168
+ }
169
+ const canonical = (0, exports.canonicalLang)(asset.lang);
170
+ if (canonical === null) {
171
+ orphanIds.push(asset.id);
172
+ warnings.push(`Asset ${describeAsset(asset)} has no language; kept in the package but not used.`);
173
+ continue;
174
+ }
175
+ knownAssetIds.push(asset.id);
176
+ const group = (_c = candidatesByLang.get(canonical)) !== null && _c !== void 0 ? _c : [];
177
+ group.push(asset);
178
+ candidatesByLang.set(canonical, group);
179
+ }
180
+ // Step 2: languages with no known id fall back to the title-derived slugs.
181
+ for (const lang of langs) {
182
+ const canonical = (0, exports.canonicalLang)(lang);
183
+ const known = (_d = candidatesByLang.get(canonical)) !== null && _d !== void 0 ? _d : [];
184
+ if (known.length === 1) {
185
+ byLang.set(canonical, { status: "found", asset: known[0], via: "id" });
186
+ continue;
187
+ }
188
+ if (known.length > 1) {
189
+ byLang.set(canonical, { status: "ambiguous", candidates: known });
190
+ continue;
191
+ }
192
+ const found = new Map();
193
+ let error = null;
194
+ for (const slug of (0, exports.slugCandidatesForLang)(input.learnJson, canonical)) {
195
+ // eslint-disable-next-line no-await-in-loop
196
+ const lookup = await deps.getAssetBySlug(input.bcToken, slug);
197
+ if (lookup.kind === "error") {
198
+ error = `Could not check Breathecode asset "${slug}": ${lookup.error}`;
199
+ break;
200
+ }
201
+ if (lookup.kind === "missing")
202
+ continue;
203
+ const asset = lookup.asset;
204
+ if (asset.learnpackId !== null &&
205
+ learnpackId !== null &&
206
+ asset.learnpackId !== learnpackId) {
207
+ warnings.push(`Asset ${describeAsset(asset)} matches the slug for "${lang}" but belongs to Learnpack package ${asset.learnpackId}; ignored.`);
208
+ continue;
209
+ }
210
+ found.set(asset.id, asset);
211
+ }
212
+ if (error !== null) {
213
+ byLang.set(canonical, { status: "unknown", error });
214
+ }
215
+ else if (found.size === 0) {
216
+ byLang.set(canonical, { status: "missing" });
217
+ }
218
+ else if (found.size === 1) {
219
+ const asset = [...found.values()][0];
220
+ if (previousIds.length > 0) {
221
+ warnings.push(`Asset ${describeAsset(asset)} for "${lang}" was found by slug, not through the Rigobot package; it will be added to asset_ids.`);
222
+ }
223
+ byLang.set(canonical, { status: "found", asset, via: "slug" });
224
+ }
225
+ else {
226
+ byLang.set(canonical, {
227
+ status: "ambiguous",
228
+ candidates: [...found.values()],
229
+ });
230
+ }
231
+ }
232
+ return {
233
+ packageSlug: input.packageSlug,
234
+ learnpackId,
235
+ packageState: "ok",
236
+ knownAssetIds,
237
+ orphanIds,
238
+ byLang,
239
+ warnings,
240
+ };
241
+ }
242
+ /**
243
+ * Academy handling for the publish prompt, from the assets that were found.
244
+ * @param resolution - Output of resolvePackageAssets.
245
+ * @returns `select` when no found asset has an academy, `locked` when they all share one, `conflict` otherwise.
246
+ */
247
+ const determineAcademyMode = (resolution) => {
248
+ const academyIds = [];
249
+ for (const entry of resolution.byLang.values()) {
250
+ if (entry.status === "found" && entry.asset.academyId !== null) {
251
+ academyIds.push(entry.asset.academyId);
252
+ }
253
+ }
254
+ const unique = [...new Set(academyIds)];
255
+ if (unique.length === 0)
256
+ return { type: "select" };
257
+ if (unique.length === 1)
258
+ return { type: "locked", academyId: unique[0] };
259
+ return { type: "conflict", academies: unique };
260
+ };
261
+ exports.determineAcademyMode = determineAcademyMode;
262
+ /**
263
+ * Whether at least one language already has an asset.
264
+ * @param resolution - Output of resolvePackageAssets.
265
+ * @returns True when any language resolved to `found`.
266
+ */
267
+ const hasPublishedAsset = (resolution) => [...resolution.byLang.values()].some(entry => entry.status === "found");
268
+ exports.hasPublishedAsset = hasPublishedAsset;
@@ -0,0 +1,48 @@
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 { AssetSyncError } from "./api";
10
+ import { PackageAssetsResolution } from "./assetResolver";
11
+ export type AssetSyncInput = {
12
+ bcToken: string;
13
+ rigoToken: string;
14
+ packageSlug: string;
15
+ learnJson: any;
16
+ deployUrl: string;
17
+ academyId?: number;
18
+ /** Raw readme for a language ("" when there is none). Base64 encoding happens here. */
19
+ readReadme: (lang: string) => Promise<string>;
20
+ /** A resolution computed earlier (e.g. before the upload) to reuse instead of resolving again. */
21
+ resolution?: PackageAssetsResolution;
22
+ };
23
+ export type SyncedAsset = {
24
+ lang: string;
25
+ action: "created" | "updated";
26
+ asset: {
27
+ id: number;
28
+ slug: string;
29
+ };
30
+ };
31
+ export type AssetSyncResult = {
32
+ errors: AssetSyncError[];
33
+ warnings: string[];
34
+ synced: SyncedAsset[];
35
+ };
36
+ /**
37
+ * Localized value with the same fallbacks the publish flow always used.
38
+ * @param translations - A learn.json map such as `title` or `description`.
39
+ * @param lang - Preferred language key.
40
+ * @returns The value for `lang`, else "en"/"us", else the first one, else "".
41
+ */
42
+ export declare const getLocalizedValue: (translations: Record<string, any> | undefined, lang: string) => string;
43
+ /**
44
+ * Synchronises every language of a package with Breathecode.
45
+ * @param input - Tokens, package data, deploy url and readme provider.
46
+ * @returns Per-language errors (the publish itself is never blocked), warnings worth logging, and what was written.
47
+ */
48
+ export declare function syncPackageAssets(input: AssetSyncInput): Promise<AssetSyncResult>;