@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.
- package/lib/commands/serve.js +39 -14
- package/lib/managers/config/index.js +2 -5
- package/lib/managers/file.js +19 -51
- package/lib/managers/ideReleases.d.ts +24 -0
- package/lib/managers/ideReleases.js +111 -0
- package/lib/utils/continueGeneration.d.ts +65 -0
- package/lib/utils/continueGeneration.js +82 -0
- package/lib/utils/rigoActions.d.ts +4 -1
- package/lib/utils/rigoActions.js +3 -1
- package/package.json +1 -1
- package/src/commands/serve.ts +69 -22
- package/src/managers/config/index.ts +17 -31
- package/src/managers/file.ts +30 -70
- package/src/managers/ideReleases.ts +142 -0
- package/src/ui/_app/app.js +130 -72
- package/src/ui/app.tar.gz +0 -0
- package/src/utils/continueGeneration.ts +108 -0
- package/src/utils/rigoActions.ts +5 -1
package/src/commands/serve.ts
CHANGED
|
@@ -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
|
-
[
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/src/managers/file.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
52
|
+
const [cloudTags, githubTags] = await Promise.all([
|
|
53
|
+
fetchTagList(IDE_CLOUDFRONT_MANIFEST),
|
|
54
|
+
fetchTagList(IDE_GITHUB_MANIFEST),
|
|
55
|
+
])
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
68
|
-
error
|
|
60
|
+
`No IDE version found for ${version} in the versions manifests, defaulting to version 4.0.2`
|
|
69
61
|
)
|
|
70
|
-
|
|
71
|
-
tags = [`learnpack-${version}.tar.gz`]
|
|
62
|
+
release = { version: "4.0.2", source: "github" }
|
|
72
63
|
}
|
|
73
64
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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 ${
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
+
}
|