@learnpack/learnpack 5.0.364 → 5.0.366

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.
@@ -39,6 +39,7 @@ import {
39
39
  addInteractivity,
40
40
  generateCodeChallenge,
41
41
  generateStepSlug,
42
+ getCompletionJob,
42
43
  } from "../utils/rigoActions"
43
44
  import * as dotenv from "dotenv"
44
45
 
@@ -55,6 +56,11 @@ import {
55
56
  planGenerationRetry,
56
57
  RetryPlan,
57
58
  } from "../utils/generationRetry"
59
+ import {
60
+ currentJobId,
61
+ isGenerationRunning,
62
+ resolveGenerationMode,
63
+ } from "../utils/continueGeneration"
58
64
  // import { handleAssetCreation } from "./publish"
59
65
  import axios from "axios"
60
66
  import * as FormData from "form-data"
@@ -2297,6 +2303,35 @@ class ServeCommand extends SessionCommand {
2297
2303
  )
2298
2304
  }
2299
2305
 
2306
+ const language = syllabus.courseInfo.language || "en"
2307
+ const previousStatus = exercise.status
2308
+
2309
+ // A second click, or a retry while the job still runs, would start
2310
+ // another paid job for the same lesson.
2311
+ if (previousStatus === "GENERATING") {
2312
+ const jobId = currentJobId(exercise, language)
2313
+ let job = null
2314
+ if (jobId) {
2315
+ try {
2316
+ job = await getCompletionJob(rigoToken, jobId, {
2317
+ timeoutMs: 5000,
2318
+ })
2319
+ } catch (error) {
2320
+ console.error(`Could not check job ${jobId}:`, error)
2321
+ }
2322
+ }
2323
+
2324
+ if (isGenerationRunning(exercise, language, job, Date.now())) {
2325
+ console.log(
2326
+ `Lesson ${lessonUid} is already being generated (job ${jobId}), not starting another one`
2327
+ )
2328
+ throw new ConflictError(
2329
+ `Lesson "${exercise.title}" is already being generated`,
2330
+ { code: "LESSON_ALREADY_GENERATING", completionId: jobId }
2331
+ )
2332
+ }
2333
+ }
2334
+
2300
2335
  // previous exercise
2301
2336
  let previousReadme = "---"
2302
2337
  const previousExercise = syllabus.lessons[exerciseIndex - 1]
@@ -2334,7 +2369,7 @@ class ServeCommand extends SessionCommand {
2334
2369
  // A new job by the user, so the automatic retries start over.
2335
2370
  syllabus.lessons[exerciseIndex].status = "GENERATING"
2336
2371
  syllabus.lessons[exerciseIndex].translations = {
2337
- [syllabus.courseInfo.language || "en"]: startedJob(completionId),
2372
+ [language]: startedJob(completionId),
2338
2373
  }
2339
2374
  console.log("Lesson", syllabus.lessons[exerciseIndex])
2340
2375
  if (syllabus.feedback && typeof syllabus.feedback === "string") {
@@ -2343,7 +2378,11 @@ class ServeCommand extends SessionCommand {
2343
2378
  syllabus.feedback = feedback
2344
2379
  }
2345
2380
 
2346
- syllabus.generationMode = mode
2381
+ syllabus.generationMode = resolveGenerationMode(
2382
+ previousStatus,
2383
+ syllabus.generationMode,
2384
+ mode
2385
+ )
2347
2386
 
2348
2387
  await saveSyllabus(courseSlug, syllabus, bucket)
2349
2388
  })
@@ -5479,11 +5518,19 @@ class ServeCommand extends SessionCommand {
5479
5518
  syllabus.courseInfo.language
5480
5519
  )
5481
5520
 
