diff --git a/package-lock.json b/package-lock.json index 6b65974..360a192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@coderoad/cli", - "version": "0.0.3", + "version": "0.0.4", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index dfb904d..055a00a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coderoad/cli", - "version": "0.0.3", + "version": "0.0.4", "description": "A CLI to build the configuration file for Coderoad Tutorials", "main": "src/main.js", "bin": { diff --git a/src/build.ts b/src/build.ts index ee16bc8..7b83aa9 100644 --- a/src/build.ts +++ b/src/build.ts @@ -2,197 +2,80 @@ import * as yamlParser from "js-yaml"; import * as path from "path"; import * as _ from "lodash"; import * as fs from "fs"; -import * as T from "../typings/tutorial"; +import * as util from "util"; import { parse } from "./utils/parse"; -// import validate from './validator'; - -// import not working -const simpleGit = require("simple-git/promise"); - -const workingDir = "tmp"; - -function rmDir(dir: string, rmSelf = false) { - try { - let files; - rmSelf = rmSelf === undefined ? true : rmSelf; - - try { - files = fs.readdirSync(dir); - } catch (e) { - console.log(`Sorry, directory '${dir}' doesn't exist.`); - return; - } - - if (files.length > 0) { - files.forEach(function (filePath: string) { - if (fs.statSync(path.join(dir, filePath)).isDirectory()) { - rmDir(path.join(dir, filePath)); - } else { - fs.unlinkSync(path.join(dir, filePath)); - } - }); - } - - if (rmSelf) { - // check if user want to delete the directory ir just the files in this directory - fs.rmdirSync(dir); - } - } catch (error) { - return error; - } -} +import { getArg } from "./utils/args"; +import { getCommits, CommitLogObject } from "./utils/commits"; +import * as T from "../typings/tutorial"; -async function cleanupFiles(workingDir: string) { - try { - const gitModule = simpleGit(process.cwd()); +const write = util.promisify(fs.writeFile); +const read = util.promisify(fs.readFile); - await gitModule.subModule(["deinit", "-f", workingDir]); - await gitModule.rm(workingDir); - await gitModule.reset(["HEAD"]); - rmDir(path.join(process.cwd(), ".git", "modules", workingDir)); - rmDir(workingDir); - } catch (error) { - return error; - } -} +export type BuildConfigOptions = { + text: string; // text document from markdown + config: T.Tutorial; // yaml config file converted to json + commits: CommitLogObject; // an object of tutorial positions with a list of commit hashes +}; -export type BuildOptions = { - repo: string; // Git url to the repo. It should finish with .git - codeBranch: string; // The branch containing the tutorial code - setupBranch: string; // The branch containing the tutorialuration files - isLocal: boolean; // define if the repo is local or remote +type BuildArgs = { + dir: string; + markdown: string; + yaml: string; output: string; }; -async function build({ repo, codeBranch, setupBranch, isLocal }: BuildOptions) { - let git: any; - let isSubModule = false; - let localPath: string; - - if (isLocal) { - git = simpleGit(repo); - localPath = repo; - } else { - const gitTest = simpleGit(process.cwd()); - const isRepo = await gitTest.checkIsRepo(); - localPath = path.join(process.cwd(), workingDir); - - if (isRepo) { - await gitTest.submoduleAdd(repo, workingDir); - - isSubModule = true; - } else { - await gitTest.clone(repo, localPath); - } - - git = simpleGit(localPath); - } - - await git.fetch(); - - // checkout the branch to load tutorialuration and content branch - await git.checkout(setupBranch); - - // Load files - const _content = fs.readFileSync(path.join(localPath, "TUTORIAL.md"), "utf8"); - let _config = fs.readFileSync(path.join(localPath, "coderoad.yaml"), "utf8"); - - const tutorial = parse(_content, _config); - - // Checkout the code branches - await git.checkout(codeBranch); - - // Load all logs - const logs = await git.log(); +const parseArgs = (args: string[]): BuildArgs => { + // default . + const dir = args[0] || "."; + // -o --output - default coderoad.json + const output = + getArg(args, { name: "output", alias: "o" }) || "coderoad.json"; + // -m --markdown - default TUTORIAL.md + const markdown = + getArg(args, { name: "markdown", alias: "m" }) || "TUTORIAL.md"; + // -y --yaml - default coderoad-config.yml + const yaml = + getArg(args, { name: "coderoad-config.yml", alias: "y" }) || + "coderoad-config.yml"; + + return { + dir, + output, + markdown, + yaml, + }; +}; - // Filter relevant logs - const parts = new Set(); +async function build(args: string[]) { + const options = parseArgs(args); - for (const commit of logs.all) { - const matches = commit.message.match( - /^(?(?L\d+)S\d+)(?[QA])?/ - ); + // path to run build from + const localPath = path.join(process.cwd(), options.dir); - if (matches && !parts.has(matches[0])) { - // Uses a set to make sure only the latest commit is proccessed - parts.add(matches[0]); + // load files + const [_markdown, _yaml] = await Promise.all([ + read(path.join(localPath, options.markdown), "utf8"), + read(path.join(localPath, options.yaml), "utf8"), + ]); - // Add the content and git hash to the tutorial - if (matches.groups.stepId) { - // If it's a step: add the content and the setup/solution hashes depending on the type - const level: T.Level | null = - tutorial.levels.find( - (level: T.Level) => level.id === matches.groups.levelId - ) || null; - if (!level) { - console.log(`Level ${matches.groups.levelId} not found`); - } else { - const theStep: T.Step | null = - level.steps.find( - (step: T.Step) => step.id === matches.groups.stepId - ) || null; + const config = yamlParser.load(_yaml); - if (!theStep) { - console.log(`Step ${matches.groups.stepId} not found`); - } else { - if (matches.groups.stepType === "Q") { - theStep.setup.commits.push(commit.hash.substr(0, 7)); - } else if ( - matches.groups.stepType === "A" && - theStep.solution && - theStep.solution.commits - ) { - theStep.solution.commits.push(commit.hash.substr(0, 7)); - } - } - } - } else { - // If it's level: add the commit hash (if the level has the commit key) and the content to the tutorial - const theLevel: T.Level | null = - tutorial.levels.find( - (level: T.Level) => level.id === matches.groups.levelId - ) || null; + const commits: CommitLogObject = await getCommits(config.config.repo.branch); - if (!theLevel) { - console.log(`Level ${matches.groups.levelId} not found`); - } else { - if (_.has(theLevel, "tutorial.commits")) { - if (theLevel.setup) { - theLevel.setup.commits.push(commit.hash.substr(0, 7)); - } - } - } - } - } - } + // Otherwise, continue with the other options + const tutorial: T.Tutorial = await parse({ + text: _markdown, + config, + commits, + }); - // cleanup the submodules - if (!isLocal) { - let cleanupErr; - - if (isSubModule) { - cleanupErr = await cleanupFiles(workingDir); + if (tutorial) { + if (options.output) { + await write(options.output, JSON.stringify(tutorial), "utf8"); } else { - cleanupErr = rmDir(path.join(process.cwd(), workingDir)); - } - - if (cleanupErr) { - console.log( - `Error when deleting temporary files on ${ - isSubModule ? "module" : "folder" - } ${workingDir}.` - ); + console.log(JSON.stringify(tutorial, null, 2)); } } - - // const isValid = validate(tutorial); - - // if (!isValid) { - // console.log(JSON.stringify(validate.errors, null, 2)); - // return; - // } - - return tutorial; } export default build; diff --git a/src/cli.ts b/src/cli.ts index de9766e..e7d0749 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,187 +1,30 @@ -import * as inquirer from "inquirer"; -import * as fs from "fs"; -import * as T from "../typings/tutorial"; -import build, { BuildOptions } from "./build"; +import build from "./build"; import create from "./create"; -// import not working -const arg = require("arg"); -const simpleGit = require("simple-git/promise"); - -type Q = inquirer.Question & { choices?: string[] }; - -type ParsedArgs = { - command: string; - git?: string; - dir?: string; - codeBranch: string; - setupBranch: string; - output?: string; - help: string; -}; - -type Options = { - repo: string; - setupBranch: string; - codeBranch: string; - output: string; - isLocal: boolean; -}; - -const localGit = "Local directory"; -const remoteGit = "Git remote address"; - -function parseArgumentsIntoOptions(rawArgs: string[]): ParsedArgs { - const args = arg( - { - "--git": String, - "--dir": String, - "--code": String, - "--setup": String, - "--output": String, - "--help": Boolean, - "-g": "--git", - "-d": "--dir", - "-c": "--code", - "-s": "--setup", - "-o": "--output", - "-h": "--help", - }, - { - argv: rawArgs.slice(2), - } - ); - return { - command: args["_"][0], - git: args["--git"], - dir: args["--dir"], - codeBranch: args["--code"], - setupBranch: args["--setup"], - output: args["--output"] || "./config.json", - help: args["--help"] || false, - }; -} - -export async function promptForMissingOptions( - options: ParsedArgs -): Promise { - const questions: Q[] = []; - - // if no git remote addres is provided, assume current folder - if (!options.git && !options.dir) { - // check if the current dir is a valid repo - const git = simpleGit(process.cwd()); - const isRepo = await git.checkIsRepo(); - - if (!isRepo) { - questions.push({ - type: "list", - name: "source", - message: `The current directory (${process.cwd()}) is not a valid git repo. Would you like to provide a...`, - choices: [localGit, remoteGit], - default: localGit, - }); - - questions.push({ - type: "input", - name: "localGit", - message: - "Please, provide a local directory of the valid git repository: ", - when: (input: any) => input.source === localGit, - }); - - questions.push({ - type: "input", - name: "remoteGit", - message: "Please, provide the address of a remote git repository: ", - when: (input: any) => input.source === remoteGit, - }); - } - } - // if both local dir and remote repos are provided - else if (options.git && options.dir) { - questions.push({ - type: "list", - name: "source", - message: - "A local git directory and a remote address were both provided. Please, choose either one or those to parse: ", - choices: [localGit, remoteGit], - default: localGit, - }); - } - - // if the branch containing the code is not provided - if (!options.codeBranch) { - questions.push({ - type: "input", - name: "codeBranch", - message: "Please, provide the branch with the code commits: ", - }); - } - - // if the branch containing the setup files is not provided - if (!options.setupBranch) { - questions.push({ - type: "input", - name: "setupBranch", - message: - "Please, provide the branch with the setup files (coderoad.yaml and tutorial.md): ", - }); - } - - const answers: any = await inquirer.prompt(questions); - - let repo: string; - let isLocal: boolean; - - if (answers.source) { - repo = (answers.source === localGit ? options.dir : options.git) || ""; - isLocal = answers.source === localGit; - } else { - repo = options.dir || options.git || process.cwd(); - isLocal = options.git ? false : true; - } - - return { - repo, - setupBranch: options.setupBranch || answers.setupBranch, - codeBranch: options.codeBranch || answers.codeBranch, - output: options.output || ".", - isLocal, - }; -} - -export async function cli(args: string[]): Promise { - let parsedArgs: ParsedArgs = parseArgumentsIntoOptions(args); - - // If help called just print the help text and exit - if (parsedArgs.help) { - console.log( - "Docs can be found at github: https://github.com/coderoad/coderoad-cli/" - ); - } else if (!parsedArgs.command) { - console.log( - `The command is missing. Choose either 'create' or 'build' and its options.` - ); - } else { - switch (parsedArgs.command) { - case "build": - // Otherwise, continue with the other options - const options: BuildOptions = await promptForMissingOptions(parsedArgs); - const tutorial: T.Tutorial = await build(options); - - if (tutorial) { - if (options.output) { - fs.writeFileSync(options.output, JSON.stringify(tutorial), "utf8"); - } else { - console.log(JSON.stringify(tutorial, null, 2)); - } - } - break; - - case "create": - create(process.cwd()); - break; - } +export async function cli(rawArgs: string[]): Promise { + const command: string = rawArgs[2]; + const args = rawArgs.slice(3); + + switch (command) { + case "--version": + case "-v": + const version = require("../package.json").version; + console.log(`v${version}`); + return; + + case "build": + build(args); + break; + + case "create": + create(process.cwd()); + break; + + case "--help": + case "-h": + default: + console.log( + "Docs can be found at github: https://github.com/coderoad/coderoad-cli/" + ); } } diff --git a/src/utils/args.ts b/src/utils/args.ts new file mode 100644 index 0000000..e9688c9 --- /dev/null +++ b/src/utils/args.ts @@ -0,0 +1,32 @@ +type ArgValueParams = { name: string; alias?: string; param?: boolean }; + +const checkValue = ( + args: string[], + string: string, + options: ArgValueParams +) => { + const nameIndex = args.indexOf(string); + if (nameIndex > -1) { + if (options.param) { + const value = args[nameIndex + 1]; + if (!value) { + throw new Error(`Argument ${string} is missing a parameter value`); + } + return value; + } + } + return null; +}; + +export function getArg(args: string[], options: ArgValueParams): string | null { + let value: null | string = null; + + const aliasString = `-${options.alias}`; + value = checkValue(args, aliasString, options); + if (!value) { + const nameString = `--${options.name}`; + value = checkValue(args, nameString, options); + } + + return value; +} diff --git a/src/utils/commits.ts b/src/utils/commits.ts new file mode 100644 index 0000000..afbc57b --- /dev/null +++ b/src/utils/commits.ts @@ -0,0 +1,69 @@ +import * as fs from "fs"; +import util from "util"; +import * as path from "path"; +import gitP, { SimpleGit } from "simple-git/promise"; +import * as T from "../../typings/tutorial"; + +const mkdir = util.promisify(fs.mkdir); +const exists = util.promisify(fs.exists); +const rmdir = util.promisify(fs.rmdir); + +type GetCommitOptions = { + localDir: string; + codeBranch: string; +}; + +export type CommitLogObject = { [position: string]: string[] }; + +export async function getCommits({ + localDir, + codeBranch, +}: GetCommitOptions): Promise { + const git: SimpleGit = gitP(localDir); + + const isRepo = await git.checkIsRepo(); + + if (!isRepo) { + throw new Error("No git repo provided"); + } + + const tmpDir = path.join(localDir, ".tmp"); + + const tmpDirExists = await exists(tmpDir); + if (tmpDirExists) { + await rmdir(tmpDir, { recursive: true }); + } + await mkdir(tmpDir); + const tempGit = gitP(tmpDir); + await tempGit.clone(localDir, tmpDir); + + // Checkout the code branches + await git.checkout(codeBranch); + + // Load all logs + const logs = await git.log(); + + // Filter relevant logs + const commits: CommitLogObject = {}; + + for (const commit of logs.all) { + const matches = commit.message.match( + /^(?(?L\d+)(S\d+))(?[QA])?/ + ); + + if (matches && matches.length) { + // Use an object of commit arrays to collect all commits + const position = matches[0]; + if (!commits[position]) { + // does not exist, create the list + commits[position] = [commit.hash]; + } else { + // add to the list + commits[position].push(commit.hash); + } + } + } + // cleanup the tmp directory + await rmdir(tmpDir, { recursive: true }); + return commits; +} diff --git a/src/utils/parse.ts b/src/utils/parse.ts index d00da9e..6c66063 100644 --- a/src/utils/parse.ts +++ b/src/utils/parse.ts @@ -1,5 +1,5 @@ -import * as yamlParser from "js-yaml"; import * as _ from "lodash"; +import { CommitLogObject } from "./commits"; import * as T from "../../typings/tutorial"; type TutorialFrame = { @@ -59,6 +59,7 @@ export function parseMdContent(md: string): TutorialFrame | never { levelSummary, levelContent, } = levelMatch.groups; + const level = { [levelId]: { id: levelId, @@ -92,31 +93,73 @@ export function parseMdContent(md: string): TutorialFrame | never { return sections; } -export function parse(_content: string, _config: string): T.Tutorial { - const mdContent: TutorialFrame = parseMdContent(_content); - // Parse tutorial to JSON - const tutorial: T.Tutorial = yamlParser.load(_config); +type ParseParams = { + text: string; + config: Partial; + commits: CommitLogObject; +}; + +export function parse(params: ParseParams): any { + const parsed = { ...params.config }; + + const mdContent: TutorialFrame = parseMdContent(params.text); // Add the summary to the tutorial file - tutorial["summary"] = mdContent.summary; + parsed["summary"] = mdContent.summary; // merge content and tutorial - if (tutorial.levels) { - tutorial.levels.forEach((level: T.Level) => { + if (parsed.levels) { + parsed.levels.forEach((level: T.Level, levelIndex: number) => { const levelContent = mdContent[level.id]; + if (!levelContent) { console.log(`Markdown content not found for ${level.id}`); return; } + + // add level setup commits + const levelSetupKey = `L${levelIndex + 1}`; + if (params.commits[levelSetupKey]) { + if (!level.setup) { + level.setup = { + commits: [], + }; + } + level.setup.commits = params.commits[levelSetupKey]; + } + const { steps, ...content } = levelContent; + // add level step commits if (steps) { - steps.forEach((step: T.Step) => _.merge(step, steps[step.id])); + steps.forEach((step: T.Step, stepIndex: number) => { + const stepSetupKey = `${levelSetupKey}S${stepIndex + `1`}Q`; + if (params.commits[stepSetupKey]) { + if (!step.setup) { + step.setup = { + commits: [], + }; + } + step.setup.commits = params.commits[stepSetupKey]; + } + + const stepSolutionKey = `${levelSetupKey}S${stepIndex + `1`}A`; + if (params.commits[stepSolutionKey]) { + if (!step.solution) { + step.solution = { + commits: [], + }; + } + step.solution.commits = params.commits[stepSolutionKey]; + } + + return _.merge(step, steps[step.id]); + }); } _.merge(level, content); }); } - return tutorial; + return parsed; } diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts new file mode 100644 index 0000000..cb21ee0 --- /dev/null +++ b/src/utils/prompt.ts @@ -0,0 +1,116 @@ +// import not working +// const arg = require("arg"); +// const simpleGit = require("simple-git/promise"); + +// type Q = inquirer.Question & { choices?: string[] }; + +// type ParsedArgs = { +// command: string; +// git?: string; +// dir?: string; +// codeBranch: string; +// setupBranch: string; +// output?: string; +// help: string; +// version?: string; +// }; + +// type Options = { +// repo: string; +// setupBranch: string; +// codeBranch: string; +// output: string; +// isLocal: boolean; +// }; + +// const localGit = "Local directory"; +// const remoteGit = "Git remote address"; + +// export async function promptForMissingOptions( +// options: ParsedArgs +// ): Promise { +// const questions: Q[] = []; + +// // if no git remote addres is provided, assume current folder +// if (!options.git && !options.dir) { +// // check if the current dir is a valid repo +// const git = simpleGit(process.cwd()); +// const isRepo = await git.checkIsRepo(); + +// if (!isRepo) { +// questions.push({ +// type: "list", +// name: "source", +// message: `The current directory (${process.cwd()}) is not a valid git repo. Would you like to provide a...`, +// choices: [localGit, remoteGit], +// default: localGit, +// }); + +// questions.push({ +// type: "input", +// name: "localGit", +// message: +// "Please, provide a local directory of the valid git repository: ", +// when: (input: any) => input.source === localGit, +// }); + +// questions.push({ +// type: "input", +// name: "remoteGit", +// message: "Please, provide the address of a remote git repository: ", +// when: (input: any) => input.source === remoteGit, +// }); +// } +// } +// // if both local dir and remote repos are provided +// else if (options.git && options.dir) { +// questions.push({ +// type: "list", +// name: "source", +// message: +// "A local git directory and a remote address were both provided. Please, choose either one or those to parse: ", +// choices: [localGit, remoteGit], +// default: localGit, +// }); +// } + +// // if the branch containing the code is not provided +// if (!options.codeBranch) { +// questions.push({ +// type: "input", +// name: "codeBranch", +// message: "Please, provide the branch with the code commits: ", +// }); +// } + +// // if the branch containing the setup files is not provided +// if (!options.setupBranch) { +// questions.push({ +// type: "input", +// name: "setupBranch", +// message: +// "Please, provide the branch with the setup files (coderoad.yaml and tutorial.md): ", +// }); +// } + +// const answers: any = await inquirer.prompt(questions); + +// let repo: string; +// let isLocal: boolean; + +// if (answers.source) { +// repo = (answers.source === localGit ? options.dir : options.git) || ""; +// isLocal = answers.source === localGit; +// } else { +// repo = options.dir || options.git || process.cwd(); +// isLocal = options.git ? false : true; +// } + +// return { +// repo, +// setupBranch: options.setupBranch || answers.setupBranch, +// codeBranch: options.codeBranch || answers.codeBranch, +// output: options.output || ".", +// isLocal, +// }; +// } diff --git a/tests/parse.test.ts b/tests/parse.test.ts index 9544e70..13ce2d2 100644 --- a/tests/parse.test.ts +++ b/tests/parse.test.ts @@ -8,8 +8,12 @@ describe("parse", () => { `; - const yaml = `version: "0.1.0"`; - const result = parse(md, yaml); + const config = { version: "0.1.0" }; + const result = parse({ + text: md, + config, + commits: {}, + }); const expected = { summary: { description: "Short description to be shown as a tutorial's subtitle.", @@ -31,11 +35,15 @@ Description. Some text `; - const yaml = `version: "0.1.0" -levels: -- id: L1 -`; - const result = parse(md, yaml); + const config = { + levels: [{ id: "L1" }], + }; + + const result = parse({ + text: md, + config, + commits: {}, + }); const expected = { levels: [ { @@ -62,17 +70,20 @@ Description. Some text `; - const yaml = `version: "0.1.0" -levels: -- id: L1 - setup: - files: [] - commits: [] - solution: - files: [] - commits: [] -`; - const result = parse(md, yaml); + const config = { + levels: [ + { + id: "L1", + setup: { files: [], commits: [] }, + solution: { files: [], commits: [] }, + }, + ], + }; + const result = parse({ + text: md, + config, + commits: {}, + }); const expected = { levels: [ { @@ -99,11 +110,12 @@ Description. Some text that becomes the summary `; - const yaml = `version: "0.1.0" -levels: -- id: L1 -`; - const result = parse(md, yaml); + const config = { levels: [{ id: "L1" }] }; + const result = parse({ + text: md, + config, + commits: {}, + }); const expected = { levels: [ { @@ -127,11 +139,12 @@ Description. Some text that becomes the summary and goes beyond the maximum length of 80 so that it gets truncated at the end `; - const yaml = `version: "0.1.0" -levels: -- id: L1 -`; - const result = parse(md, yaml); + const config = { levels: [{ id: "L1" }] }; + const result = parse({ + text: md, + config, + commits: {}, + }); const expected = { levels: [ { @@ -161,11 +174,12 @@ Second line Third line `; - const yaml = `version: "0.1.0" -levels: -- id: L1 -`; - const result = parse(md, yaml); + const config = { levels: [{ id: "L1" }] }; + const result = parse({ + text: md, + config, + commits: {}, + }); const expected = { summary: { description: "Description.\n\nSecond description line", @@ -208,7 +222,41 @@ config: - name: node version: '>=10' `; - const result = parse(md, yaml); + + const config = { + config: { + testRunner: { + command: "./node_modules/.bin/mocha", + args: { + filter: "--grep", + tap: "--reporter=mocha-tap-reporter", + }, + directory: "coderoad", + setup: { + commits: ["abcdefg1"], + commands: [], + }, + }, + appVersions: { + vscode: ">=0.7.0", + }, + repo: { + uri: "https://path.to/repo", + branch: "aBranch", + }, + dependencies: [ + { + name: "node", + version: ">=10", + }, + ], + }, + }; + const result = parse({ + text: md, + config, + commits: {}, + }); const expected = { summary: { description: "Description.\n\nSecond description line", diff --git a/tsconfig.json b/tsconfig.json index 04d2d49..d3d6cfc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ "emitDecoratorMetadata": true, "allowJs": true, "removeComments": true, - "skipLibCheck": true + "skipLibCheck": true, + "esModuleInterop": true }, "exclude": ["node_modules", ".vscode", "bin", "build", "tests"] } diff --git a/typings/lib.d.ts b/typings/lib.d.ts index 3b2928e..a9b884c 100644 --- a/typings/lib.d.ts +++ b/typings/lib.d.ts @@ -2,7 +2,3 @@ declare module "arg" { export default (args: { [key: string]: any }, options: { argv: string[] }) => any; } - -declare module "simple-git/promise" { - export default any; -}