@learnpack/learnpack 5.0.275 → 5.0.276

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.
Files changed (71) hide show
  1. package/README.md +409 -409
  2. package/lib/commands/audit.js +15 -15
  3. package/lib/commands/breakToken.js +19 -19
  4. package/lib/commands/clean.js +3 -3
  5. package/lib/commands/init.js +41 -41
  6. package/lib/commands/logout.js +3 -3
  7. package/lib/commands/publish.js +5 -10
  8. package/lib/commands/serve.js +3 -2
  9. package/lib/creatorDist/assets/index-BfLyIQVh.js +10343 -10224
  10. package/lib/managers/config/index.js +77 -77
  11. package/lib/utils/api.d.ts +1 -1
  12. package/lib/utils/api.js +12 -9
  13. package/lib/utils/creatorUtilities.js +14 -14
  14. package/package.json +1 -1
  15. package/src/commands/audit.ts +487 -487
  16. package/src/commands/breakToken.ts +67 -67
  17. package/src/commands/clean.ts +30 -30
  18. package/src/commands/init.ts +650 -650
  19. package/src/commands/logout.ts +38 -38
  20. package/src/commands/publish.ts +20 -25
  21. package/src/commands/serve.ts +8 -3
  22. package/src/commands/start.ts +333 -333
  23. package/src/commands/translate.ts +123 -123
  24. package/src/creator/README.md +54 -54
  25. package/src/creator/eslint.config.js +28 -28
  26. package/src/creator/src/components/syllabus/ContentIndex.tsx +312 -312
  27. package/src/creator/src/i18n.ts +28 -28
  28. package/src/creator/src/index.css +217 -217
  29. package/src/creator/src/locales/en.json +126 -126
  30. package/src/creator/src/locales/es.json +126 -126
  31. package/src/creator/src/utils/configTypes.ts +122 -122
  32. package/src/creator/src/utils/constants.ts +13 -13
  33. package/src/creator/src/utils/creatorUtils.ts +46 -46
  34. package/src/creator/src/utils/eventBus.ts +2 -2
  35. package/src/creator/src/utils/lib.ts +468 -468
  36. package/src/creator/src/utils/socket.ts +61 -61
  37. package/src/creator/src/utils/store.ts +222 -222
  38. package/src/creator/src/vite-env.d.ts +1 -1
  39. package/src/creator/vite.config.ts +13 -13
  40. package/src/creatorDist/assets/index-BfLyIQVh.js +10343 -10224
  41. package/src/managers/config/defaults.ts +49 -49
  42. package/src/managers/config/exercise.ts +364 -364
  43. package/src/managers/config/index.ts +775 -775
  44. package/src/managers/file.ts +236 -236
  45. package/src/managers/server/routes.ts +554 -554
  46. package/src/managers/session.ts +182 -182
  47. package/src/managers/telemetry.ts +188 -188
  48. package/src/models/action.ts +13 -13
  49. package/src/models/config-manager.ts +28 -28
  50. package/src/models/config.ts +106 -106
  51. package/src/models/creator.ts +47 -47
  52. package/src/models/exercise-obj.ts +30 -30
  53. package/src/models/session.ts +39 -39
  54. package/src/models/socket.ts +61 -61
  55. package/src/models/status.ts +16 -16
  56. package/src/ui/_app/app.css +1 -1
  57. package/src/ui/_app/app.js +366 -363
  58. package/src/ui/app.tar.gz +0 -0
  59. package/src/utils/BaseCommand.ts +56 -56
  60. package/src/utils/api.ts +53 -39
  61. package/src/utils/audit.ts +392 -392
  62. package/src/utils/checkNotInstalled.ts +267 -267
  63. package/src/utils/configBuilder.ts +82 -82
  64. package/src/utils/convertCreds.js +34 -34
  65. package/src/utils/creatorUtilities.ts +504 -504
  66. package/src/utils/incrementVersion.js +74 -74
  67. package/src/utils/misc.ts +58 -58
  68. package/src/utils/rigoActions.ts +500 -500
  69. package/src/utils/sidebarGenerator.ts +195 -195
  70. package/src/utils/templates/isolated/exercises/01-hello-world/README.es.md +26 -26
  71. package/src/utils/templates/isolated/exercises/01-hello-world/README.md +26 -26
