@learnpack/learnpack 5.0.355 → 5.0.357
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/serve.d.ts +0 -17
- package/lib/commands/serve.js +159 -129
- package/lib/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
- package/lib/creatorDist/assets/index-zrPponAn.css +1701 -0
- package/lib/creatorDist/index.html +2 -2
- package/lib/models/creator.d.ts +1 -1
- package/lib/services/ingest/adapters/gcsCourseStorage.d.ts +13 -0
- package/lib/services/ingest/adapters/gcsCourseStorage.js +58 -0
- package/lib/services/ingest/adapters/rigoUserTokenAuth.d.ts +15 -0
- package/lib/services/ingest/adapters/rigoUserTokenAuth.js +46 -0
- package/lib/services/ingest/adapters/rigobotPackageRegistry.d.ts +7 -0
- package/lib/services/ingest/adapters/rigobotPackageRegistry.js +119 -0
- package/lib/services/ingest/core/buildIngestConfig.d.ts +27 -0
- package/lib/services/ingest/core/buildIngestConfig.js +40 -0
- package/lib/services/ingest/core/buildInitialSyllabus.d.ts +24 -0
- package/lib/services/ingest/core/buildInitialSyllabus.js +104 -0
- package/lib/services/ingest/core/buildSidebar.d.ts +13 -0
- package/lib/services/ingest/core/buildSidebar.js +28 -0
- package/lib/services/ingest/core/coursePaths.d.ts +31 -0
- package/lib/services/ingest/core/coursePaths.js +50 -0
- package/lib/services/ingest/core/ingestCoursePackage.d.ts +52 -0
- package/lib/services/ingest/core/ingestCoursePackage.js +217 -0
- package/lib/services/ingest/core/normalizeLearnJson.d.ts +25 -0
- package/lib/services/ingest/core/normalizeLearnJson.js +29 -0
- package/lib/services/ingest/core/pairLessons.d.ts +29 -0
- package/lib/services/ingest/core/pairLessons.js +102 -0
- package/lib/services/ingest/core/planWrites.d.ts +44 -0
- package/lib/services/ingest/core/planWrites.js +59 -0
- package/lib/services/ingest/core/resolveSlug.d.ts +53 -0
- package/lib/services/ingest/core/resolveSlug.js +93 -0
- package/lib/services/ingest/core/types.d.ts +53 -0
- package/lib/services/ingest/core/types.js +2 -0
- package/lib/services/ingest/core/validatePackage.d.ts +38 -0
- package/lib/services/ingest/core/validatePackage.js +97 -0
- package/lib/services/ingest/createIngestService.d.ts +34 -0
- package/lib/services/ingest/createIngestService.js +38 -0
- package/lib/services/ingest/errors.d.ts +22 -0
- package/lib/services/ingest/errors.js +34 -0
- package/lib/services/ingest/http/errorMapping.d.ts +8 -0
- package/lib/services/ingest/http/errorMapping.js +40 -0
- package/lib/services/ingest/http/router.d.ts +32 -0
- package/lib/services/ingest/http/router.js +124 -0
- package/lib/services/ingest/http/zipPackageReader.d.ts +26 -0
- package/lib/services/ingest/http/zipPackageReader.js +62 -0
- package/lib/services/ingest/ports/courseStorage.d.ts +25 -0
- package/lib/services/ingest/ports/courseStorage.js +2 -0
- package/lib/services/ingest/ports/packageRegistry.d.ts +58 -0
- package/lib/services/ingest/ports/packageRegistry.js +2 -0
- package/lib/services/ingest/ports/requestAuthenticator.d.ts +25 -0
- package/lib/services/ingest/ports/requestAuthenticator.js +2 -0
- package/lib/utils/coursePackage/bucketIo.d.ts +23 -0
- package/lib/utils/coursePackage/bucketIo.js +36 -0
- package/lib/utils/coursePackage/learnJson.d.ts +26 -0
- package/lib/utils/coursePackage/learnJson.js +36 -0
- package/lib/utils/coursePackage/sidebar.d.ts +15 -0
- package/lib/utils/coursePackage/sidebar.js +26 -0
- package/lib/utils/creatorSocket.js +15 -21
- package/lib/utils/gcpCredentials.d.ts +18 -0
- package/lib/utils/gcpCredentials.js +27 -0
- package/lib/utils/rigoActions.d.ts +16 -0
- package/lib/utils/rigoActions.js +102 -1
- package/lib/utils/socketRegistry.d.ts +46 -0
- package/lib/utils/socketRegistry.js +83 -0
- package/package.json +1 -1
- package/src/commands/serve.ts +162 -114
- package/src/creator/README.md +63 -51
- package/src/creator/package-lock.json +1188 -8
- package/src/creator/package.json +5 -1
- package/src/creator/src/App.tsx +120 -54
- package/src/creator/src/components/FileUploader.tsx +1 -12
- package/src/creator/src/components/NotificationListener.tsx +1 -8
- package/src/creator/src/components/syllabus/SyllabusEditor.tsx +101 -36
- package/src/creator/src/locales/en.json +8 -0
- package/src/creator/src/locales/es.json +8 -0
- package/src/creator/src/utils/completionResult.test.ts +144 -0
- package/src/creator/src/utils/completionResult.ts +154 -0
- package/src/creator/src/utils/constants.ts +17 -4
- package/src/creator/src/utils/socket.test.ts +113 -0
- package/src/creator/src/utils/socket.ts +60 -37
- package/src/creator/src/utils/store.ts +84 -67
- package/src/creator/src/utils/useCompletionWatchdog.test.ts +99 -0
- package/src/creator/src/utils/useCompletionWatchdog.ts +37 -0
- package/src/creator/vitest.config.ts +16 -0
- package/src/creatorDist/assets/{index-DnthLsvb.js → index-D6pmbMe9.js} +14030 -13903
- package/src/creatorDist/assets/index-zrPponAn.css +1701 -0
- package/src/creatorDist/index.html +2 -2
- package/src/models/creator.ts +1 -1
- package/src/services/ingest/adapters/gcsCourseStorage.ts +70 -0
- package/src/services/ingest/adapters/rigoUserTokenAuth.ts +65 -0
- package/src/services/ingest/adapters/rigobotPackageRegistry.ts +161 -0
- package/src/services/ingest/core/buildIngestConfig.ts +51 -0
- package/src/services/ingest/core/buildInitialSyllabus.ts +122 -0
- package/src/services/ingest/core/buildSidebar.ts +32 -0
- package/src/services/ingest/core/coursePaths.ts +59 -0
- package/src/services/ingest/core/ingestCoursePackage.ts +343 -0
- package/src/services/ingest/core/normalizeLearnJson.ts +47 -0
- package/src/services/ingest/core/pairLessons.ts +132 -0
- package/src/services/ingest/core/planWrites.ts +91 -0
- package/src/services/ingest/core/resolveSlug.ts +119 -0
- package/src/services/ingest/core/types.ts +57 -0
- package/src/services/ingest/core/validatePackage.ts +132 -0
- package/src/services/ingest/createIngestService.ts +66 -0
- package/src/services/ingest/errors.ts +45 -0
- package/src/services/ingest/http/errorMapping.ts +49 -0
- package/src/services/ingest/http/router.ts +156 -0
- package/src/services/ingest/http/zipPackageReader.ts +81 -0
- package/src/services/ingest/ports/courseStorage.ts +25 -0
- package/src/services/ingest/ports/packageRegistry.ts +60 -0
- package/src/services/ingest/ports/requestAuthenticator.ts +28 -0
- package/src/ui/_app/app.js +325 -325
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/coursePackage/bucketIo.ts +46 -0
- package/src/utils/coursePackage/learnJson.ts +37 -0
- package/src/utils/coursePackage/sidebar.ts +28 -0
- package/src/utils/creatorSocket.ts +16 -22
- package/src/utils/gcpCredentials.ts +38 -0
- package/src/utils/rigoActions.ts +163 -0
- package/src/utils/socketRegistry.ts +85 -0
- package/lib/creatorDist/assets/index-CjddKHB_.css +0 -1
- package/src/creatorDist/assets/index-CjddKHB_.css +0 -1
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { PackageRegistry } from "../ports/packageRegistry"
|
|
2
|
+
import { IngestError } from "../errors"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Room to spare under Rigobot's 100-character SlugField, so a suffixed
|
|
6
|
+
* suggestion still fits.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_SLUG_LENGTH = 80
|
|
9
|
+
|
|
10
|
+
/** How many alternatives to offer when a slug is taken. */
|
|
11
|
+
const SUGGESTION_COUNT = 3
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The strictest of the slug rules in play, on purpose.
|
|
15
|
+
*
|
|
16
|
+
* This repo's own `slugify` keeps dots, and two things break when one survives:
|
|
17
|
+
* Rigobot stores `package_slug` in a Django SlugField whose validator rejects
|
|
18
|
+
* them (it persists anyway, since the view never calls full_clean), and the
|
|
19
|
+
* published course is served at `{slug}.learn-pack.com`, where a dot adds a
|
|
20
|
+
* subdomain level the wildcard certificate does not cover.
|
|
21
|
+
* @param raw - Candidate slug or title.
|
|
22
|
+
* @returns Lowercase, hyphen-separated, alphanumeric-only slug.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeSlug(raw: string): string {
|
|
25
|
+
return raw
|
|
26
|
+
.normalize("NFD")
|
|
27
|
+
.replace(/[\u0300-\u036F]/g, "")
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^\da-z]+/g, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "")
|
|
31
|
+
.slice(0, MAX_SLUG_LENGTH)
|
|
32
|
+
.replace(/-+$/g, "")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Proposes alternatives for a slug that is taken.
|
|
37
|
+
*
|
|
38
|
+
* Suggested rather than applied: the slug is the public URL and, in practice,
|
|
39
|
+
* permanent — the rename endpoint does not update Rigobot's copy. Auto-suffixing
|
|
40
|
+
* is how a `solid-principles-7` ends up shipped. This mirrors the creator's own
|
|
41
|
+
* publish dialog, which checks availability and lets the human choose.
|
|
42
|
+
* @param base - The slug that was taken.
|
|
43
|
+
* @param registry - Used to check each candidate.
|
|
44
|
+
* @returns Up to three available slugs.
|
|
45
|
+
*/
|
|
46
|
+
export async function suggestSlugs(
|
|
47
|
+
base: string,
|
|
48
|
+
registry: PackageRegistry
|
|
49
|
+
): Promise<string[]> {
|
|
50
|
+
const suggestions: string[] = []
|
|
51
|
+
|
|
52
|
+
for (let suffix = 2; suggestions.length < SUGGESTION_COUNT; suffix++) {
|
|
53
|
+
// Trim the base, not the suffix, so the candidate stays under the cap.
|
|
54
|
+
const tail = `-${suffix}`
|
|
55
|
+
const candidate = `${base.slice(0, MAX_SLUG_LENGTH - tail.length)}${tail}`
|
|
56
|
+
|
|
57
|
+
// eslint-disable-next-line no-await-in-loop
|
|
58
|
+
if (await registry.isSlugAvailable(candidate)) {
|
|
59
|
+
suggestions.push(candidate)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (suffix > SUGGESTION_COUNT + 10) {
|
|
63
|
+
break
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return suggestions
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ResolveSlugInput = {
|
|
71
|
+
/** Slug the caller asked for, if any. */
|
|
72
|
+
requestedSlug?: string;
|
|
73
|
+
/** Course title, used to derive a slug when none was requested. */
|
|
74
|
+
courseTitle?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Settles which slug the course will live under.
|
|
79
|
+
*
|
|
80
|
+
* An explicit slug is validated rather than repaired: silently reshaping what
|
|
81
|
+
* somebody typed hands them a different URL than the one they asked for. A
|
|
82
|
+
* derived slug is only a suggestion, which is why the caller is expected to
|
|
83
|
+
* show it.
|
|
84
|
+
*
|
|
85
|
+
* There is no shared default. An earlier version of the exporter fell back to
|
|
86
|
+
* "LearnPack Course" for an untitled course, which slugifies to
|
|
87
|
+
* `learnpack-course` for *every* such course — and since slugs are globally
|
|
88
|
+
* unique, the second one collides with the first.
|
|
89
|
+
* @param input - Requested slug and/or course title.
|
|
90
|
+
* @returns The slug to use.
|
|
91
|
+
* @throws IngestError `INVALID_SLUG` when nothing usable was given.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveSlug(input: ResolveSlugInput): string {
|
|
94
|
+
const requested = (input.requestedSlug || "").trim()
|
|
95
|
+
|
|
96
|
+
if (requested) {
|
|
97
|
+
const normalized = normalizeSlug(requested)
|
|
98
|
+
|
|
99
|
+
if (normalized !== requested) {
|
|
100
|
+
throw new IngestError(
|
|
101
|
+
"INVALID_SLUG",
|
|
102
|
+
`"${requested}" is not a usable slug. Did you mean "${normalized}"? Slugs are lowercase letters, digits and hyphens, up to ${MAX_SLUG_LENGTH} characters.`
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return normalized
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const derived = normalizeSlug((input.courseTitle || "").trim())
|
|
110
|
+
|
|
111
|
+
if (!derived) {
|
|
112
|
+
throw new IngestError(
|
|
113
|
+
"INVALID_SLUG",
|
|
114
|
+
"The course has no title to derive a slug from; pass one explicitly"
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return derived
|
|
119
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { LessonType } from "../../../utils/packageManifest"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A lesson entry as the generator plans it, before any content exists.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors `files/lesson_plan.json`. Everything past `id`, `title` and `type` is
|
|
7
|
+
* optional because an earlier schema (ADR-0001 in learnpack-evals) had neither
|
|
8
|
+
* `lesson_focus` nor `concepts_covered`, and packages generated back then are
|
|
9
|
+
* still ingestable.
|
|
10
|
+
*/
|
|
11
|
+
export type PlannedLesson = {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
type?: string;
|
|
15
|
+
module_number?: number;
|
|
16
|
+
duration_minutes?: number;
|
|
17
|
+
concepts_covered?: string[];
|
|
18
|
+
lesson_focus?: string | null;
|
|
19
|
+
key_insight?: string | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The generator's `files/lesson_plan.json`. */
|
|
23
|
+
export type LessonPlan = {
|
|
24
|
+
course_title?: string;
|
|
25
|
+
course_duration_minutes?: number;
|
|
26
|
+
language?: string;
|
|
27
|
+
lessons: PlannedLesson[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A planned lesson matched to the exercise folder that actually holds it.
|
|
32
|
+
*
|
|
33
|
+
* `exerciseSlug` comes from the package, never from re-slugifying the title:
|
|
34
|
+
* the generator's Python slugify and this repo's TypeScript one disagree (one
|
|
35
|
+
* collapses every non-alphanumeric run to a hyphen, the other keeps dots), and
|
|
36
|
+
* real titles contain typographic apostrophes that make the difference
|
|
37
|
+
* visible. A recomputed slug would key the sidebar to a folder that does not
|
|
38
|
+
* exist, and the lesson would render empty.
|
|
39
|
+
*/
|
|
40
|
+
export type IngestLesson = {
|
|
41
|
+
id: string;
|
|
42
|
+
exerciseSlug: string;
|
|
43
|
+
title: string;
|
|
44
|
+
type: LessonType;
|
|
45
|
+
durationMinutes: number;
|
|
46
|
+
/** The generation topic, from `lesson_focus`, falling back to the title. */
|
|
47
|
+
description: string;
|
|
48
|
+
/** Language codes with a README present for this lesson. */
|
|
49
|
+
languages: string[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A plan entry that had no content in the package. */
|
|
53
|
+
export type SkippedLesson = {
|
|
54
|
+
id: string;
|
|
55
|
+
title: string;
|
|
56
|
+
reason: "no-content";
|
|
57
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { IngestError } from "../errors"
|
|
2
|
+
|
|
3
|
+
/** Path of the lesson plan inside the package, relative to its root. */
|
|
4
|
+
export const LESSON_PLAN_PATH = ".learn/artifacts/files/lesson_plan.json"
|
|
5
|
+
|
|
6
|
+
/** Path of the course manifest inside the package. */
|
|
7
|
+
export const LEARN_JSON_PATH = "learn.json"
|
|
8
|
+
|
|
9
|
+
export type PackageFiles = Record<string, string>;
|
|
10
|
+
|
|
11
|
+
const normalize = (path: string): string => path.replace(/\\/g, "/")
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Rejects a package the bucket consumers could not read correctly.
|
|
15
|
+
*
|
|
16
|
+
* Two of these rules look arbitrary and are not — both come from how
|
|
17
|
+
* `buildConfig` (`utils/configBuilder.ts`) derives a course from whatever files
|
|
18
|
+
* it finds:
|
|
19
|
+
*
|
|
20
|
+
* - It picks the manifest with `files.find(f => f.name.endsWith("learn.json"))`,
|
|
21
|
+
* and GCS lists lexicographically, where `.learn/…` sorts *before*
|
|
22
|
+
* `learn.json`. A second file ending in that name would shadow the real one
|
|
23
|
+
* and the course would be configured from the wrong manifest.
|
|
24
|
+
* - It reads the exercise slug as the segment after `exercises`, so a nested
|
|
25
|
+
* folder by that name would mint phantom lessons.
|
|
26
|
+
*
|
|
27
|
+
* Neither is hypothetical enough to skip: they cost one pass over the entries
|
|
28
|
+
* and fail with a message that names the offending path.
|
|
29
|
+
* @param files - Package entries, keyed by path relative to the package root.
|
|
30
|
+
* @returns Nothing; a valid package simply returns.
|
|
31
|
+
* @throws IngestError `INVALID_PACKAGE` describing the first problem found.
|
|
32
|
+
*/
|
|
33
|
+
export function validatePackage(files: PackageFiles): void {
|
|
34
|
+
const paths = Object.keys(files).map(path => normalize(path))
|
|
35
|
+
|
|
36
|
+
if (!paths.includes(LEARN_JSON_PATH)) {
|
|
37
|
+
throw new IngestError(
|
|
38
|
+
"INVALID_PACKAGE",
|
|
39
|
+
`The package has no ${LEARN_JSON_PATH} at its root`
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!paths.includes(LESSON_PLAN_PATH)) {
|
|
44
|
+
throw new IngestError(
|
|
45
|
+
"INVALID_PACKAGE",
|
|
46
|
+
`The package has no ${LESSON_PLAN_PATH}; the syllabus cannot be built without it`
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const shadowingManifest = paths.find(
|
|
51
|
+
path => path !== LEARN_JSON_PATH && path.endsWith("learn.json")
|
|
52
|
+
)
|
|
53
|
+
if (shadowingManifest) {
|
|
54
|
+
throw new IngestError(
|
|
55
|
+
"INVALID_PACKAGE",
|
|
56
|
+
`"${shadowingManifest}" would shadow the course manifest: no other file may end in learn.json`
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const nestedExercises = paths.find(path => {
|
|
61
|
+
const segments = path.split("/")
|
|
62
|
+
// lastIndexOf, not indexOf: the root folder legitimately sits at 0, so
|
|
63
|
+
// asking where the *first* one is would always answer 0 and never flag the
|
|
64
|
+
// nested one this rule exists to catch.
|
|
65
|
+
return segments.lastIndexOf("exercises") > 0
|
|
66
|
+
})
|
|
67
|
+
if (nestedExercises) {
|
|
68
|
+
throw new IngestError(
|
|
69
|
+
"INVALID_PACKAGE",
|
|
70
|
+
`"${nestedExercises}" nests a folder named "exercises"; only the package root may have one`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hasLesson = paths.some(path => path.startsWith("exercises/"))
|
|
75
|
+
if (!hasLesson) {
|
|
76
|
+
throw new IngestError(
|
|
77
|
+
"INVALID_PACKAGE",
|
|
78
|
+
"The package has no exercises/ folder, so it holds no lessons"
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const README_PATTERN = /^readme(?:\.([a-z]{2}))?\.md$/i
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Reads which exercise folders exist and which languages each one has.
|
|
87
|
+
*
|
|
88
|
+
* A folder counts as a lesson only once it holds a README: an exercise with
|
|
89
|
+
* supporting files but no prose is not something the IDE can render.
|
|
90
|
+
* @param files - Package entries.
|
|
91
|
+
* @returns Folder slugs in listing order, and their language codes.
|
|
92
|
+
*/
|
|
93
|
+
export function readExercises(files: PackageFiles): {
|
|
94
|
+
slugs: string[];
|
|
95
|
+
languagesBySlug: Record<string, string[]>;
|
|
96
|
+
} {
|
|
97
|
+
const languagesBySlug: Record<string, string[]> = {}
|
|
98
|
+
|
|
99
|
+
for (const rawPath of Object.keys(files)) {
|
|
100
|
+
const path = normalize(rawPath)
|
|
101
|
+
if (!path.startsWith("exercises/")) {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const [, slug, fileName, ...rest] = path.split("/")
|
|
106
|
+
if (!slug || !fileName || rest.length > 0) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const match = fileName.match(README_PATTERN)
|
|
111
|
+
if (!match) {
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// `README.md` with no language segment is English, matching
|
|
116
|
+
// `getReadmeExtension` in utils/creatorUtilities.
|
|
117
|
+
const lang = (match[1] || "en").toLowerCase()
|
|
118
|
+
if (!languagesBySlug[slug]) {
|
|
119
|
+
languagesBySlug[slug] = []
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!languagesBySlug[slug].includes(lang)) {
|
|
123
|
+
languagesBySlug[slug].push(lang)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
for (const langs of Object.values(languagesBySlug)) {
|
|
128
|
+
langs.sort()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { slugs: Object.keys(languagesBySlug), languagesBySlug }
|
|
132
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Storage } from "@google-cloud/storage"
|
|
2
|
+
import { requireGcpCredentials } from "../../utils/gcpCredentials"
|
|
3
|
+
import { requireGcsBucketName } from "../../utils/gcsBucketName"
|
|
4
|
+
import { CourseStorage } from "./ports/courseStorage"
|
|
5
|
+
import { PackageRegistry } from "./ports/packageRegistry"
|
|
6
|
+
import { RequestAuthenticator } from "./ports/requestAuthenticator"
|
|
7
|
+
import { createGcsCourseStorage } from "./adapters/gcsCourseStorage"
|
|
8
|
+
import { createRigobotPackageRegistry } from "./adapters/rigobotPackageRegistry"
|
|
9
|
+
import { createRigoUserTokenAuth } from "./adapters/rigoUserTokenAuth"
|
|
10
|
+
|
|
11
|
+
export type IngestService = {
|
|
12
|
+
storage: CourseStorage;
|
|
13
|
+
auth: RequestAuthenticator;
|
|
14
|
+
/**
|
|
15
|
+
* Organization every ingested package must end up under.
|
|
16
|
+
*
|
|
17
|
+
* Rigobot attaches one automatically when the owner belongs to exactly one,
|
|
18
|
+
* so the ingest verifies the result rather than assuming it: landing in the
|
|
19
|
+
* wrong organization cannot be undone through the API.
|
|
20
|
+
*/
|
|
21
|
+
organization: string;
|
|
22
|
+
/**
|
|
23
|
+
* Registry scoped to the caller's token.
|
|
24
|
+
*
|
|
25
|
+
* Built per request because the package's owner is whoever the token belongs
|
|
26
|
+
* to — a registry fixed at boot would silently file every ingested course
|
|
27
|
+
* under the server's identity.
|
|
28
|
+
*/
|
|
29
|
+
registryFor(rigoToken: string): PackageRegistry;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function requireIngestOrganization(): string {
|
|
33
|
+
const organization = (process.env.INGEST_ORGANIZATION || "").trim()
|
|
34
|
+
|
|
35
|
+
if (!organization) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"INGEST_ORGANIZATION (env) is required: it names the Rigobot organization ingested packages belong to"
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return organization
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Composition root: the only place in the ingest service that reads env vars.
|
|
46
|
+
*
|
|
47
|
+
* Everything below this line receives its dependencies already built, which is
|
|
48
|
+
* what keeps the ports testable without credentials and lets the whole service
|
|
49
|
+
* move behind a different transport later.
|
|
50
|
+
* @returns The assembled service.
|
|
51
|
+
* @throws Error naming the missing variable when configuration is incomplete.
|
|
52
|
+
*/
|
|
53
|
+
export function createIngestServiceFromEnv(): IngestService {
|
|
54
|
+
const credentials = requireGcpCredentials()
|
|
55
|
+
const bucketName = requireGcsBucketName()
|
|
56
|
+
const organization = requireIngestOrganization()
|
|
57
|
+
|
|
58
|
+
const bucket = new Storage({ credentials }).bucket(bucketName)
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
storage: createGcsCourseStorage(bucket),
|
|
62
|
+
auth: createRigoUserTokenAuth(),
|
|
63
|
+
organization,
|
|
64
|
+
registryFor: createRigobotPackageRegistry,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed failures for the ingest service.
|
|
3
|
+
*
|
|
4
|
+
* The HTTP layer has to answer a slug collision with 409, a package owned by
|
|
5
|
+
* someone else with 403, and an unreachable Rigobot with 502 — three outcomes
|
|
6
|
+
* that arrive as the same `AxiosError`. Discriminating them by message
|
|
7
|
+
* substring would break the first time Rigobot rewords an error, so adapters
|
|
8
|
+
* name the failure where they still have the response in hand and everything
|
|
9
|
+
* downstream switches on `code`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type IngestErrorCode =
|
|
13
|
+
| "INVALID_PACKAGE"
|
|
14
|
+
| "INVALID_SLUG"
|
|
15
|
+
| "SLUG_TAKEN"
|
|
16
|
+
| "SLUG_OWNED_BY_OTHER"
|
|
17
|
+
| "ORGANIZATION_MISMATCH"
|
|
18
|
+
| "INVALID_TOKEN"
|
|
19
|
+
| "REGISTRY_UNAVAILABLE"
|
|
20
|
+
| "STORAGE_FAILURE";
|
|
21
|
+
|
|
22
|
+
export class IngestError extends Error {
|
|
23
|
+
readonly code: IngestErrorCode
|
|
24
|
+
|
|
25
|
+
readonly cause?: unknown
|
|
26
|
+
|
|
27
|
+
constructor(code: IngestErrorCode, message: string, cause?: unknown) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = "IngestError"
|
|
30
|
+
this.code = code
|
|
31
|
+
this.cause = cause
|
|
32
|
+
// Restores the prototype chain, which extending a built-in breaks when the
|
|
33
|
+
// TypeScript target is ES5 — without it `instanceof IngestError` is false.
|
|
34
|
+
Object.setPrototypeOf(this, IngestError.prototype)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Narrows an unknown catch binding to an IngestError.
|
|
40
|
+
* @param error - Value caught from a throwing call.
|
|
41
|
+
* @returns True when the value is an IngestError.
|
|
42
|
+
*/
|
|
43
|
+
export function isIngestError(error: unknown): error is IngestError {
|
|
44
|
+
return error instanceof IngestError
|
|
45
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConflictError,
|
|
3
|
+
ForbiddenError,
|
|
4
|
+
InternalServerError,
|
|
5
|
+
OperationalError,
|
|
6
|
+
UnauthorizedError,
|
|
7
|
+
ValidationError,
|
|
8
|
+
} from "../../../utils/errorHandler"
|
|
9
|
+
import { IngestError, IngestErrorCode } from "../errors"
|
|
10
|
+
|
|
11
|
+
type ErrorFactory = (message: string) => OperationalError;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Which HTTP failure each ingest failure is.
|
|
15
|
+
*
|
|
16
|
+
* The line between 403 and 409 is the one worth reading twice: a **409** means
|
|
17
|
+
* the slug is yours and the caller can act — retry with overwrite, or pick
|
|
18
|
+
* another. A **403** means it is not yours, and no retry will help. Collapsing
|
|
19
|
+
* the two would leave the CLI unable to tell "try again differently" from
|
|
20
|
+
* "stop".
|
|
21
|
+
*
|
|
22
|
+
* `REGISTRY_UNAVAILABLE` would be a 502 in a vacuum. `errorHandler` has no
|
|
23
|
+
* class for that, and adding one to shade a single case is not worth touching a
|
|
24
|
+
* module the rest of the server depends on; the message already says Rigobot
|
|
25
|
+
* was unreachable.
|
|
26
|
+
*
|
|
27
|
+
* Declared as a total Record, not a switch with a default: adding a code to the
|
|
28
|
+
* union without deciding its status then fails the build instead of quietly
|
|
29
|
+
* becoming a 500.
|
|
30
|
+
*/
|
|
31
|
+
const BY_CODE: Record<IngestErrorCode, ErrorFactory> = {
|
|
32
|
+
INVALID_PACKAGE: message => new ValidationError(message),
|
|
33
|
+
INVALID_SLUG: message => new ValidationError(message),
|
|
34
|
+
INVALID_TOKEN: message => new UnauthorizedError(message),
|
|
35
|
+
SLUG_OWNED_BY_OTHER: message => new ForbiddenError(message),
|
|
36
|
+
ORGANIZATION_MISMATCH: message => new ForbiddenError(message),
|
|
37
|
+
SLUG_TAKEN: message => new ConflictError(message),
|
|
38
|
+
REGISTRY_UNAVAILABLE: message => new InternalServerError(message),
|
|
39
|
+
STORAGE_FAILURE: message => new InternalServerError(message),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Translates a domain failure into the HTTP error the global handler renders.
|
|
44
|
+
* @param error - The failure raised by the ingest service.
|
|
45
|
+
* @returns An operational error carrying the right status code.
|
|
46
|
+
*/
|
|
47
|
+
export function toHttpError(error: IngestError): OperationalError {
|
|
48
|
+
return BY_CODE[error.code](error.message)
|
|
49
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as express from "express"
|
|
2
|
+
import { Request, Response, Router } from "express"
|
|
3
|
+
import { asyncHandler, ValidationError } from "../../../utils/errorHandler"
|
|
4
|
+
import { createUploadMiddleware } from "../../../utils/misc"
|
|
5
|
+
import {
|
|
6
|
+
createIngestServiceFromEnv,
|
|
7
|
+
IngestService,
|
|
8
|
+
} from "../createIngestService"
|
|
9
|
+
import { ingestCoursePackage } from "../core/ingestCoursePackage"
|
|
10
|
+
import { isIngestError } from "../errors"
|
|
11
|
+
import { toHttpError } from "./errorMapping"
|
|
12
|
+
import { readPackageZip } from "./zipPackageReader"
|
|
13
|
+
|
|
14
|
+
const FILE_FIELD = "file"
|
|
15
|
+
|
|
16
|
+
let cached: IngestService | undefined
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Builds the ingest service once, on the first request that needs it.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately not built at boot. `INGEST_ORGANIZATION` is a new variable that
|
|
22
|
+
* no existing deployment sets, and the composition root throws without it — so
|
|
23
|
+
* constructing it during startup would stop every current server from booting
|
|
24
|
+
* over a capability it does not use. The repo never lets a new capability do
|
|
25
|
+
* that: a missing `RIGOBOT_SYSTEM_TOKEN` skips the descriptions stage, a
|
|
26
|
+
* missing `REDIS_URL` disables history, and unset GitHub credentials fail only
|
|
27
|
+
* the GitHub endpoints.
|
|
28
|
+
*
|
|
29
|
+
* The failure therefore belongs to the request, not the process, and arrives as
|
|
30
|
+
* a 400 naming the variable — the same shape the GitHub endpoints use.
|
|
31
|
+
* @returns The assembled service.
|
|
32
|
+
* @throws ValidationError when configuration is incomplete.
|
|
33
|
+
*/
|
|
34
|
+
function getService(): IngestService {
|
|
35
|
+
if (cached) {
|
|
36
|
+
return cached
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
cached = createIngestServiceFromEnv()
|
|
41
|
+
return cached
|
|
42
|
+
} catch (error) {
|
|
43
|
+
throw new ValidationError(
|
|
44
|
+
`Course ingest is not configured. ${(error as Error).message}`
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads a form field as a boolean.
|
|
51
|
+
*
|
|
52
|
+
* Multer parses the text fields alongside the file into `req.body`, where they
|
|
53
|
+
* arrive as strings.
|
|
54
|
+
* @param value - Raw field value.
|
|
55
|
+
* @returns Whether the flag was set.
|
|
56
|
+
*/
|
|
57
|
+
const readFlag = (value: unknown): boolean =>
|
|
58
|
+
value === true || value === "true" || value === "1"
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Converts an ingest failure into its HTTP equivalent.
|
|
62
|
+
*
|
|
63
|
+
* Registered on the router rather than inside the handler so every route added
|
|
64
|
+
* later inherits the mapping instead of re-implementing it.
|
|
65
|
+
* @param error - Whatever the handler rejected with.
|
|
66
|
+
* @param _req - Unused.
|
|
67
|
+
* @param _res - Unused.
|
|
68
|
+
* @param next - Passes the translated error to the global handler.
|
|
69
|
+
* @returns Nothing.
|
|
70
|
+
*/
|
|
71
|
+
export function ingestErrorTranslator(
|
|
72
|
+
error: unknown,
|
|
73
|
+
_req: Request,
|
|
74
|
+
_res: Response,
|
|
75
|
+
next: express.NextFunction
|
|
76
|
+
): void {
|
|
77
|
+
next(isIngestError(error) ? toHttpError(error) : error)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The ingest endpoint.
|
|
82
|
+
*
|
|
83
|
+
* Accepts `multipart/form-data` rather than a raw zip body: multer with memory
|
|
84
|
+
* storage is already wired in this repo, no raw-body middleware is, and the
|
|
85
|
+
* client side of every existing zip upload already builds a FormData with a
|
|
86
|
+
* `file` field. Matching that means the producer has nothing new to learn.
|
|
87
|
+
* @returns A router to mount under a path prefix.
|
|
88
|
+
*/
|
|
89
|
+
export function createIngestRouter(): Router {
|
|
90
|
+
const router = express.Router()
|
|
91
|
+
const upload = createUploadMiddleware()
|
|
92
|
+
|
|
93
|
+
router.post(
|
|
94
|
+
"/packages",
|
|
95
|
+
upload.single(FILE_FIELD),
|
|
96
|
+
asyncHandler(async (req: Request, res: Response) => {
|
|
97
|
+
const service = getService()
|
|
98
|
+
|
|
99
|
+
// eslint-disable-next-line
|
|
100
|
+
// @ts-ignore multer augments the request; the Express types do not know
|
|
101
|
+
const uploaded = req.file
|
|
102
|
+
|
|
103
|
+
if (!uploaded) {
|
|
104
|
+
throw new ValidationError(
|
|
105
|
+
`A "${FILE_FIELD}" field holding the course zip is required`
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Shape first, credentials second. Authenticating costs a round trip to
|
|
110
|
+
// Rigobot, and spending it on a request that was never well-formed only
|
|
111
|
+
// buys a less accurate answer — a malformed call would come back 401
|
|
112
|
+
// instead of being told what it actually got wrong. Multer has already
|
|
113
|
+
// buffered the upload by this point either way, so nothing is saved by
|
|
114
|
+
// rejecting earlier.
|
|
115
|
+
const identity = await service.auth.authenticate(req.headers)
|
|
116
|
+
|
|
117
|
+
const body = (req.body || {}) as Record<string, unknown>
|
|
118
|
+
const files = await readPackageZip(uploaded.buffer)
|
|
119
|
+
|
|
120
|
+
const report = await ingestCoursePackage(
|
|
121
|
+
{
|
|
122
|
+
files,
|
|
123
|
+
requestedSlug: typeof body.slug === "string" ? body.slug : undefined,
|
|
124
|
+
overwrite: readFlag(body.overwrite),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
storage: service.storage,
|
|
128
|
+
registry: service.registryFor(identity.rigoToken),
|
|
129
|
+
organization: service.organization,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// 200 even when lessons were left out. A partial course is a course, and
|
|
134
|
+
// publish already answers this way: the caller gets the result plus what
|
|
135
|
+
// degraded, and decides how loudly to say it.
|
|
136
|
+
res.json({ message: "Course ingested", ...report })
|
|
137
|
+
})
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// Must come after the routes: Express only reaches an error middleware for
|
|
141
|
+
// failures raised by handlers registered before it.
|
|
142
|
+
router.use(ingestErrorTranslator)
|
|
143
|
+
|
|
144
|
+
return router
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Drops the memoized service.
|
|
149
|
+
*
|
|
150
|
+
* Exposed for tests, which change the environment between cases and need the
|
|
151
|
+
* next request to rebuild rather than reuse.
|
|
152
|
+
* @returns Nothing.
|
|
153
|
+
*/
|
|
154
|
+
export function resetIngestServiceCache(): void {
|
|
155
|
+
cached = undefined
|
|
156
|
+
}
|