5521
+ const language = syllabus.courseInfo.language || "en"
5482
5522
  const initialSyllabus = {
5483
5523
  ...syllabus,
5484
5524
  lessons: syllabus.lessons.map((lesson, index) => {
5485
5525
  if (index < 1) {
5486
- return { ...lesson, generated: false, status: "GENERATING" }
5526
+ // Its start time, so a retry right away is refused even before
5527
+ // the job id is saved below.
5528
+ return {
5529
+ ...lesson,
5530
+ generated: false,
5531
+ status: "GENERATING",
5532
+ translations: { [language]: startedJob(0) },
5533
+ }
5487
5534
  }
5488
5535
 
5489
5536
  return {
@@ -5508,25 +5555,25 @@ class ServeCommand extends SessionCommand {
5508
5555
 
5509
5556
  const lastResult = "---"
5510
5557
 
5511
- // Use new two-phase generation workflow
5512
- const completionId = await startInitialContentGeneration(
5513
- rigoToken,
5514
- syllabus.lessons,
5515
- syllabus.courseInfo,
5516
- firstLesson,
5517
- courseSlug,
5518
- syllabus.courseInfo.purpose,
5519
- lastResult
5520
- )
5521
- if (firstLesson) {
5522
- firstLesson.translations = {
5523
- [syllabus.courseInfo.language || "en"]: {
5524
- completionId,
5525
- startedAt: Date.now(),
5526
- completedAt: 0,
5527
- },
5528
- }
5529
- }
5558
+ // In the course queue, so the webhook of this job waits until its id
5559
+ // is saved. The id was never saved before, so the first lesson had no
5560
+ // protection against a second job.
5561
+ await serializeByCourse(courseSlug, async () => {
5562
+ // Use new two-phase generation workflow
5563
+ const completionId = await startInitialContentGeneration(
5564
+ rigoToken,
5565
+ syllabus.lessons,
5566
+ syllabus.courseInfo,
5567
+ firstLesson,
5568
+ courseSlug,
5569
+ syllabus.courseInfo.purpose,
5570
+ lastResult
5571
+ )
5572
+
5573
+ await patchLesson(courseSlug, firstLesson.uid, bucket, lesson => {
5574
+ lesson.translations = { [language]: startedJob(completionId) }
5575
+ })
5576
+ })
5530
5577
 
5531
5578
  await createInitialReadme(
5532
5579
  JSON.stringify(syllabus.courseInfo),
@@ -17,6 +17,7 @@ import defaults from "./defaults"
17
17
  import { exercise } from "./exercise"
18
18
 
19
19
  import { rmSync } from "../file"
20
+ import { getLatestEditorVersion } from "../ideReleases"
20
21
  import { IConfigObj, TMode, TAgent } from "../../models/config"
21
22
  import {
22
23
  IConfigManagerAttributes,
@@ -29,9 +30,7 @@ import { IExercise } from "../../models/exercise-obj"
29
30
  const execAsync = promisify(exec)
30
31
 
31
32
  // eslint-disable-next-line
32
- const fetch = require("node-fetch")
33
- // eslint-disable-next-line
34
- const chalk = require("chalk")
33
+ const chalk = require("chalk");
35
34
 
36
35
  /* exercise folder name standard */
37
36
 
@@ -167,8 +166,7 @@ export default async ({
167
166
  configPath: confPath.config,
168
167
  },
169
168
  })
170
- if (!configObj.config)
171
- throw InternalError("Config object not found")
169
+ if (!configObj.config) throw InternalError("Config object not found")
172
170
 
173
171
  configObj.config.outputPath = confPath.base + "/dist"
174
172
 
@@ -263,15 +261,10 @@ throw InternalError("Config object not found")
263
261
  configObj.config.editor.mode =
264
262
  process.env.TERM_PROGRAM === "vscode" ? "extension" : "preview"
265
263
 
266
- if (version)
267
- configObj.config.editor.version = version
264
+ if (version) configObj.config.editor.version = version
268
265
  else if (configObj.config.editor.version === null) {
269
266
  Console.debug("Config version not found, downloading default.")
270
- const resp = await fetch(
271
- "https://raw.githubusercontent.com/learnpack/ide/master/package.json"
272
- )
273
- const packageJSON = await resp.json()
274
- configObj.config.editor.version = packageJSON.version || "5.0.0"
267
+ configObj.config.editor.version = await getLatestEditorVersion()
275
268
  }
276
269
 
277
270
  configObj.config.dirPath = "./" + confPath.base
@@ -303,8 +296,7 @@ configObj.config.editor.version = version
303
296
 
304
297
  // Execute the code and replace the value
305
298
  const { stdout } = await execAsync(variable.source)
306
- if (!configObj.config || !configObj.config.variables)
307
- return
299
+ if (!configObj.config || !configObj.config.variables) return
308
300
 
309
301
  configObj.config.variables[v] = stdout.trim()
310
302
  } catch (error) {
@@ -332,16 +324,14 @@ return
332
324
  const map: any = {
333
325
  python3: "python",
334
326
  }
335
- if (map[_l])
336
- return map[_l]
327
+ if (map[_l]) return map[_l]
337
328
  return _l
338
329
  }
339
330
 
340
331
  // decode aliases
341
332
  language = alias(language)
342
333
 
343
- if (this.validLanguages[language])
344
- return true
334
+ if (this.validLanguages[language]) return true
345
335
 
346
336
  Console.debug(`Validating engine for ${language} compilation`)
347
337
  let result = shell.exec("learnpack plugins", { silent: true })
@@ -430,8 +420,7 @@ return true
430
420
  const exercise = (configObj.exercises || []).find(
431
421
  ex => ex.slug === slug
432
422
  )
433
- if (!exercise)
434
- throw ValidationError(`Exercise ${slug} not found`)
423
+ if (!exercise) throw ValidationError(`Exercise ${slug} not found`)
435
424
 
436
425
  return exercise
437
426
  },
@@ -450,8 +439,7 @@ throw ValidationError(`Exercise ${slug} not found`)
450
439
  exercise.files.forEach((f: IFile) => {
451
440
  if (configObj.config) {
452
441
  const _path = configObj.config.outputPath + "/" + f.name
453
- if (f.hidden === false && fs.existsSync(_path))
454
- fs.unlinkSync(_path)
442
+ if (f.hidden === false && fs.existsSync(_path)) fs.unlinkSync(_path)
455
443
  }
456
444
  })
457
445
 
@@ -472,7 +460,9 @@ fs.unlinkSync(_path)
472
460
  ex => ex.slug === slug
473
461
  )
474
462
  if (!exercise)
475
- throw ValidationError(`Exercise ${slug} not found on the configuration`)
463
+ throw ValidationError(
464
+ `Exercise ${slug} not found on the configuration`
465
+ )
476
466
 
477
467
  if (configObj.config) {
478
468
  for (const fileName of fs.readdirSync(
@@ -494,8 +484,7 @@ fs.unlinkSync(_path)
494
484
  if (name === path.basename(configObj?.config?.dirPath || ""))
495
485
  return false
496
486
  // ignore folders that start with a dot
497
- if (name.charAt(0) === "." || name.charAt(0) === "_")
498
- return false
487
+ if (name.charAt(0) === "." || name.charAt(0) === "_") return false
499
488
 
500
489
  return fs.lstatSync(source).isDirectory()
501
490
  }
@@ -506,8 +495,7 @@ return false
506
495
  .map(name => path.join(source, name))
507
496
  .filter(isDirectory)
508
497
  // add the .learn folder
509
- if (!fs.existsSync(confPath.base))
510
- fs.mkdirSync(confPath.base)
498
+ if (!fs.existsSync(confPath.base)) fs.mkdirSync(confPath.base)
511
499
  // add the outout folder where webpack will publish the the html/css/js files
512
500
  if (
513
501
  configObj.config &&
@@ -537,8 +525,7 @@ fs.mkdirSync(confPath.base)
537
525
  createExercise: (slug: string, content: string, language: string) => {
538
526
  try {
539
527
  const dirPath = `${configObj.config?.exercisesPath}/${slug}`
540
- if (!fs.existsSync(dirPath))
541
- fs.mkdirSync(dirPath, { recursive: true })
528
+ if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true })
542
529
  const isEnglish = language === "us" || language === "en"
543
530
  const fileName = isEnglish ? "README.md" : `README.${language}.md`
544
531
  fs.writeFileSync(`${dirPath}/${fileName}`, content)
@@ -623,8 +610,7 @@ function deepMerge(...sources: any): any {
623
610
  value = deepMerge(acc[key], value)
624
611
  }
625
612
 
626
- if (value !== undefined)
627
- acc = { ...acc, [key]: value }
613
+ if (value !== undefined) acc = { ...acc, [key]: value }
628
614
  }
629
615
  }
630
616
  }
@@ -6,9 +6,18 @@ import * as targz from "targz"
6
6
  import Console from "../utils/console"
7
7
  import * as https from "https"
8
8
  import { InternalError } from "../utils/errors"
9
+ import {
10
+ FETCH_TIMEOUT_MS,
11
+ IDE_CLOUDFRONT_MANIFEST,
12
+ IDE_GITHUB_MANIFEST,
13
+ editorTarballUrl,
14
+ fetchTagList,
15
+ getLatestEditorVersion,
16
+ resolveEditorRelease,
17
+ } from "./ideReleases"
9
18
 
10
19
  // eslint-disable-next-line
11
- const fetch = require("node-fetch")
20
+ const fetch = require("node-fetch");
12
21
 
13
22
  export const decompress = (sourcePath: string, destinationPath: string) =>
14
23
  new Promise((resolve, reject) => {
@@ -34,81 +43,33 @@ export const downloadEditor = async (
34
43
  version: string | undefined,
35
44
  destination: string
36
45
  ) => {
37
- Console.info("Downloading the LearnPack coding UI, this may take a minute...")
46
+ Console.info(
47
+ "Downloading the LearnPack coding UI, this may take a minute..."
48
+ )
38
49
 
39
- if (!version) {
40
- const res = await fetch(
41
- "https://raw.githubusercontent.com/learnpack/ide/master/package.json"
42
- )
43
- const json = await res.json()
44
- version = json.version || "4.0.0"
50
+ if (!version) version = await getLatestEditorVersion()
45
51
 
46
- if (!version)
47
- throw InternalError(
48
- `Coding Editor version was not found on learnpack repository, check the config.editor.version property on learn.json`
49
- )
50
- }
52
+ const [cloudTags, githubTags] = await Promise.all([
53
+ fetchTagList(IDE_CLOUDFRONT_MANIFEST),
54
+ fetchTagList(IDE_GITHUB_MANIFEST),
55
+ ])
51
56
 
52
- const [major, minor] = version.split(".")
53
- const versionPrefix = `${major}.${minor}`
54
-
55
- let tags
56
- try {
57
- const tagsRes = await fetch(
58
- "https://raw.githubusercontent.com/learnpack/ide/master/versions.json"
59
- )
60
- tags = await tagsRes.json()
61
-
62
- if (!Array.isArray(tags)) {
63
- throw new TypeError(`Invalid versions.json format`)
64
- }
65
- } catch (error) {
57
+ let release = resolveEditorRelease(version, cloudTags, githubTags)
58
+ if (!release) {
66
59
  Console.debug(
67
- "Error reading versions.json, defaulting to version 4.0.2",
68
- error
60
+ `No IDE version found for ${version} in the versions manifests, defaulting to version 4.0.2`
69
61
  )
70
- version = "4.0.2"
71
- tags = [`learnpack-${version}.tar.gz`]
62
+ release = { version: "4.0.2", source: "github" }
72
63
  }
73
64
 
74
- let matchingTags
75
- try {
76
- if (tags.length === 0 || !tags)
77
- throw InternalError(`No found tags in versions.json ${tags}`)
78
-
79
- matchingTags = tags
80
- .filter(tag => tag.includes(`learnpack-${versionPrefix}`))
81
- .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
82
-
83
- if (matchingTags.length === 0)
84
- throw InternalError(
85
- `No matching version found for prefix ${versionPrefix} in the versions.json`
86
- )
87
- } catch (error) {
88
- Console.debug("Error processing tags, defaulting to version 4.0.2", error)
89
- version = "4.0.2"
90
- matchingTags = [`learnpack-${version}.tar.gz`]
91
- }
92
-
93
- const latestVersion = matchingTags[0]
94
- .replace("learnpack-", "")
95
- .replace(".tar.gz", "")
96
-
97
- const versionNumber = parseInt(latestVersion.split(".")[0])
98
-
99
- let url = `https://github.com/learnpack/coding-ide/blob/${latestVersion}/dist`
100
-
101
- if (versionNumber > 2)
102
- url = `https://github.com/learnpack/ide/blob/master/bin/learnpack-${latestVersion}.tar.gz`
103
-
104
- const resp2 = await fetch(url, { method: "HEAD" })
105
- if (!resp2.ok)
65
+ const url = editorTarballUrl(release.version, release.source)
66
+ const resp = await fetch(url, { method: "HEAD", timeout: FETCH_TIMEOUT_MS })
67
+ if (!resp.ok)
106
68
  throw InternalError(
107
- `Coding Editor ${latestVersion} was not found on learnpack repository, check the config.editor.version property on learn.json`
69
+ `Coding Editor ${release.version} was not found on ${release.source}, check the config.editor.version property on learn.json`
108
70
  )
109
71
 
110
- url += versionNumber > 2 ? `?raw=true` : `/app.tar.gz?raw=true`
111
-
72
+ Console.debug(`Using IDE ${release.version} from ${release.source}`)
112
73
  return download(url, destination)
113
74
  }
114
75
 
@@ -123,7 +84,7 @@ export const download = (url: string, dest: string) => {
123
84
  })
124
85
  file.on("error", err => {
125
86
  file.close()
126
- if (err.name === "EEXIST") {
87
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
127
88
  Console.debug("File already exists")
128
89
  resolve("File already exists")
129
90
  } else {
@@ -132,11 +93,10 @@ export const download = (url: string, dest: string) => {
132
93
  }
133
94
  })
134
95
  response.pipe(file)
135
- } else if (response.statusCode === 302 || response.statusCode === 301) {
136
- // Console.debug("Servers redirected to "+response.headers.location)
96
+ } else if ([301, 302, 303, 307, 308].includes(response.statusCode || 0)) {
137
97
  // Recursively follow redirects, only a 200 will resolve.
138
98
  if (response.headers.location) {
139
- download(response.headers.location, dest)
99
+ download(new URL(response.headers.location, url).toString(), dest)
140
100
  .then(() => resolve(/* */ ""))
141
101
  .catch(error => {
142
102
  Console.error(error)
@@ -0,0 +1,142 @@
1
+ import Console from "../utils/console"
2
+
3
+ // eslint-disable-next-line
4
+ const fetch = require("node-fetch");
5
+
6
+ // IDE tarballs published from 5.0.417 on live on CloudFront; older ones stay in the
7
+ // learnpack/ide GitHub repo, whose versions.json is frozen. Both lists are merged.
8
+ export const IDE_CLOUDFRONT_BASE =
9
+ "https://d3a7w3e3g8ydll.cloudfront.net/learnpack/ide"
10
+ export const IDE_CLOUDFRONT_MANIFEST = `${IDE_CLOUDFRONT_BASE}/versions.json`
11
+ export const IDE_GITHUB_MANIFEST =
12
+ "https://raw.githubusercontent.com/learnpack/ide/master/versions.json"
13
+ export const IDE_GITHUB_PACKAGE =
14
+ "https://raw.githubusercontent.com/learnpack/ide/master/package.json"
15
+
16
+ export const FETCH_TIMEOUT_MS = 10_000
17
+ const DEFAULT_EDITOR_VERSION = "5.0.0"
18
+
19
+ export type TEditorSource = "cloudfront" | "github";
20
+
21
+ export interface IEditorRelease {
22
+ version: string;
23
+ source: TEditorSource;
24
+ }
25
+
26
+ interface IParsedVersion {
27
+ version: string;
28
+ major: number;
29
+ minor: number;
30
+ patch: number;
31
+ }
32
+
33
+ export const parseVersion = (version: string): IParsedVersion | null => {
34
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim())
35
+ if (!match) return null
36
+ return {
37
+ version: match.slice(1, 4).join("."),
38
+ major: Number(match[1]),
39
+ minor: Number(match[2]),
40
+ patch: Number(match[3]),
41
+ }
42
+ }
43
+
44
+ // "learnpack-5.0.417.tar.gz" -> parsed 5.0.417; anything else -> null
45
+ export const parseTag = (tag: string): IParsedVersion | null => {
46
+ const match = /^learnpack-(\d+\.\d+\.\d+)\.tar\.gz$/.exec(tag)
47
+ return match ? parseVersion(match[1]) : null
48
+ }
49
+
50
+ const compareVersions = (a: IParsedVersion, b: IParsedVersion): number =>
51
+ a.major - b.major || a.minor - b.minor || a.patch - b.patch
52
+
53
+ const parseTags = (tags: string[] | null): IParsedVersion[] =>
54
+ (tags || []).map(parseTag).filter((v): v is IParsedVersion => v !== null)
55
+
56
+ // Highest version listed in a manifest, or null when there is none.
57
+ export const highestVersion = (tags: string[] | null): string | null => {
58
+ const sorted = parseTags(tags).sort(compareVersions)
59
+ return sorted.length > 0 ? sorted[sorted.length - 1].version : null
60
+ }
61
+
62
+ // Picks the tarball to download for the requested version. Only major.minor of the
63
+ // request is used and the highest patch available wins, as the CLI always did.
64
+ // The source is CloudFront when the version is in its manifest, GitHub otherwise.
65
+ export const resolveEditorRelease = (
66
+ requested: string,
67
+ cloudTags: string[] | null,
68
+ githubTags: string[] | null
69
+ ): IEditorRelease | null => {
70
+ const [major, minor] = requested.split(".").map(part => Number(part))
71
+ if (!Number.isInteger(major) || !Number.isInteger(minor)) return null
72
+
73
+ const cloud = parseTags(cloudTags)
74
+ const candidates = [...cloud, ...parseTags(githubTags)]
75
+ .filter(v => v.major === major && v.minor === minor)
76
+ .sort(compareVersions)
77
+
78
+ if (candidates.length === 0) return null
79
+
80
+ const { version } = candidates[candidates.length - 1]
81
+ const source: TEditorSource = cloud.some(v => v.version === version) ?
82
+ "cloudfront" :
83
+ "github"
84
+
85
+ return { version, source }
86
+ }
87
+
88
+ export const editorTarballUrl = (
89
+ version: string,
90
+ source: TEditorSource
91
+ ): string => {
92
+ if (source === "cloudfront") {
93
+ return `${IDE_CLOUDFRONT_BASE}/learnpack-${version}.tar.gz`
94
+ }
95
+
96
+ const major = Number.parseInt(version.split(".")[0], 10)
97
+ return major > 2 ?
98
+ `https://github.com/learnpack/ide/blob/master/bin/learnpack-${version}.tar.gz?raw=true` :
99
+ `https://github.com/learnpack/coding-ide/blob/${version}/dist/app.tar.gz?raw=true`
100
+ }
101
+
102
+ // Fetches a versions manifest. Never throws: any failure returns null.
103
+ export const fetchTagList = async (url: string): Promise<string[] | null> => {
104
+ try {
105
+ const res = await fetch(url, { timeout: FETCH_TIMEOUT_MS })
106
+ if (!res.ok) {
107
+ Console.debug(`Could not read ${url}: ${res.status} ${res.statusText}`)
108
+ return null
109
+ }
110
+
111
+ const json = await res.json()
112
+ if (!Array.isArray(json) || !json.every(tag => typeof tag === "string")) {
113
+ Console.debug(`Invalid versions manifest format at ${url}`)
114
+ return null
115
+ }
116
+
117
+ return json
118
+ } catch (error) {
119
+ Console.debug(`Could not read ${url}`, error)
120
+ return null
121
+ }
122
+ }
123
+
124
+ // Latest published IDE version: CloudFront manifest, then GitHub package.json, then a default.
125
+ export const getLatestEditorVersion = async (): Promise<string> => {
126
+ const fromCloud = highestVersion(await fetchTagList(IDE_CLOUDFRONT_MANIFEST))
127
+ if (fromCloud) return fromCloud
128
+
129
+ try {
130
+ const res = await fetch(IDE_GITHUB_PACKAGE, { timeout: FETCH_TIMEOUT_MS })
131
+ if (res.ok) {
132
+ const json = await res.json()
133
+ if (typeof json?.version === "string" && parseVersion(json.version)) {
134
+ return json.version
135
+ }
136
+ }
137
+ } catch (error) {
138
+ Console.debug("Could not read the IDE package.json from GitHub", error)
139
+ }
140
+
141
+ return DEFAULT_EDITOR_VERSION
142
+ }