@@ -1,650 +1,650 @@
1
- import { flags } from "@oclif/command"
2
- import BaseCommand from "../utils/BaseCommand"
3
- import ora from "ora"
4
-
5
- // eslint-disable-next-line
6
- import * as fs from "fs-extra"
7
- import * as prompts from "prompts"
8
- import cli from "cli-ux"
9
- import * as eta from "eta"
10
-
11
- import api from "../utils/api"
12
- import Console from "../utils/console"
13
- import { ValidationError } from "../utils/errors"
14
-
15
- import * as path from "path"
16
- import {
17
- // hasCreatorPermission,
18
- generateImage,
19
- downloadImage,
20
- interactiveCreation,
21
- createCodeFile,
22
- readmeCreator,
23
- createPreviewReadme,
24
- makeReadmeReadable,
25
- isValidRigoToken,
26
- } from "../utils/rigoActions"
27
- import { getConsumable } from "../utils/api"
28
- import {
29
- checkReadability,
30
- PackageInfo,
31
- getExInfo,
32
- extractImagesFromMarkdown,
33
- getFilenameFromUrl,
34
- makePackageInfo,
35
- estimateDuration,
36
- appendContentIndex,
37
- appendAIRules,
38
- slugify,
39
- } from "../utils/creatorUtilities"
40
- import SessionManager from "../managers/session"
41
-
42
- const durationByKind: Record<string, number> = {
43
- code: 5,
44
- quiz: 3,
45
- read: 2,
46
- }
47
-
48
- function estimateActivities(estimatedDuration: number) {
49
- const result: Record<string, number> = {}
50
-
51
- for (const kind of Object.keys(durationByKind)) {
52
- result[kind] = Math.floor(estimatedDuration / durationByKind[kind])
53
- }
54
-
55
- return result
56
- }
57
-
58
- const PARAMS = {
59
- expected_grade_level: "6",
60
- max_fkgl: 8,
61
- max_words: 200,
62
- max_rewrite_attempts: 3,
63
- max_title_length: 50,
64
- }
65
-
66
- const whichTargetAudienceAndEstimatedDuration = async () => {
67
- const res = await prompts([
68
- {
69
- type: "text",
70
- name: "targetAudience",
71
- message: "What is the target audience for this tutorial?",
72
- },
73
- {
74
- type: "number",
75
- name: "estimatedDuration",
76
- message: "What is the estimated duration for this tutorial in minutes?",
77
- },
78
- ])
79
- return {
80
- targetAudience: res.targetAudience,
81
- estimatedDuration: res.estimatedDuration,
82
- }
83
- }
84
-
85
- async function processExercise(
86
- rigoToken: string,
87
- steps: string[],
88
- packageContext: string,
89
- exercise: any,
90
- exercisesDir: string
91
- ): Promise<string> {
92
- const { exNumber, exTitle, kind, description } = getExInfo(exercise)
93
- const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
94
-
95
- const readme = await readmeCreator(
96
- rigoToken,
97
- {
98
- title: `${exNumber} - ${exTitle}`,
99
- output_lang: "en",
100
- list_of_exercises: steps.join(","),
101
- tutorial_description: packageContext,
102
- lesson_description: description,
103
- kind: kind.toLowerCase(),
104
- last_lesson: "",
105
- },
106
- "learnpack-lesson-writer"
107
- )
108
-
109
- const duration = durationByKind[kind.toLowerCase()]
110
- let attempts = 0
111
- let readability = checkReadability(readme.parsed.content, 200, duration || 1)
112
-
113
- while (
114
- readability.fkglResult.fkgl > PARAMS.max_fkgl &&
115
- attempts < PARAMS.max_rewrite_attempts
116
- ) {
117
- Console.warning(
118
- `The lesson ${exTitle} has as readability score of ${
119
- readability.fkglResult.fkgl
120
- } . It exceeds the maximum of words per minute. Rewriting it... (Attempt ${
121
- attempts + 1
122
- })`
123
- )
124
-
125
- // eslint-disable-next-line
126
- const reducedReadme = await makeReadmeReadable(
127
- rigoToken,
128
- {
129
- lesson: readability.body,
130
- number_of_words: readability.minutes.toString(),
131
- expected_number_words: PARAMS.max_words.toString(),
132
- fkgl_results: JSON.stringify(readability.fkglResult),
133
- expected_grade_level: PARAMS.expected_grade_level,
134
- },
135
- "learnpack-lesson-writer"
136
- )
137
-
138
- // console.log("REDUCED README START", reducedReadme, "REDUCED README END")
139
-
140
- if (!reducedReadme) break
141
-
142
- readability = checkReadability(
143
- reducedReadme.parsed.content,
144
- PARAMS.max_words,
145
- duration || 1
146
- )
147
-
148
- attempts++
149
- }
150
-
151
- Console.success(
152
- `After ${attempts} attempts, the lesson ${exTitle} has a readability score of ${
153
- readability.fkglResult.fkgl
154
- } using FKGL. And it has ${readability.minutes.toFixed(
155
- 2
156
- )} minutes of reading time.`
157
- )
158
-
159
- const readmeFilename = "README.md"
160
- fs.writeFileSync(
161
- path.join(exerciseDir, readmeFilename),
162
- readability.newMarkdown
163
- )
164
-
165
- if (kind.toLowerCase() === "code") {
166
- const codeFile = await createCodeFile(rigoToken, {
167
- readme: readability.newMarkdown,
168
- tutorial_info: packageContext,
169
- })
170
-
171
- fs.writeFileSync(
172
- path.join(
173
- exerciseDir,
174
- `app.${codeFile.parsed.extension.replace(".", "")}`
175
- ),
176
- codeFile.parsed.content
177
- )
178
- }
179
-
180
- return readability.newMarkdown
181
- }
182
-
183
- const initializeInteractiveCreation = async (
184
- rigoToken: string,
185
- courseInfo: string
186
- ): Promise<{
187
- steps: string[]
188
- title: string
189
- description: string
190
- interactions: string
191
- difficulty: string
192
- duration: number
193
- }> => {
194
- let prevInteractions = ""
195
- let isReady = false
196
- let currentSteps = []
197
- let currentTitle = ""
198
- let currentDescription = ""
199
- let currentDifficulty = ""
200
-
201
- while (!isReady) {
202
- const spinner = ora("Thinking...").start()
203
- let wholeInfo = courseInfo
204
- wholeInfo += `
205
- Current title: ${currentTitle}
206
- Current description: ${currentDescription}
207
- `
208
- // eslint-disable-next-line
209
- const res = await interactiveCreation(rigoToken, {
210
- courseInfo: wholeInfo,
211
- prevInteractions: prevInteractions,
212
- })
213
- spinner.succeed("Done!")
214
- currentSteps = res.parsed.listOfSteps
215
- isReady = res.parsed.ready
216
- if (res.parsed.title && currentTitle !== res.parsed.title) {
217
- currentTitle = res.parsed.title
218
- }
219
-
220
- if (
221
- res.parsed.description &&
222
- currentDescription !== res.parsed.description
223
- ) {
224
- currentDescription = res.parsed.description
225
- }
226
-
227
- if (res.parsed.difficulty && currentDifficulty !== res.parsed.difficulty) {
228
- currentDifficulty = res.parsed.difficulty
229
- }
230
-
231
- if (!isReady) {
232
- console.log(currentSteps)
233
- Console.info(`AI: ${res.parsed.aiMessage}`)
234
- prevInteractions += `\nAI: ${res.parsed.aiMessage}`
235
- // eslint-disable-next-line
236
- const userMessage = await prompts([
237
- {
238
- type: "text",
239
- name: "userMessage",
240
- message: "Your message: ",
241
- },
242
- ])
243
- prevInteractions += `\nUser: ${userMessage.userMessage}`
244
- }
245
- }
246
-
247
- const duration = estimateDuration(currentSteps)
248
- return {
249
- steps: currentSteps,
250
- title: currentTitle,
251
- description: currentDescription,
252
- interactions: prevInteractions,
253
- difficulty: currentDifficulty,
254
- duration,
255
- }
256
- }
257
-
258
- process.emitWarning = (warning: string) => {
259
- Console.debug("A Warning was emitted by Node.js: ", warning)
260
- }
261
-
262
- const handleAILogic = async (tutorialDir: string, packageInfo: PackageInfo) => {
263
- fs.removeSync(path.join(tutorialDir, "exercises", "01-hello-world"))
264
-
265
- let sessionPayload = await SessionManager.getPayload()
266
-
267
- const sessionExists = sessionPayload && sessionPayload.rigobot
268
-
269
- const isValidToken =
270
- sessionExists && sessionPayload.rigobot.key ?
271
- await isValidRigoToken(sessionPayload.rigobot.key) :
272
- false
273
-
274
- const isValidBreathecodeToken =
275
- sessionExists && sessionPayload.token ?
276
- await api.validateToken(sessionPayload.token) :
277
- false
278
-
279
- if (!sessionExists || !isValidBreathecodeToken || !isValidToken) {
280
- Console.info(
281
- "Almost there! First you need to login with 4Geeks.com to use AI Generation tool for creators. You can create a new account here: https://4geeks.com/checkout?plan=4geeks-creator"
282
- )
283
- try {
284
- sessionPayload = await SessionManager.login()
285
- } catch (error) {
286
- Console.error("Error trying to authenticate")
287
- Console.error((error as TypeError).message || (error as string))
288
- }
289
- }
290
-
291
- const rigoToken = sessionPayload.rigobot.key
292
-
293
- const consumable = await getConsumable(sessionPayload.token, "ai-generation")
294
-
295
- if (consumable.count === 0) {
296
- Console.error(
297
- "It seems you cannot generate tutorials with AI. Make sure you creator subscription is up to date here: https://4geeks.com/profile/subscriptions. If you believe there is an issue you can always contact support@4geeks.com"
298
- )
299
- process.exit(1)
300
- }
301
-
302
- // const isCreator = await hasCreatorPermission(rigoToken)
303
- // if (!isCreator) {
304
- // Console.error(
305
- // "👀 Oops! You need to be a creator to use our specialized AI. Please contact support"
306
- // )
307
- // process.exit(1)
308
- // }
309
-
310
- Console.success("🎉 Let's begin this learning journey!")
311
-
312
- const { targetAudience, estimatedDuration } =
313
- await whichTargetAudienceAndEstimatedDuration()
314
- const contentIndex = await appendContentIndex()
315
- const airules = await appendAIRules()
316
-
317
- if (airules) {
318
- const rulesDir = path.join(tutorialDir, ".learn", "rules")
319
-
320
- // Crear el directorio si no existe
321
- fs.mkdirSync(rulesDir, { recursive: true })
322
-
323
- fs.writeFileSync(path.join(rulesDir, "airules.txt"), airules)
324
- }
325
-
326
- let packageContext = `
327
- \n
328
- Title: "${packageInfo.title.us}"
329
- Description: "${packageInfo.description.us}"
330
- Target Audience: "${targetAudience}"
331
- Estimated Duration: "${estimatedDuration} minutes"
332
-
333
- ${
334
- contentIndex ?
335
- `Content Index submitted by the user, use this to guide your creation. Keep in mind that your tutorial should contain these topics:
336
- ---
337
- ${contentIndex}
338
- ---
339
- ` :
340
- ""
341
- }
342
-
343
- This is the duration for each type of step, use it to estimate the number of steps to create:
344
- ${Object.entries(durationByKind)
345
- .map(([key, value]) => `${key}: ${value} minutes`)
346
- .join("\n")}
347
-
348
-
349
- Within the estimated duration, is possible to have the following activities:
350
- Format=
351
- Activity: Maximum number of steps for duration
352
-
353
- Estimated activities:
354
- ${JSON.stringify(estimateActivities(estimatedDuration))}
355
-
356
- You should create a tutorial that is engaging and fun to follow.
357
-
358
-
359
- ${
360
- airules ?
361
- `
362
- This is a list of rules you need to follow when creating the tutorial:
363
- ${airules}
364
- ` :
365
- ""
366
- }
367
- `
368
-
369
- const { steps, title, description, duration, difficulty } =
370
- await initializeInteractiveCreation(rigoToken, packageContext)
371
- packageInfo.title.us = title
372
- packageInfo.description.us = description
373
- packageInfo.duration = duration
374
- packageInfo.difficulty = difficulty
375
-
376
- packageContext = `
377
- Title: "${title}"
378
- Description: "${description}"
379
- Target Audience: "${targetAudience}"
380
- List of exercises: ${steps.join(", ")}
381
-
382
- AI Rules: ${airules}
383
- `
384
- const exercisesDir = path.join(tutorialDir, "exercises")
385
- fs.ensureDirSync(exercisesDir)
386
-
387
- Console.info("Creating lessons...")
388
- for (const [index, exercise] of steps.entries()) {
389
- const { exNumber, exTitle } = getExInfo(exercise)
390
- const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
391
- fs.ensureDirSync(exerciseDir)
392
- }
393
-
394
- const exercisePromises = steps.map(exercise =>
395
- processExercise(rigoToken, steps, packageContext, exercise, exercisesDir)
396
- )
397
-
398
- const readmeContents = await Promise.all(exercisePromises)
399
- Console.success("Lessons created! 🎉")
400
-
401
- Console.info("Generating images for the lessons...")
402
- let imagesArray: any[] = []
403
-
404
- for (const content of readmeContents) {
405
- imagesArray = [...imagesArray, ...extractImagesFromMarkdown(content)]
406
- }
407
-
408
- const imagePromises = imagesArray.map(async (image: any) => {
409
- try {
410
- const filename = getFilenameFromUrl(image.url)
411
- const webhookUrl = `${process.env.HOST}/webhooks/${slugify(
412
- packageInfo.title.us
413
- )}/images/${filename}`
414
-
415
- const imagePath = path.join(tutorialDir, ".learn", "assets", filename)
416
-
417
- const res = await generateImage(rigoToken, {
418
- prompt: image.alt,
419
- callbackUrl: webhookUrl,
420
- })
421
- await downloadImage(res.image_url, imagePath)
422
- return true
423
- } catch {
424
- Console.error(`Error downloading image ${image.url}`)
425
-
426
- return false
427
- }
428
- })
429
- await Promise.all(imagePromises)
430
- Console.info(
431
- "Images generated successfully! 🎉 Your tutorial will be ready soon!"
432
- )
433
-
434
- Console.info("Creating preview readme...")
435
-
436
- await createPreviewReadme(tutorialDir, packageInfo, rigoToken, readmeContents)
437
- const imagePath = path.join(tutorialDir, "preview.png")
438
-
439
- const res = await generateImage(rigoToken, {
440
- prompt:
441
- "Generate a preview image for the tutorial. This is all the tutorial information: " +
442
- packageContext +
443
- "\n Generate only a basic preview image, add the tutorial Title as a text add the top middle, avoid adding any other text elements. Try to generate an image that related with the tutorial content.",
444
- callbackUrl: `${process.env.HOST}/webhooks/${slugify(
445
- packageInfo.title.us
446
- )}/images/preview.png`,
447
- })
448
-
449
- // await downloadImage(res.image_url, imagePath)
450
-
451
- return true
452
- }
453
-
454
- const getChoices = async (empty: boolean) => {
455
- const defaultChoices = {
456
- title: "My Interactive Tutorial",
457
- description: "",
458
- difficulty: "beginner",
459
- duration: 5,
460
- useAI: "no",
461
- grading: "isolated",
462
- }
463
-
464
- if (empty) {
465
- return defaultChoices
466
- }
467
-
468
- const choices = await prompts([
469
- {
470
- type: "select",
471
- name: "grading",
472
- message: "How are you going to grade students or yourself?",
473
- choices: [
474
- {
475
- title:
476
- "No grading: No feedback or testing whatsoever, similar to a an interactive book.",
477
- value: null,
478
- },
479
- {
480
- title: "Isolated: Each step is a new separate exercise",
481
- value: "isolated",
482
- },
483
- {
484
- title:
485
- "Step by step: Each step builds on top of each other like an incremental tutorial",
486
- value: "incremental",
487
- },
488
- ],
489
- },
490
- {
491
- type: "text",
492
-
493
- name: "title",
494
- initial: "My Interactive Tutorial",
495
- message: "Title for your tutorial? Press enter to leave as it is",
496
- validate: (value: string) => {
497
- if (value.length > PARAMS.max_title_length) {
498
- return `Title must be less than ${PARAMS.max_title_length} characters`
499
- }
500
-
501
- return true
502
- },
503
- },
504
- {
505
- type: "text",
506
- name: "description",
507
- initial: "",
508
- message: "Description for your tutorial? Press enter to leave blank",
509
- },
510
-
511
- {
512
- type: "select",
513
- name: "useAI",
514
- message:
515
- "Want a little bit of AI magic to help you? Our AI can craft the tutorial for you",
516
- choices: [
517
- {
518
- title: "Yes, please help me",
519
- value: "yes",
520
- },
521
- { title: "No, thanks, I prefer to do it manually", value: "no" },
522
- ],
523
- },
524
- ])
525
-
526
- const completeChoices = {
527
- ...choices,
528
- difficulty: "beginner",
529
- duration: 30,
530
- }
531
- return completeChoices
532
- }
533
-
534
- class InitComand extends BaseCommand {
535
- static description =
536
- "Create a new learning package: Book, Tutorial or Exercise"
537
-
538
- static flags = {
539
- ...BaseCommand.flags,
540
- grading: flags.help({ char: "h" }),
541
- }
542
-
543
- async run() {
544
- const { flags } = this.parse(InitComand)
545
-
546
- await alreadyInitialized()
547
-
548
- const choices = await getChoices(flags.yes)
549
-
550
- const packageInfo = makePackageInfo(choices)
551
-
552
- const tutorialDir = `./${packageInfo.slug}`
553
- fs.ensureDirSync(tutorialDir)
554
-
555
- const templatesDir = path.resolve(
556
- __dirname,
557
- "../../src/utils/templates/" + (choices.grading || "no-grading")
558
- )
559
- if (!fs.existsSync(templatesDir))
560
- throw ValidationError(`Template ${templatesDir} does not exists`)
561
- await fs.copySync(templatesDir, tutorialDir)
562
-
563
- if (choices.useAI === "yes") {
564
- await handleAILogic(tutorialDir, packageInfo)
565
- }
566
-
567
- const languages = ["en", "es"]
568
-
569
- // Creating README files
570
- for (const language of languages) {
571
- const readmeFilename = `README${language !== "en" ? `.${language}` : ""}`
572
- const readmeTemplatePath = path.resolve(
573
- templatesDir,
574
- `${readmeFilename}.ejs`
575
- )
576
-
577
- if (choices.useAI !== "yes") {
578
- const readmeObject = {
579
- title: packageInfo.title.us,
580
- description: packageInfo.description.us,
581
- grading: packageInfo.grading,
582
- difficulty: packageInfo.difficulty,
583
- duration: packageInfo.duration,
584
- }
585
- const readmeContent = eta.render(
586
- fs.readFileSync(readmeTemplatePath, "utf-8"),
587
- readmeObject
588
- )
589
- fs.writeFileSync(
590
- path.join(tutorialDir, `${readmeFilename}.md`),
591
- readmeContent
592
- )
593
- }
594
-
595
- if (fs.existsSync(path.join(tutorialDir, `${readmeFilename}.ejs`)))
596
- fs.removeSync(path.join(tutorialDir, `${readmeFilename}.ejs`))
597
- }
598
-
599
- cli.action.start("Initializing package")
600
-
601
- if (!fs.existsSync(path.join(tutorialDir, ".gitignore")))
602
- fs.copyFile(
603
- path.resolve(__dirname, "../../src/utils/templates/gitignore.txt"),
604
- path.join(tutorialDir, ".gitignore")
605
- )
606
-
607
- fs.writeFileSync(
608
- path.join(tutorialDir, "learn.json"),
609
- JSON.stringify(packageInfo, null, 2)
610
- )
611
-
612
- cli.action.stop()
613
- Console.success(`😋 Package initialized successfully in ${tutorialDir}`)
614
- Console.help(
615
- `Get inside the tutorial with the command: $ cd ${tutorialDir}`
616
- )
617
- Console.help(
618
- `Start the exercises by running the following command on your terminal: $ learnpack start`
619
- )
620
- process.exit(0)
621
- }
622
- }
623
-
624
- const alreadyInitialized = () =>
625
- new Promise((resolve, reject) => {
626
- fs.readdir("./", function (err: any, files: any) {
627
- files = files.filter((f: any) =>
628
- [".learn", "learn.json", "bc.json", ".breathecode"].includes(f)
629
- )
630
- if (err) {
631
- reject(ValidationError(err.message))
632
- throw ValidationError(err.message)
633
- } else if (files.length > 0) {
634
- reject(
635
- ValidationError(
636
- "It seems the package is already initialized because we've found the following files: " +
637
- files.join(",")
638
- )
639
- )
640
- throw ValidationError(
641
- "It seems the package is already initialized because we've found the following files: " +
642
- files.join(",")
643
- )
644
- }
645
-
646
- resolve(false)
647
- })
648
- })
649
-
650
- export default InitComand
1
+ import { flags } from "@oclif/command"
2
+ import BaseCommand from "../utils/BaseCommand"
3
+ import ora from "ora"
4
+
5
+ // eslint-disable-next-line
6
+ import * as fs from "fs-extra"
7
+ import * as prompts from "prompts"
8
+ import cli from "cli-ux"
9
+ import * as eta from "eta"
10
+
11
+ import api from "../utils/api"
12
+ import Console from "../utils/console"
13
+ import { ValidationError } from "../utils/errors"
14
+
15
+ import * as path from "path"
16
+ import {
17
+ // hasCreatorPermission,
18
+ generateImage,
19
+ downloadImage,
20
+ interactiveCreation,
21
+ createCodeFile,
22
+ readmeCreator,
23
+ createPreviewReadme,
24
+ makeReadmeReadable,
25
+ isValidRigoToken,
26
+ } from "../utils/rigoActions"
27
+ import { getConsumable } from "../utils/api"
28
+ import {
29
+ checkReadability,
30
+ PackageInfo,
31
+ getExInfo,
32
+ extractImagesFromMarkdown,
33
+ getFilenameFromUrl,
34
+ makePackageInfo,
35
+ estimateDuration,
36
+ appendContentIndex,
37
+ appendAIRules,
38
+ slugify,
39
+ } from "../utils/creatorUtilities"
40
+ import SessionManager from "../managers/session"
41
+
42
+ const durationByKind: Record<string, number> = {
43
+ code: 5,
44
+ quiz: 3,
45
+ read: 2,
46
+ }
47
+
48
+ function estimateActivities(estimatedDuration: number) {
49
+ const result: Record<string, number> = {}
50
+
51
+ for (const kind of Object.keys(durationByKind)) {
52
+ result[kind] = Math.floor(estimatedDuration / durationByKind[kind])
53
+ }
54
+
55
+ return result
56
+ }
57
+
58
+ const PARAMS = {
59
+ expected_grade_level: "6",
60
+ max_fkgl: 8,
61
+ max_words: 200,
62
+ max_rewrite_attempts: 3,
63
+ max_title_length: 50,
64
+ }
65
+
66
+ const whichTargetAudienceAndEstimatedDuration = async () => {
67
+ const res = await prompts([
68
+ {
69
+ type: "text",
70
+ name: "targetAudience",
71
+ message: "What is the target audience for this tutorial?",
72
+ },
73
+ {
74
+ type: "number",
75
+ name: "estimatedDuration",
76
+ message: "What is the estimated duration for this tutorial in minutes?",
77
+ },
78
+ ])
79
+ return {
80
+ targetAudience: res.targetAudience,
81
+ estimatedDuration: res.estimatedDuration,
82
+ }
83
+ }
84
+
85
+ async function processExercise(
86
+ rigoToken: string,
87
+ steps: string[],
88
+ packageContext: string,
89
+ exercise: any,
90
+ exercisesDir: string
91
+ ): Promise<string> {
92
+ const { exNumber, exTitle, kind, description } = getExInfo(exercise)
93
+ const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
94
+
95
+ const readme = await readmeCreator(
96
+ rigoToken,
97
+ {
98
+ title: `${exNumber} - ${exTitle}`,
99
+ output_lang: "en",
100
+ list_of_exercises: steps.join(","),
101
+ tutorial_description: packageContext,
102
+ lesson_description: description,
103
+ kind: kind.toLowerCase(),
104
+ last_lesson: "",
105
+ },
106
+ "learnpack-lesson-writer"
107
+ )
108
+
109
+ const duration = durationByKind[kind.toLowerCase()]
110
+ let attempts = 0
111
+ let readability = checkReadability(readme.parsed.content, 200, duration || 1)
112
+
113
+ while (
114
+ readability.fkglResult.fkgl > PARAMS.max_fkgl &&
115
+ attempts < PARAMS.max_rewrite_attempts
116
+ ) {
117
+ Console.warning(
118
+ `The lesson ${exTitle} has as readability score of ${
119
+ readability.fkglResult.fkgl
120
+ } . It exceeds the maximum of words per minute. Rewriting it... (Attempt ${
121
+ attempts + 1
122
+ })`
123
+ )
124
+
125
+ // eslint-disable-next-line
126
+ const reducedReadme = await makeReadmeReadable(
127
+ rigoToken,
128
+ {
129
+ lesson: readability.body,
130
+ number_of_words: readability.minutes.toString(),
131
+ expected_number_words: PARAMS.max_words.toString(),
132
+ fkgl_results: JSON.stringify(readability.fkglResult),
133
+ expected_grade_level: PARAMS.expected_grade_level,
134
+ },
135
+ "learnpack-lesson-writer"
136
+ )
137
+
138
+ // console.log("REDUCED README START", reducedReadme, "REDUCED README END")
139
+
140
+ if (!reducedReadme) break
141
+
142
+ readability = checkReadability(
143
+ reducedReadme.parsed.content,
144
+ PARAMS.max_words,
145
+ duration || 1
146
+ )
147
+
148
+ attempts++
149
+ }
150
+
151
+ Console.success(
152
+ `After ${attempts} attempts, the lesson ${exTitle} has a readability score of ${
153
+ readability.fkglResult.fkgl
154
+ } using FKGL. And it has ${readability.minutes.toFixed(
155
+ 2
156
+ )} minutes of reading time.`
157
+ )
158
+
159
+ const readmeFilename = "README.md"
160
+ fs.writeFileSync(
161
+ path.join(exerciseDir, readmeFilename),
162
+ readability.newMarkdown
163
+ )
164
+
165
+ if (kind.toLowerCase() === "code") {
166
+ const codeFile = await createCodeFile(rigoToken, {
167
+ readme: readability.newMarkdown,
168
+ tutorial_info: packageContext,
169
+ })
170
+
171
+ fs.writeFileSync(
172
+ path.join(
173
+ exerciseDir,
174
+ `app.${codeFile.parsed.extension.replace(".", "")}`
175
+ ),
176
+ codeFile.parsed.content
177
+ )
178
+ }
179
+
180
+ return readability.newMarkdown
181
+ }
182
+
183
+ const initializeInteractiveCreation = async (
184
+ rigoToken: string,
185
+ courseInfo: string
186
+ ): Promise<{
187
+ steps: string[]
188
+ title: string
189
+ description: string
190
+ interactions: string
191
+ difficulty: string
192
+ duration: number
193
+ }> => {
194
+ let prevInteractions = ""
195
+ let isReady = false
196
+ let currentSteps = []
197
+ let currentTitle = ""
198
+ let currentDescription = ""
199
+ let currentDifficulty = ""
200
+
201
+ while (!isReady) {
202
+ const spinner = ora("Thinking...").start()
203
+ let wholeInfo = courseInfo
204
+ wholeInfo += `
205
+ Current title: ${currentTitle}
206
+ Current description: ${currentDescription}
207
+ `
208
+ // eslint-disable-next-line
209
+ const res = await interactiveCreation(rigoToken, {
210
+ courseInfo: wholeInfo,
211
+ prevInteractions: prevInteractions,
212
+ })
213
+ spinner.succeed("Done!")
214
+ currentSteps = res.parsed.listOfSteps
215
+ isReady = res.parsed.ready
216
+ if (res.parsed.title && currentTitle !== res.parsed.title) {
217
+ currentTitle = res.parsed.title
218
+ }
219
+
220
+ if (
221
+ res.parsed.description &&
222
+ currentDescription !== res.parsed.description
223
+ ) {
224
+ currentDescription = res.parsed.description
225
+ }
226
+
227
+ if (res.parsed.difficulty && currentDifficulty !== res.parsed.difficulty) {
228
+ currentDifficulty = res.parsed.difficulty
229
+ }
230
+
231
+ if (!isReady) {
232
+ console.log(currentSteps)
233
+ Console.info(`AI: ${res.parsed.aiMessage}`)
234
+ prevInteractions += `\nAI: ${res.parsed.aiMessage}`
235
+ // eslint-disable-next-line
236
+ const userMessage = await prompts([
237
+ {
238
+ type: "text",
239
+ name: "userMessage",
240
+ message: "Your message: ",
241
+ },
242
+ ])
243
+ prevInteractions += `\nUser: ${userMessage.userMessage}`
244
+ }
245
+ }
246
+
247
+ const duration = estimateDuration(currentSteps)
248
+ return {
249
+ steps: currentSteps,
250
+ title: currentTitle,
251
+ description: currentDescription,
252
+ interactions: prevInteractions,
253
+ difficulty: currentDifficulty,
254
+ duration,
255
+ }
256
+ }
257
+
258
+ process.emitWarning = (warning: string) => {
259
+ Console.debug("A Warning was emitted by Node.js: ", warning)
260
+ }
261
+
262
+ const handleAILogic = async (tutorialDir: string, packageInfo: PackageInfo) => {
263
+ fs.removeSync(path.join(tutorialDir, "exercises", "01-hello-world"))
264
+
265
+ let sessionPayload = await SessionManager.getPayload()
266
+
267
+ const sessionExists = sessionPayload && sessionPayload.rigobot
268
+
269
+ const isValidToken =
270
+ sessionExists && sessionPayload.rigobot.key ?
271
+ await isValidRigoToken(sessionPayload.rigobot.key) :
272
+ false
273
+
274
+ const isValidBreathecodeToken =
275
+ sessionExists && sessionPayload.token ?
276
+ await api.validateToken(sessionPayload.token) :
277
+ false
278
+
279
+ if (!sessionExists || !isValidBreathecodeToken || !isValidToken) {
280
+ Console.info(
281
+ "Almost there! First you need to login with 4Geeks.com to use AI Generation tool for creators. You can create a new account here: https://4geeks.com/checkout?plan=4geeks-creator"
282
+ )
283
+ try {
284
+ sessionPayload = await SessionManager.login()
285
+ } catch (error) {
286
+ Console.error("Error trying to authenticate")
287
+ Console.error((error as TypeError).message || (error as string))
288
+ }
289
+ }
290
+
291
+ const rigoToken = sessionPayload.rigobot.key
292
+
293
+ const consumable = await getConsumable(sessionPayload.token, "ai-generation")
294
+
295
+ if (consumable.count === 0) {
296
+ Console.error(
297
+ "It seems you cannot generate tutorials with AI. Make sure you creator subscription is up to date here: https://4geeks.com/profile/subscriptions. If you believe there is an issue you can always contact support@4geeks.com"
298
+ )
299
+ process.exit(1)
300
+ }
301
+
302
+ // const isCreator = await hasCreatorPermission(rigoToken)
303
+ // if (!isCreator) {
304
+ // Console.error(
305
+ // "👀 Oops! You need to be a creator to use our specialized AI. Please contact support"
306
+ // )
307
+ // process.exit(1)
308
+ // }
309
+
310
+ Console.success("🎉 Let's begin this learning journey!")
311
+
312
+ const { targetAudience, estimatedDuration } =
313
+ await whichTargetAudienceAndEstimatedDuration()
314
+ const contentIndex = await appendContentIndex()
315
+ const airules = await appendAIRules()
316
+
317
+ if (airules) {
318
+ const rulesDir = path.join(tutorialDir, ".learn", "rules")
319
+
320
+ // Crear el directorio si no existe
321
+ fs.mkdirSync(rulesDir, { recursive: true })
322
+
323
+ fs.writeFileSync(path.join(rulesDir, "airules.txt"), airules)
324
+ }
325
+
326
+ let packageContext = `
327
+ \n
328
+ Title: "${packageInfo.title.us}"
329
+ Description: "${packageInfo.description.us}"
330
+ Target Audience: "${targetAudience}"
331
+ Estimated Duration: "${estimatedDuration} minutes"
332
+
333
+ ${
334
+ contentIndex ?
335
+ `Content Index submitted by the user, use this to guide your creation. Keep in mind that your tutorial should contain these topics:
336
+ ---
337
+ ${contentIndex}
338
+ ---
339
+ ` :
340
+ ""
341
+ }
342
+
343
+ This is the duration for each type of step, use it to estimate the number of steps to create:
344
+ ${Object.entries(durationByKind)
345
+ .map(([key, value]) => `${key}: ${value} minutes`)
346
+ .join("\n")}
347
+
348
+
349
+ Within the estimated duration, is possible to have the following activities:
350
+ Format=
351
+ Activity: Maximum number of steps for duration
352
+
353
+ Estimated activities:
354
+ ${JSON.stringify(estimateActivities(estimatedDuration))}
355
+
356
+ You should create a tutorial that is engaging and fun to follow.
357
+
358
+
359
+ ${
360
+ airules ?
361
+ `
362
+ This is a list of rules you need to follow when creating the tutorial:
363
+ ${airules}
364
+ ` :
365
+ ""
366
+ }
367
+ `
368
+
369
+ const { steps, title, description, duration, difficulty } =
370
+ await initializeInteractiveCreation(rigoToken, packageContext)
371
+ packageInfo.title.us = title
372
+ packageInfo.description.us = description
373
+ packageInfo.duration = duration
374
+ packageInfo.difficulty = difficulty
375
+
376
+ packageContext = `
377
+ Title: "${title}"
378
+ Description: "${description}"
379
+ Target Audience: "${targetAudience}"
380
+ List of exercises: ${steps.join(", ")}
381
+
382
+ AI Rules: ${airules}
383
+ `
384
+ const exercisesDir = path.join(tutorialDir, "exercises")
385
+ fs.ensureDirSync(exercisesDir)
386
+
387
+ Console.info("Creating lessons...")
388
+ for (const [index, exercise] of steps.entries()) {
389
+ const { exNumber, exTitle } = getExInfo(exercise)
390
+ const exerciseDir = path.join(exercisesDir, `${exNumber}-${exTitle}`)
391
+ fs.ensureDirSync(exerciseDir)
392
+ }
393
+
394
+ const exercisePromises = steps.map(exercise =>
395
+ processExercise(rigoToken, steps, packageContext, exercise, exercisesDir)
396
+ )
397
+
398
+ const readmeContents = await Promise.all(exercisePromises)
399
+ Console.success("Lessons created! 🎉")
400
+
401
+ Console.info("Generating images for the lessons...")
402
+ let imagesArray: any[] = []
403
+
404
+ for (const content of readmeContents) {
405
+ imagesArray = [...imagesArray, ...extractImagesFromMarkdown(content)]
406
+ }
407
+
408
+ const imagePromises = imagesArray.map(async (image: any) => {
409
+ try {
410
+ const filename = getFilenameFromUrl(image.url)
411
+ const webhookUrl = `${process.env.HOST}/webhooks/${slugify(
412
+ packageInfo.title.us
413
+ )}/images/${filename}`
414
+
415
+ const imagePath = path.join(tutorialDir, ".learn", "assets", filename)
416
+
417
+ const res = await generateImage(rigoToken, {
418
+ prompt: image.alt,
419
+ callbackUrl: webhookUrl,
420
+ })
421
+ await downloadImage(res.image_url, imagePath)
422
+ return true
423
+ } catch {
424
+ Console.error(`Error downloading image ${image.url}`)
425
+
426
+ return false
427
+ }
428
+ })
429
+ await Promise.all(imagePromises)
430
+ Console.info(
431
+ "Images generated successfully! 🎉 Your tutorial will be ready soon!"
432
+ )
433
+
434
+ Console.info("Creating preview readme...")
435
+
436
+ await createPreviewReadme(tutorialDir, packageInfo, rigoToken, readmeContents)
437
+ const imagePath = path.join(tutorialDir, "preview.png")
438
+
439
+ const res = await generateImage(rigoToken, {
440
+ prompt:
441
+ "Generate a preview image for the tutorial. This is all the tutorial information: " +
442
+ packageContext +
443
+ "\n Generate only a basic preview image, add the tutorial Title as a text add the top middle, avoid adding any other text elements. Try to generate an image that related with the tutorial content.",
444
+ callbackUrl: `${process.env.HOST}/webhooks/${slugify(
445
+ packageInfo.title.us
446
+ )}/images/preview.png`,
447
+ })
448
+
449
+ // await downloadImage(res.image_url, imagePath)
450
+
451
+ return true
452
+ }
453
+
454
+ const getChoices = async (empty: boolean) => {
455
+ const defaultChoices = {
456
+ title: "My Interactive Tutorial",
457
+ description: "",
458
+ difficulty: "beginner",
459
+ duration: 5,
460
+ useAI: "no",
461
+ grading: "isolated",
462
+ }
463
+
464
+ if (empty) {
465
+ return defaultChoices
466
+ }
467
+
468
+ const choices = await prompts([
469
+ {
470
+ type: "select",
471
+ name: "grading",
472
+ message: "How are you going to grade students or yourself?",
473
+ choices: [
474
+ {
475
+ title:
476
+ "No grading: No feedback or testing whatsoever, similar to a an interactive book.",
477
+ value: null,
478
+ },
479
+ {
480
+ title: "Isolated: Each step is a new separate exercise",
481
+ value: "isolated",
482
+ },
483
+ {
484
+ title:
485
+ "Step by step: Each step builds on top of each other like an incremental tutorial",
486
+ value: "incremental",
487
+ },
488
+ ],
489
+ },
490
+ {
491
+ type: "text",
492
+
493
+ name: "title",
494
+ initial: "My Interactive Tutorial",
495
+ message: "Title for your tutorial? Press enter to leave as it is",
496
+ validate: (value: string) => {
497
+ if (value.length > PARAMS.max_title_length) {
498
+ return `Title must be less than ${PARAMS.max_title_length} characters`
499
+ }
500
+
501
+ return true
502
+ },
503
+ },
504
+ {
505
+ type: "text",
506
+ name: "description",
507
+ initial: "",
508
+ message: "Description for your tutorial? Press enter to leave blank",
509
+ },
510
+
511
+ {
512
+ type: "select",
513
+ name: "useAI",
514
+ message:
515
+ "Want a little bit of AI magic to help you? Our AI can craft the tutorial for you",
516
+ choices: [
517
+ {
518
+ title: "Yes, please help me",
519
+ value: "yes",
520
+ },
521
+ { title: "No, thanks, I prefer to do it manually", value: "no" },
522
+ ],
523
+ },
524
+ ])
525
+
526
+ const completeChoices = {
527
+ ...choices,
528
+ difficulty: "beginner",
529
+ duration: 30,
530
+ }
531
+ return completeChoices
532
+ }
533
+
534
+ class InitComand extends BaseCommand {
535
+ static description =
536
+ "Create a new learning package: Book, Tutorial or Exercise"
537
+
538
+ static flags = {
539
+ ...BaseCommand.flags,
540
+ grading: flags.help({ char: "h" }),
541
+ }
542
+
543
+ async run() {
544
+ const { flags } = this.parse(InitComand)
545
+
546
+ await alreadyInitialized()
547
+
548
+ const choices = await getChoices(flags.yes)
549
+
550
+ const packageInfo = makePackageInfo(choices)
551
+
552
+ const tutorialDir = `./${packageInfo.slug}`
553
+ fs.ensureDirSync(tutorialDir)
554
+
555
+ const templatesDir = path.resolve(
556
+ __dirname,
557
+ "../../src/utils/templates/" + (choices.grading || "no-grading")
558
+ )
559
+ if (!fs.existsSync(templatesDir))
560
+ throw ValidationError(`Template ${templatesDir} does not exists`)
561
+ await fs.copySync(templatesDir, tutorialDir)
562
+
563
+ if (choices.useAI === "yes") {
564
+ await handleAILogic(tutorialDir, packageInfo)
565
+ }
566
+
567
+ const languages = ["en", "es"]
568
+
569
+ // Creating README files
570
+ for (const language of languages) {
571
+ const readmeFilename = `README${language !== "en" ? `.${language}` : ""}`
572
+ const readmeTemplatePath = path.resolve(
573
+ templatesDir,
574
+ `${readmeFilename}.ejs`
575
+ )
576
+
577
+ if (choices.useAI !== "yes") {
578
+ const readmeObject = {
579
+ title: packageInfo.title.us,
580
+ description: packageInfo.description.us,
581
+ grading: packageInfo.grading,
582
+ difficulty: packageInfo.difficulty,
583
+ duration: packageInfo.duration,
584
+ }
585
+ const readmeContent = eta.render(
586
+ fs.readFileSync(readmeTemplatePath, "utf-8"),
587
+ readmeObject
588
+ )
589
+ fs.writeFileSync(
590
+ path.join(tutorialDir, `${readmeFilename}.md`),
591
+ readmeContent
592
+ )
593
+ }
594
+
595
+ if (fs.existsSync(path.join(tutorialDir, `${readmeFilename}.ejs`)))
596
+ fs.removeSync(path.join(tutorialDir, `${readmeFilename}.ejs`))
597
+ }
598
+
599
+ cli.action.start("Initializing package")
600
+
601
+ if (!fs.existsSync(path.join(tutorialDir, ".gitignore")))
602
+ fs.copyFile(
603
+ path.resolve(__dirname, "../../src/utils/templates/gitignore.txt"),
604
+ path.join(tutorialDir, ".gitignore")
605
+ )
606
+
607
+ fs.writeFileSync(
608
+ path.join(tutorialDir, "learn.json"),
609
+ JSON.stringify(packageInfo, null, 2)
610
+ )
611
+
612
+ cli.action.stop()
613
+ Console.success(`😋 Package initialized successfully in ${tutorialDir}`)
614
+ Console.help(
615
+ `Get inside the tutorial with the command: $ cd ${tutorialDir}`
616
+ )
617
+ Console.help(
618
+ `Start the exercises by running the following command on your terminal: $ learnpack start`
619
+ )
620
+ process.exit(0)
621
+ }
622
+ }
623
+
624
+ const alreadyInitialized = () =>
625
+ new Promise((resolve, reject) => {
626
+ fs.readdir("./", function (err: any, files: any) {
627
+ files = files.filter((f: any) =>
628
+ [".learn", "learn.json", "bc.json", ".breathecode"].includes(f)
629
+ )
630
+ if (err) {
631
+ reject(ValidationError(err.message))
632
+ throw ValidationError(err.message)
633
+ } else if (files.length > 0) {
634
+ reject(
635
+ ValidationError(
636
+ "It seems the package is already initialized because we've found the following files: " +
637
+ files.join(",")
638
+ )
639
+ )
640
+ throw ValidationError(
641
+ "It seems the package is already initialized because we've found the following files: " +
642
+ files.join(",")
643
+ )
644
+ }
645
+
646
+ resolve(false)
647
+ })
648
+ })
649
+
650
+ export default InitComand