@learnpack/learnpack 5.0.357 → 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/commands/publish.d.ts +0 -10
- package/lib/commands/publish.js +54 -170
- package/lib/commands/serve.js +145 -134
- package/lib/ui/static/og-image.png +0 -0
- package/lib/utils/api.d.ts +62 -15
- package/lib/utils/api.js +124 -45
- package/lib/utils/assetResolver.d.ts +116 -0
- package/lib/utils/assetResolver.js +268 -0
- package/lib/utils/assetSync.d.ts +48 -0
- package/lib/utils/assetSync.js +267 -0
- package/lib/utils/branding.d.ts +43 -0
- package/lib/utils/branding.js +75 -0
- package/lib/utils/creatorUtilities.d.ts +1 -0
- package/lib/utils/creatorUtilities.js +17 -1
- package/package.json +2 -2
- package/src/commands/publish.ts +63 -258
- package/src/commands/serve.ts +200 -162
- package/src/ui/_app/app.js +2072 -2072
- package/src/ui/app.tar.gz +0 -0
- package/src/ui/static/og-image.png +0 -0
- package/src/utils/api.ts +157 -42
- package/src/utils/assetResolver.ts +368 -0
- package/src/utils/assetSync.ts +343 -0
- package/src/utils/awsCredentials.ts +57 -57
- package/src/utils/branding.ts +78 -0
- package/src/utils/creatorUtilities.ts +16 -0
- package/src/utils/descriptions/publishStage.ts +394 -394
- package/src/utils/descriptions/resumePublication.ts +217 -217
- package/src/utils/descriptions/s3Storage.ts +214 -214
- package/src/utils/s3/packageManifestBackfill.ts +771 -771
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.
|
|
4
|
-
exports.
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
362
|
-
*
|
|
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
|
|
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
|
|
367
|
-
var _a;
|
|
368
|
-
|
|
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 ${
|
|
383
|
+
headers: { Authorization: `Token ${cleanToken}` },
|
|
374
384
|
});
|
|
375
|
-
|
|
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 (
|
|
378
|
-
|
|
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
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
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, {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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 {
|
|
496
|
+
return { kind: "found", asset };
|
|
445
497
|
}
|
|
446
|
-
catch (
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
669
|
+
getAssetById: exports.getAssetById,
|
|
670
|
+
getAssetBySlug: exports.getAssetBySlug,
|
|
592
671
|
updateAsset,
|
|
593
|
-
|
|
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>;
|