@learnpack/learnpack 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- package/README.md +695 -0
- package/bin/run +5 -0
- package/bin/run.cmd +3 -0
- package/oclif.manifest.json +1 -0
- package/package.json +111 -0
- package/plugin/command/compile.js +17 -0
- package/plugin/command/test.js +29 -0
- package/plugin/index.js +6 -0
- package/plugin/plugin.js +71 -0
- package/plugin/utils.js +78 -0
- package/src/commands/audit.js +243 -0
- package/src/commands/clean.js +27 -0
- package/src/commands/download.js +52 -0
- package/src/commands/hello.js +20 -0
- package/src/commands/init.js +133 -0
- package/src/commands/login.js +45 -0
- package/src/commands/logout.js +39 -0
- package/src/commands/publish.js +78 -0
- package/src/commands/start.js +169 -0
- package/src/commands/test.js +85 -0
- package/src/index.js +1 -0
- package/src/managers/config/allowed_files.js +12 -0
- package/src/managers/config/defaults.js +32 -0
- package/src/managers/config/exercise.js +212 -0
- package/src/managers/config/index.js +342 -0
- package/src/managers/file.js +137 -0
- package/src/managers/server/index.js +62 -0
- package/src/managers/server/routes.js +151 -0
- package/src/managers/session.js +83 -0
- package/src/managers/socket.js +185 -0
- package/src/managers/test.js +77 -0
- package/src/ui/download.js +48 -0
- package/src/utils/BaseCommand.js +34 -0
- package/src/utils/SessionCommand.js +46 -0
- package/src/utils/api.js +164 -0
- package/src/utils/audit.js +114 -0
- package/src/utils/console.js +16 -0
- package/src/utils/errors.js +90 -0
- package/src/utils/exercisesQueue.js +45 -0
- package/src/utils/fileQueue.js +194 -0
- package/src/utils/misc.js +26 -0
- package/src/utils/templates/gitignore.txt +20 -0
- package/src/utils/templates/incremental/.learn/exercises/01-hello-world/README.es.md +26 -0
- package/src/utils/templates/incremental/.learn/exercises/01-hello-world/README.md +25 -0
- package/src/utils/templates/incremental/README.ejs +5 -0
- package/src/utils/templates/incremental/README.es.ejs +5 -0
- package/src/utils/templates/isolated/01-hello-world/README.es.md +27 -0
- package/src/utils/templates/isolated/01-hello-world/README.md +27 -0
- package/src/utils/templates/isolated/README.ejs +5 -0
- package/src/utils/templates/isolated/README.es.ejs +5 -0
- package/src/utils/templates/no-grading/README.ejs +5 -0
- package/src/utils/templates/no-grading/README.es.ejs +5 -0
- package/src/utils/validators.js +15 -0
- package/src/utils/watcher.js +24 -0
@@ -0,0 +1,212 @@
|
|
1
|
+
const p = require("path")
|
2
|
+
const frontMatter = require('front-matter')
|
3
|
+
const fs = require("fs")
|
4
|
+
let Console = require('../../utils/console');
|
5
|
+
const allowed = require('./allowed_files')
|
6
|
+
const { ValidationError } = require('../../utils/errors.js')
|
7
|
+
|
8
|
+
const exercise = (path, position, configObject) => {
|
9
|
+
|
10
|
+
const { config, exercises } = configObject;
|
11
|
+
let slug = p.basename(path)
|
12
|
+
|
13
|
+
if(!validateExerciseDirectoryName(slug)){
|
14
|
+
Console.error('Exercise directory "'+slug+'" has an invalid name, it has to start with two or three digits followed by words separated by underscors or hyphen (no white spaces). e.g: 01.12-hello-world')
|
15
|
+
Console.help('Verify that the folder "'+slug+'" starts with two numbers and it does not contain white spaces or weird characters.')
|
16
|
+
throw ValidationError(`This exercise has a invalid name: ${slug}`)
|
17
|
+
}
|
18
|
+
|
19
|
+
// get all the files
|
20
|
+
const files = fs.readdirSync(path)
|
21
|
+
|
22
|
+
/**
|
23
|
+
* build the translation array like:
|
24
|
+
{
|
25
|
+
"us": "path/to/Readme.md",
|
26
|
+
"es": "path/to/Readme.es.md"
|
27
|
+
}
|
28
|
+
*/
|
29
|
+
var translations = {}
|
30
|
+
files.filter(file => file.toLowerCase().includes('readme')).forEach(file => {
|
31
|
+
const parts = file.split('.')
|
32
|
+
if(parts.length === 3) translations[parts[1]] = file
|
33
|
+
else translations["us"] = file
|
34
|
+
})
|
35
|
+
|
36
|
+
// if the slug is a dot, it means there is not "exercises" folder, and its just a single README.md
|
37
|
+
if(slug == ".") slug = "default-index";
|
38
|
+
|
39
|
+
const detected = detect(configObject, files);
|
40
|
+
return {
|
41
|
+
position, path, slug, translations,
|
42
|
+
language: detected.language,
|
43
|
+
entry: detected.entry ? path + "/" + detected.entry : null, //full path to the exercise entry
|
44
|
+
title: slug || "Exercise",
|
45
|
+
graded: files.filter(file => file.toLowerCase().startsWith('test.') || file.toLowerCase().startsWith('tests.')).length > 0,
|
46
|
+
files: filterFiles(files, path),
|
47
|
+
//if the exercises was on the config before I may keep the status done
|
48
|
+
done: (Array.isArray(exercises) && typeof exercises[position] !== 'undefined' && path.substring(path.indexOf('exercises/')+10) == exercises[position].slug) ? exercises[position].done : false,
|
49
|
+
getReadme: function(lang=null){
|
50
|
+
if(lang == 'us') lang = null // <-- english is default, no need to append it to the file name
|
51
|
+
if (!fs.existsSync(`${this.path}/README${lang ? "."+lang : ''}.md`)){
|
52
|
+
Console.error(`Language ${lang} not found for exercise ${slug}, switching to default language`)
|
53
|
+
if(lang) lang = null
|
54
|
+
if (!fs.existsSync(`${this.path}/README${lang ? "."+lang : ''}.md`)) throw Error('Readme file not found for exercise: '+this.path+'/README.md')
|
55
|
+
}
|
56
|
+
let content = fs.readFileSync(`${this.path}/README${lang ? "."+lang : ''}.md`,"utf8")
|
57
|
+
// content = content.replace(/!\[.*\](../../assets/script-test.gif)/, "<div>$1</div>")
|
58
|
+
const attr = frontMatter(content)
|
59
|
+
return attr
|
60
|
+
},
|
61
|
+
getFile: function(name){
|
62
|
+
const file = this.files.find(f => f.name === name);
|
63
|
+
if (!fs.existsSync(file.path)) throw Error('File not found: '+file.path)
|
64
|
+
else if(fs.lstatSync(file.path).isDirectory()) return 'Error: This is not a file to be read, but a directory: '+file.path
|
65
|
+
|
66
|
+
// get file content
|
67
|
+
const content = fs.readFileSync(file.path)
|
68
|
+
|
69
|
+
//create reset folder
|
70
|
+
if (!fs.existsSync(`${config.dirPath}/resets`)) fs.mkdirSync(`${config.dirPath}/resets`)
|
71
|
+
if (!fs.existsSync(`${config.dirPath}/resets/`+this.slug)){
|
72
|
+
fs.mkdirSync(`${config.dirPath}/resets/`+this.slug)
|
73
|
+
if (!fs.existsSync(`${config.dirPath}/resets/${this.slug}/${name}`)){
|
74
|
+
fs.writeFileSync(`${config.dirPath}/resets/${this.slug}/${name}`, content)
|
75
|
+
}
|
76
|
+
}
|
77
|
+
|
78
|
+
return content
|
79
|
+
},
|
80
|
+
saveFile: function(name, content){
|
81
|
+
const file = this.files.find(f => f.name === name);
|
82
|
+
if (!fs.existsSync(file.path)) throw Error('File not found: '+file.path)
|
83
|
+
return fs.writeFileSync(file.path, content, 'utf8')
|
84
|
+
},
|
85
|
+
getTestReport: function(){
|
86
|
+
const _path = `${config.confPath.base}/reports/${this.slug}.json`
|
87
|
+
if (!fs.existsSync(_path)) return {}
|
88
|
+
|
89
|
+
const content = fs.readFileSync(_path)
|
90
|
+
const data = JSON.parse(content)
|
91
|
+
return data
|
92
|
+
},
|
93
|
+
}
|
94
|
+
}
|
95
|
+
|
96
|
+
const validateExerciseDirectoryName = (str) => {
|
97
|
+
if(str == "./") return true;
|
98
|
+
const regex = /^(\d{2,3}(\.\d{1,2})?-([A-Za-z0-9]+(-|_)?)+)$/
|
99
|
+
return regex.test(str)
|
100
|
+
}
|
101
|
+
|
102
|
+
const isCodable = (str) => {
|
103
|
+
const extension = p.extname(str);
|
104
|
+
return allowed.extensions.includes(extension.substring(1).toLowerCase());
|
105
|
+
}
|
106
|
+
|
107
|
+
const isNotConfiguration = (str) => {
|
108
|
+
return !allowed.names.includes(str);
|
109
|
+
}
|
110
|
+
|
111
|
+
const shouldBeVisible = function(file){
|
112
|
+
return (
|
113
|
+
// doest not have "test." on their name
|
114
|
+
(file.name.toLocaleLowerCase().indexOf('test.') == -1 && file.name.toLocaleLowerCase().indexOf('tests.') == -1 && file.name.toLocaleLowerCase().indexOf('.hide.') == -1 &&
|
115
|
+
// ignore hidden files
|
116
|
+
(file.name.charAt(0) != '.') &&
|
117
|
+
// ignore learn.json and bc.json
|
118
|
+
(file.name.toLocaleLowerCase().indexOf('learn.json') == -1) && (file.name.toLocaleLowerCase().indexOf('bc.json') == -1) &&
|
119
|
+
// ignore images, videos, vectors, etc.
|
120
|
+
isCodable(file.name) && isNotConfiguration(file.name) &&
|
121
|
+
// readme's and directories
|
122
|
+
!file.name.toLowerCase().includes("readme.") && !isDirectory(file.path) && file.name.charAt(0) != '_')
|
123
|
+
);
|
124
|
+
}
|
125
|
+
|
126
|
+
const isDirectory = source => {
|
127
|
+
//if(path.basename(source) === path.basename(config.dirPath)) return false
|
128
|
+
return fs.lstatSync(source).isDirectory()
|
129
|
+
}
|
130
|
+
|
131
|
+
/**
|
132
|
+
* Learnpack must be able to AUTOMATICALLY detect language.
|
133
|
+
* Because learnpack can work with multilang exercises.
|
134
|
+
*/
|
135
|
+
const detect = (configObject, files) => {
|
136
|
+
|
137
|
+
const { config } = configObject;
|
138
|
+
|
139
|
+
if(!config) throw Error(`No configuration found during the engine detection`)
|
140
|
+
|
141
|
+
if(!config.entries) throw Error("No configuration found for entries, please add a 'entries' object with the default file name for your exercise entry file that is going to be used while compiling, for example: index.html for html, app.py for python3, etc.")
|
142
|
+
//A language was found on the config object, but this language will only be used as last resort, learnpack will try to guess each exercise language independently based on file extension (js, jsx, html, etc.)
|
143
|
+
|
144
|
+
let hasFiles = files.filter(f => f.includes('.py'))
|
145
|
+
if(hasFiles.length > 0) return {
|
146
|
+
language: "python3",
|
147
|
+
entry: hasFiles.find(f => config.entries["python3"] === f)
|
148
|
+
}
|
149
|
+
|
150
|
+
hasFiles = files.filter(f => f.includes('.java'))
|
151
|
+
if(hasFiles.length > 0) return {
|
152
|
+
language: "java",
|
153
|
+
entry: hasFiles.find(f => config.entries["java"] === f)
|
154
|
+
}
|
155
|
+
|
156
|
+
hasFiles = files.filter(f => f.includes('.jsx'))
|
157
|
+
if(hasFiles.length > 0) return {
|
158
|
+
language: "react",
|
159
|
+
entry: hasFiles.find(f => config.entries["react"] === f)
|
160
|
+
}
|
161
|
+
const hasHTML = files.filter(f => f.includes('index.html'))
|
162
|
+
const hasIndexJS = files.find(f => f.includes('index.js'))
|
163
|
+
const hasJS = files.filter(f => f.includes('.js'))
|
164
|
+
// angular, vue, vanillajs needs to have at least 2 files (html,css,js),
|
165
|
+
// the test.js and the entry file in js
|
166
|
+
// if not its just another HTML
|
167
|
+
|
168
|
+
if(hasIndexJS && hasHTML.length > 0) return {
|
169
|
+
language: "vanillajs",
|
170
|
+
entry: hasIndexJS
|
171
|
+
}
|
172
|
+
else if(hasHTML.length > 0) return {
|
173
|
+
language: "html",
|
174
|
+
entry: hasHTML.find(f => config.entries["html"] === f)
|
175
|
+
}
|
176
|
+
else if(hasJS.length > 0) return {
|
177
|
+
language: "node",
|
178
|
+
entry: hasJS.find(f => config.entries["node"] === f)
|
179
|
+
}
|
180
|
+
|
181
|
+
return {
|
182
|
+
language: null,
|
183
|
+
entry: null
|
184
|
+
};
|
185
|
+
}
|
186
|
+
|
187
|
+
const filterFiles = (files, basePath=".") => files.map(ex => ({
|
188
|
+
path: basePath+'/'+ex,
|
189
|
+
name: ex,
|
190
|
+
hidden: !shouldBeVisible({ name: ex, path: basePath+'/'+ex })
|
191
|
+
}))
|
192
|
+
.sort((f1, f2) => {
|
193
|
+
const score = { // sorting priority
|
194
|
+
"index.html": 1,
|
195
|
+
"styles.css": 2,
|
196
|
+
"styles.scss": 2,
|
197
|
+
"style.css": 2,
|
198
|
+
"style.scss": 2,
|
199
|
+
"index.css": 2,
|
200
|
+
"index.scss": 2,
|
201
|
+
"index.js": 3,
|
202
|
+
"index.jsx": 3,
|
203
|
+
}
|
204
|
+
return score[f1.name] < score[f2.name] ? -1 : 1
|
205
|
+
});
|
206
|
+
|
207
|
+
module.exports = {
|
208
|
+
exercise,
|
209
|
+
detect,
|
210
|
+
filterFiles,
|
211
|
+
validateExerciseDirectoryName
|
212
|
+
}
|
@@ -0,0 +1,342 @@
|
|
1
|
+
const path = require("path");
|
2
|
+
const fs = require("fs");
|
3
|
+
const shell = require("shelljs");
|
4
|
+
const Console = require("../../utils/console");
|
5
|
+
const watch = require("../../utils/watcher");
|
6
|
+
const chalk = require("chalk");
|
7
|
+
const fetch = require("node-fetch");
|
8
|
+
const {
|
9
|
+
ValidationError,
|
10
|
+
NotFoundError,
|
11
|
+
InternalError,
|
12
|
+
} = require("../../utils/errors.js");
|
13
|
+
|
14
|
+
let defaults = require("./defaults.js");
|
15
|
+
let { exercise } = require("./exercise.js");
|
16
|
+
|
17
|
+
const { rmSync } = require("../file.js");
|
18
|
+
/* exercise folder name standard */
|
19
|
+
|
20
|
+
const getConfigPath = () => {
|
21
|
+
const possibleFileNames = [
|
22
|
+
"learn.json",
|
23
|
+
".learn/learn.json",
|
24
|
+
"bc.json",
|
25
|
+
".breathecode/bc.json",
|
26
|
+
];
|
27
|
+
let config = possibleFileNames.find((file) => fs.existsSync(file)) || null;
|
28
|
+
if (config && fs.existsSync(".breathecode"))
|
29
|
+
return { config, base: ".breathecode" };
|
30
|
+
else if (config === null)
|
31
|
+
throw NotFoundError(
|
32
|
+
"learn.json file not found on current folder, is this a learnpack package?"
|
33
|
+
);
|
34
|
+
return { config, base: ".learn" };
|
35
|
+
};
|
36
|
+
|
37
|
+
const getExercisesPath = (base) => {
|
38
|
+
const possibleFileNames = ["./exercises", base + "/exercises", "./"];
|
39
|
+
return possibleFileNames.find((file) => fs.existsSync(file)) || null;
|
40
|
+
};
|
41
|
+
|
42
|
+
const getGitpodAddress = () => {
|
43
|
+
if (shell.exec(`gp -h`, { silent: true }).code == 0) {
|
44
|
+
return shell
|
45
|
+
.exec(`gp url`, { silent: true })
|
46
|
+
.stdout.replace(/(\r\n|\n|\r)/gm, "");
|
47
|
+
} else {
|
48
|
+
Console.debug(`Gitpod command line tool not found`);
|
49
|
+
return "http://localhost";
|
50
|
+
}
|
51
|
+
};
|
52
|
+
|
53
|
+
module.exports = async ({ grading, mode, disableGrading, version }) => {
|
54
|
+
let confPath = getConfigPath();
|
55
|
+
Console.debug("This is the config path: ", confPath);
|
56
|
+
|
57
|
+
let configObj = {};
|
58
|
+
if (confPath) {
|
59
|
+
const bcContent = fs.readFileSync(confPath.config);
|
60
|
+
|
61
|
+
let hiddenBcContent = {};
|
62
|
+
if (fs.existsSync(confPath.base + "/config.json")) {
|
63
|
+
hiddenBcContent = fs.readFileSync(confPath.base + "/config.json");
|
64
|
+
hiddenBcContent = JSON.parse(hiddenBcContent);
|
65
|
+
if (!hiddenBcContent)
|
66
|
+
throw Error(
|
67
|
+
`Invalid ${confPath.base}/config.json syntax: Unable to parse.`
|
68
|
+
);
|
69
|
+
}
|
70
|
+
|
71
|
+
const jsonConfig = JSON.parse(bcContent);
|
72
|
+
if (!jsonConfig)
|
73
|
+
throw Error(`Invalid ${confPath.config} syntax: Unable to parse.`);
|
74
|
+
|
75
|
+
//add using id to the installation
|
76
|
+
if (!jsonConfig.session)
|
77
|
+
jsonConfig.session = Math.floor(Math.random() * 10000000000000000000);
|
78
|
+
|
79
|
+
configObj = deepMerge(hiddenBcContent, { config: jsonConfig }, {
|
80
|
+
config: { disableGrading },
|
81
|
+
});
|
82
|
+
Console.debug("Content form the configuration .json ", configObj);
|
83
|
+
} else {
|
84
|
+
throw ValidationError(
|
85
|
+
"No learn.json file has been found, make sure you are in the folder"
|
86
|
+
);
|
87
|
+
}
|
88
|
+
|
89
|
+
configObj = deepMerge(defaults || {}, configObj, {
|
90
|
+
config: {
|
91
|
+
grading: grading || configObj.grading,
|
92
|
+
configPath: confPath.config,
|
93
|
+
},
|
94
|
+
});
|
95
|
+
configObj.config.outputPath = confPath.base + "/dist";
|
96
|
+
|
97
|
+
Console.debug("This is your configuration object: ", {
|
98
|
+
...configObj,
|
99
|
+
exercises: configObj.exercises
|
100
|
+
? configObj.exercises.map((e) => e.slug)
|
101
|
+
: [],
|
102
|
+
});
|
103
|
+
|
104
|
+
// auto detect agent (if possible)
|
105
|
+
if (shell.which("gp")) {
|
106
|
+
configObj.config.editor.agent = "gitpod";
|
107
|
+
configObj.config.address = getGitpodAddress();
|
108
|
+
configObj.config.publicUrl = `https://${
|
109
|
+
configObj.config.port
|
110
|
+
}-${configObj.config.address.substring(8)}`;
|
111
|
+
} else if (!configObj.config.editor.agent) {
|
112
|
+
configObj.config.editor.agent = "localhost";
|
113
|
+
}
|
114
|
+
|
115
|
+
if (!configObj.config.publicUrl)
|
116
|
+
configObj.config.publicUrl = `${configObj.config.address}:${configObj.config.port}`;
|
117
|
+
|
118
|
+
// Assign default editor mode if not set already
|
119
|
+
if (mode != null) {
|
120
|
+
configObj.config.editor.mode = mode;
|
121
|
+
}
|
122
|
+
|
123
|
+
if (!configObj.config.mode)
|
124
|
+
configObj.config.editor.mode =
|
125
|
+
configObj.config.editor.agent === "localhost" ? "standalone" : "preview";
|
126
|
+
|
127
|
+
if (version) configObj.config.editor.version = version;
|
128
|
+
else if (configObj.config.editor.version === null) {
|
129
|
+
const resp = await fetch(
|
130
|
+
"https://raw.githubusercontent.com/learnpack/coding-ide/learnpack/package.json"
|
131
|
+
);
|
132
|
+
const packageJSON = await resp.json();
|
133
|
+
configObj.config.editor.version = packageJSON.version || "1.0.61";
|
134
|
+
}
|
135
|
+
|
136
|
+
configObj.config.dirPath = "./" + confPath.base;
|
137
|
+
configObj.config.exercisesPath = getExercisesPath(confPath.base) || "./";
|
138
|
+
|
139
|
+
return {
|
140
|
+
validLanguages: {},
|
141
|
+
get: () => configObj,
|
142
|
+
validateEngine: function (language, server, socket) {
|
143
|
+
const alias = (_l) => {
|
144
|
+
let map = {
|
145
|
+
python3: "python",
|
146
|
+
};
|
147
|
+
if (map[_l]) return map[_l];
|
148
|
+
else return _l;
|
149
|
+
};
|
150
|
+
|
151
|
+
// decode aliases
|
152
|
+
language = alias(language);
|
153
|
+
|
154
|
+
if (this.validLanguages[language]) return true;
|
155
|
+
|
156
|
+
Console.debug(`Validating engine for ${language} compilation`);
|
157
|
+
let result = shell.exec("learnpack plugins", { silent: true });
|
158
|
+
|
159
|
+
if (result.code == 0 && result.stdout.includes(`learnpack-${language}`)) {
|
160
|
+
this.validLanguages[language] = true;
|
161
|
+
return true;
|
162
|
+
}
|
163
|
+
|
164
|
+
Console.info(`Language engine for ${language} not found, installing...`);
|
165
|
+
result = shell.exec(`learnpack plugins:install learnpack-${language}`, {
|
166
|
+
silent: true,
|
167
|
+
});
|
168
|
+
if (result.code === 0) {
|
169
|
+
socket.log(
|
170
|
+
"compiling",
|
171
|
+
"Installing the python compiler, you will have to reset the exercises after installation by writing on your terminal: $ learnpack run"
|
172
|
+
);
|
173
|
+
Console.info(
|
174
|
+
`Successfully installed the ${language} exercise engine, \n please start learnpack again by running the following command: \n ${chalk.white(
|
175
|
+
`$ learnpack start`
|
176
|
+
)}\n\n `
|
177
|
+
);
|
178
|
+
server.terminate();
|
179
|
+
return false;
|
180
|
+
} else {
|
181
|
+
this.validLanguages[language] = false;
|
182
|
+
socket.error(`Error installing ${language} exercise engine`);
|
183
|
+
Console.error(`Error installing ${language} exercise engine`);
|
184
|
+
Console.log(result.stdout);
|
185
|
+
throw InternalError(`Error installing ${language} exercise engine`);
|
186
|
+
}
|
187
|
+
},
|
188
|
+
clean: () => {
|
189
|
+
rmSync(configObj.config.outputPath);
|
190
|
+
rmSync(configObj.config.dirPath + "/_app");
|
191
|
+
rmSync(configObj.config.dirPath + "/reports");
|
192
|
+
rmSync(configObj.config.dirPath + "/.session");
|
193
|
+
rmSync(configObj.config.dirPath + "/resets");
|
194
|
+
|
195
|
+
// clean tag gz
|
196
|
+
if (fs.existsSync(configObj.config.dirPath + "/app.tar.gz"))
|
197
|
+
fs.unlinkSync(configObj.config.dirPath + "/app.tar.gz");
|
198
|
+
|
199
|
+
if (fs.existsSync(configObj.config.dirPath + "/config.json"))
|
200
|
+
fs.unlinkSync(configObj.config.dirPath + "/config.json");
|
201
|
+
|
202
|
+
if (fs.existsSync(configObj.config.dirPath + "/vscode_queue.json"))
|
203
|
+
fs.unlinkSync(configObj.config.dirPath + "/vscode_queue.json");
|
204
|
+
},
|
205
|
+
getExercise: (slug) => {
|
206
|
+
const exercise = configObj.exercises.find((ex) => ex.slug == slug);
|
207
|
+
if (!exercise) throw ValidationError(`Exercise ${slug} not found`);
|
208
|
+
|
209
|
+
return exercise;
|
210
|
+
},
|
211
|
+
getAllExercises: () => {
|
212
|
+
return configObj.exercises;
|
213
|
+
},
|
214
|
+
startExercise: function (slug) {
|
215
|
+
const exercise = this.getExercise(slug);
|
216
|
+
|
217
|
+
// set config.json with current exercise
|
218
|
+
configObj.currentExercise = exercise.slug;
|
219
|
+
|
220
|
+
this.save();
|
221
|
+
|
222
|
+
exercise.files.forEach((f) => {
|
223
|
+
const _path = configObj.config.outputPath + "/" + f.name;
|
224
|
+
if (f.hidden === false && fs.existsSync(_path)) fs.unlinkSync(_path);
|
225
|
+
});
|
226
|
+
|
227
|
+
return exercise;
|
228
|
+
},
|
229
|
+
noCurrentExercise: function () {
|
230
|
+
configObj.currentExercise = null;
|
231
|
+
this.save();
|
232
|
+
},
|
233
|
+
reset: (slug) => {
|
234
|
+
if (!fs.existsSync(`${configObj.config.dirPath}/resets/` + slug))
|
235
|
+
throw ValidationError("Could not find the original files for " + slug);
|
236
|
+
|
237
|
+
const exercise = configObj.exercises.find((ex) => ex.slug == slug);
|
238
|
+
if (!exercise)
|
239
|
+
throw ValidationError(
|
240
|
+
`Exercise ${slug} not found on the configuration`
|
241
|
+
);
|
242
|
+
|
243
|
+
fs.readdirSync(`${configObj.config.dirPath}/resets/${slug}/`).forEach(
|
244
|
+
(fileName) => {
|
245
|
+
const content = fs.readFileSync(
|
246
|
+
`${configObj.config.dirPath}/resets/${slug}/${fileName}`
|
247
|
+
);
|
248
|
+
fs.writeFileSync(`${exercise.path}/${fileName}`, content);
|
249
|
+
}
|
250
|
+
);
|
251
|
+
},
|
252
|
+
buildIndex: function () {
|
253
|
+
Console.info("Building the exercise index...");
|
254
|
+
|
255
|
+
const isDirectory = (source) => {
|
256
|
+
const name = path.basename(source);
|
257
|
+
if (name === path.basename(configObj.config.dirPath)) return false;
|
258
|
+
//ignore folders that start with a dot
|
259
|
+
if (name.charAt(0) === "." || name.charAt(0) === "_") return false;
|
260
|
+
|
261
|
+
return fs.lstatSync(source).isDirectory();
|
262
|
+
};
|
263
|
+
const getDirectories = (source) =>
|
264
|
+
fs
|
265
|
+
.readdirSync(source)
|
266
|
+
.map((name) => path.join(source, name))
|
267
|
+
.filter(isDirectory);
|
268
|
+
// add the .learn folder
|
269
|
+
if (!fs.existsSync(confPath.base)) fs.mkdirSync(confPath.base);
|
270
|
+
// add the outout folder where webpack will publish the the html/css/js files
|
271
|
+
if (
|
272
|
+
configObj.config.outputPath &&
|
273
|
+
!fs.existsSync(configObj.config.outputPath)
|
274
|
+
)
|
275
|
+
fs.mkdirSync(configObj.config.outputPath);
|
276
|
+
|
277
|
+
// TODO: we could use npm library front-mater to read the title of the exercises from the README.md
|
278
|
+
const grupedByDirectory = getDirectories(configObj.config.exercisesPath);
|
279
|
+
if (grupedByDirectory.length > 0)
|
280
|
+
configObj.exercises = grupedByDirectory.map((path, position) =>
|
281
|
+
exercise(path, position, configObj)
|
282
|
+
);
|
283
|
+
// else means the exercises are not in a folder
|
284
|
+
else
|
285
|
+
configObj.exercises = [
|
286
|
+
exercise(configObj.config.exercisesPath, 0, configObj),
|
287
|
+
];
|
288
|
+
this.save();
|
289
|
+
},
|
290
|
+
watchIndex: function (onChange = null) {
|
291
|
+
if (!configObj.config.exercisesPath)
|
292
|
+
throw ValidationError(
|
293
|
+
"No exercises directory to watch: " + configObj.config.exercisesPath
|
294
|
+
);
|
295
|
+
|
296
|
+
this.buildIndex();
|
297
|
+
watch(configObj.config.exercisesPath)
|
298
|
+
.then((eventname, filename) => {
|
299
|
+
Console.debug("Changes detected on your exercises");
|
300
|
+
this.buildIndex();
|
301
|
+
if (onChange) onChange();
|
302
|
+
})
|
303
|
+
.catch((error) => {
|
304
|
+
throw error;
|
305
|
+
});
|
306
|
+
},
|
307
|
+
save: (config = null) => {
|
308
|
+
Console.debug("Saving configuration with: ", configObj);
|
309
|
+
|
310
|
+
//remove the duplicates form the actions array
|
311
|
+
// configObj.config.actions = [...new Set(configObj.config.actions)];
|
312
|
+
configObj.config.translations = [
|
313
|
+
...new Set(configObj.config.translations),
|
314
|
+
];
|
315
|
+
|
316
|
+
fs.writeFileSync(
|
317
|
+
configObj.config.dirPath + "/config.json",
|
318
|
+
JSON.stringify(configObj, null, 4)
|
319
|
+
);
|
320
|
+
},
|
321
|
+
};
|
322
|
+
};
|
323
|
+
|
324
|
+
function deepMerge(...sources) {
|
325
|
+
let acc = {};
|
326
|
+
for (const source of sources) {
|
327
|
+
if (source instanceof Array) {
|
328
|
+
if (!(acc instanceof Array)) {
|
329
|
+
acc = [];
|
330
|
+
}
|
331
|
+
acc = [...source];
|
332
|
+
} else if (source instanceof Object) {
|
333
|
+
for (let [key, value] of Object.entries(source)) {
|
334
|
+
if (value instanceof Object && key in acc) {
|
335
|
+
value = deepMerge(acc[key], value);
|
336
|
+
}
|
337
|
+
if (value !== undefined) acc = { ...acc, [key]: value };
|
338
|
+
}
|
339
|
+
}
|
340
|
+
}
|
341
|
+
return acc;
|
342
|
+
}
|
@@ -0,0 +1,137 @@
|
|
1
|
+
var fs = require('fs')
|
2
|
+
var p = require('path')
|
3
|
+
let shell = require('shelljs')
|
4
|
+
const {cli} = require('cli-ux')
|
5
|
+
var targz = require('targz')
|
6
|
+
let Console = require('../utils/console')
|
7
|
+
var https = require('https')
|
8
|
+
var fetch = require('node-fetch')
|
9
|
+
const { InternalError } = require('../utils/errors');
|
10
|
+
|
11
|
+
const decompress = (sourcePath, destinationPath) => new Promise((resolve, reject) => {
|
12
|
+
Console.debug("Decompressing "+sourcePath)
|
13
|
+
targz.decompress({
|
14
|
+
src: sourcePath,
|
15
|
+
dest: destinationPath
|
16
|
+
}, function(err){
|
17
|
+
if(err) {
|
18
|
+
Console.error("Error when trying to decompress")
|
19
|
+
reject(err)
|
20
|
+
} else {
|
21
|
+
Console.info("Decompression finished successfully")
|
22
|
+
resolve()
|
23
|
+
}
|
24
|
+
})
|
25
|
+
})
|
26
|
+
|
27
|
+
const downloadEditor = async (version, destination) => {
|
28
|
+
//https://raw.githubusercontent.com/learnpack/coding-ide/master/dist/app.tar.gz
|
29
|
+
//if(versions[version] === undefined) throw new Error(`Invalid editor version ${version}`)
|
30
|
+
const resp2 = await fetch(`https://github.com/learnpack/coding-ide/blob/${version}/dist`)
|
31
|
+
if(!resp2.ok) throw InternalError(`Coding Editor v${version} was not found on learnpack repository, check the config.editor.version property on learn.json`)
|
32
|
+
|
33
|
+
Console.info(`Downloading the LearnPack coding UI v${version}, this may take a minute...`)
|
34
|
+
return await download(`https://github.com/learnpack/coding-ide/blob/${version}/dist/app.tar.gz?raw=true`, destination)
|
35
|
+
}
|
36
|
+
|
37
|
+
const download = (url, dest) =>{
|
38
|
+
Console.debug("Downloading "+url)
|
39
|
+
return new Promise((resolve, reject) => {
|
40
|
+
const request = https.get(url, response => {
|
41
|
+
if (response.statusCode === 200) {
|
42
|
+
const file = fs.createWriteStream(dest, { flags: 'wx' })
|
43
|
+
file.on('finish', () => {
|
44
|
+
resolve(true)
|
45
|
+
})
|
46
|
+
file.on('error', err => {
|
47
|
+
file.close()
|
48
|
+
if (err.code === 'EEXIST'){
|
49
|
+
Console.debug("File already exists")
|
50
|
+
resolve("File already exists")
|
51
|
+
}
|
52
|
+
else{
|
53
|
+
Console.debug("Error ",err.message)
|
54
|
+
fs.unlink(dest, () => reject(err.message)) // Delete temp file
|
55
|
+
}
|
56
|
+
|
57
|
+
})
|
58
|
+
response.pipe(file)
|
59
|
+
} else if (response.statusCode === 302 || response.statusCode === 301) {
|
60
|
+
//Console.debug("Servers redirected to "+response.headers.location)
|
61
|
+
//Recursively follow redirects, only a 200 will resolve.
|
62
|
+
download(response.headers.location, dest)
|
63
|
+
.then(() => resolve())
|
64
|
+
.catch(error => {
|
65
|
+
Console.error(error)
|
66
|
+
reject(error)
|
67
|
+
})
|
68
|
+
} else {
|
69
|
+
Console.debug(`Server responded with ${response.statusCode}: ${response.statusMessage}`)
|
70
|
+
reject(`Server responded with ${response.statusCode}: ${response.statusMessage}`)
|
71
|
+
}
|
72
|
+
})
|
73
|
+
|
74
|
+
request.on('error', err => {
|
75
|
+
reject(err.message)
|
76
|
+
})
|
77
|
+
})
|
78
|
+
}
|
79
|
+
|
80
|
+
const clone = (repository=null, folder='./') => new Promise((resolve, reject)=>{
|
81
|
+
|
82
|
+
if(!repository){
|
83
|
+
reject("Missing repository url for this package")
|
84
|
+
return false
|
85
|
+
}
|
86
|
+
|
87
|
+
cli.action.start('Verifying GIT...')
|
88
|
+
if (!shell.which('git')) {
|
89
|
+
reject('Sorry, this script requires git')
|
90
|
+
return false
|
91
|
+
}
|
92
|
+
cli.action.stop()
|
93
|
+
|
94
|
+
let fileName = p.basename(repository)
|
95
|
+
if(!fileName){
|
96
|
+
reject('Invalid repository information on package: '+repository)
|
97
|
+
return false
|
98
|
+
}
|
99
|
+
|
100
|
+
fileName = fileName.split('.')[0];
|
101
|
+
if(fs.existsSync("./"+fileName)){
|
102
|
+
reject(`Directory ${fileName} already exists; Did you download this package already?`)
|
103
|
+
return false
|
104
|
+
}
|
105
|
+
|
106
|
+
cli.action.start(`Cloning repository ${repository}...`)
|
107
|
+
if (shell.exec(`git clone ${repository}`).code !== 0) {
|
108
|
+
reject('Error: Installation failed')
|
109
|
+
}
|
110
|
+
cli.action.stop()
|
111
|
+
|
112
|
+
cli.action.start('Cleaning installation...')
|
113
|
+
if (shell.exec(`rm -R -f ${folder}${fileName}/.git`).code !== 0) {
|
114
|
+
reject('Error: removing .git directory')
|
115
|
+
}
|
116
|
+
cli.action.stop()
|
117
|
+
|
118
|
+
resolve("Done")
|
119
|
+
})
|
120
|
+
|
121
|
+
const rmSync = function(path) {
|
122
|
+
var files = [];
|
123
|
+
if( fs.existsSync(path) ) {
|
124
|
+
files = fs.readdirSync(path);
|
125
|
+
files.forEach(function(file,index){
|
126
|
+
var curPath = path + "/" + file;
|
127
|
+
if(fs.lstatSync(curPath).isDirectory()) { // recurse
|
128
|
+
rmSync(curPath);
|
129
|
+
} else { // delete file
|
130
|
+
fs.unlinkSync(curPath);
|
131
|
+
}
|
132
|
+
});
|
133
|
+
fs.rmdirSync(path);
|
134
|
+
}
|
135
|
+
};
|
136
|
+
|
137
|
+
module.exports = { download, decompress, downloadEditor, clone, rmSync }
|