/// // @ts-check /** @typedef {import('zx').ProcessOutput} ProcessOutput */ /** * TODO: - Check npm version - Recommend using volta or nvm with links - Check if ni is installed - Recommend installing `@antfu/ni` or prompt to install - Check pnpm is installed - Prompt to install with npm - Install dependencies inside project (skip if pnpm was not installed) */ /** * How to test this: run `nr test` to create a temp folder for the script * Prod run with : `npx zx@7 https://esm.is/mastering-pinia */ // ARGUMENT PARSING // TODO: once this is added by zx // const args = minimist(process.argv.slice(2), { // boolean: ['verbose', 'debug', 'skipChecks'], // string: ['branch', 'dir'], // alias: { // v: 'verbose', // d: 'dir', // b: 'branch', // skipChecks: 'skip-checks', // }, // default: { // b: 'main', // }, // stopEarly: false, // }) // simulate minimist const args = { verbose: !!(argv.verbose || argv.v), debug: !!argv.debug, branch: argv.branch || argv.b || 'main', skipChecks: !!(argv.skipChecks || argv['skip-checks']), dir: argv.dir || argv.d, _: [null, argv._[0]], } // display ran commands $.verbose = args.verbose // test mode, writes to /tmp folder const isDebug = args.debug /** @type {string} */ const branch = args.branch /** @type {string} */ const repoURL = argv.url ?? 'git@github.com:MasteringPinia/mastering-pinia--code.git' /** @type {boolean} */ const skipVersionsCheck = args.skipChecks /** @type {string} */ const targetDir = args.dir || args._[1] || (isDebug ? fs.mkdtempSync(path.join(os.tmpdir(), 'mastering-pinia-exercises-')) : path.join(os.homedir(), 'mastering-pinia-exercises')) // debug only creates a temp folder when nothing is provided if (isDebug) { console.log( chalk.bold.bgMagentaBright(` DEBUG MODE `) + chalk.bold.blueBright( `\n\n You activated debug mode with "--debug".\n If that wasn't intentional, please remove it.\n\n`, ), ) } /** * Removes a file with log * @param {string} filepath - path to the file to delete */ function deleteFile(filepath) { if ($.verbose) { console.log(`⊖ Deleting "${filepath}"...`) } return fs.remove(filepath) } /** * * @param {string} src - path to the file to move * @param {*} dest - path to the destination * @param {import('fs-extra').MoveOptions} [options] - options to move the file * @returns */ function moveFile(src, dest, options) { if ($.verbose) { console.log(`⇢ Moving "${src}" to "${dest}"...`) } return fs.move(src, dest, options) } /** * * @param {string} src - path to the file to copy * @param {string} dest - path to the destination * @param {import('fs-extra').CopyOptions} [options] - options to copy the file * @returns */ function copyFile(src, dest, options) { if ($.verbose) { console.log(`↔ Copying "${src}" to "${dest}"...`) } return fs.copy(src, dest, options) } const VERSION_RE = /^[\s\S]*?(\d+\.\d+\.\d+)[\s\S]*?$/ /** * Extract the output in semver. * * @param {ProcessOutput | undefined} output */ function extractVersion(output) { return output?.stdout.replace(VERSION_RE, '$1') } const OK = chalk.greenBright('✔') const KO = chalk.redBright('✘') const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/ /** * Parses the version. * * @param {string | null | undefined} v version string * @returns {[major: number, minor: number, patch: number]} */ function parseVersions(v) { const match = v?.match(SEMVER_RE) return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : [0, 0, 0] } /** * Is Node version okay * * @param {string | undefined} v version */ function isNodeOk(v) { const [major, minor] = parseVersions(v) return v && major >= 18 } function isNpmOk(v) { const [major, minor] = parseVersions(v) return v && major >= 8 } function isPnpmOk(v) { const [major, minor] = parseVersions(v) return v && ((major === 8 && minor >= 6) || major > 8) } function isZXOk(v) { const [major, minor] = parseVersions(v) return v && major == 7 && minor >= 2 } function isGitVersionOk(v) { const [major, minor] = parseVersions(v) return v && ((major >= 2 && minor >= 13) || major > 2) } async function checkGitUser() { let gitEmail = (await $`git config user.email`.nothrow())?.stdout .trim() .replace(/\n+$/, '') let gitName = (await $`git config user.name`.nothrow())?.stdout .trim() .replace(/\n+$/, '') // hide email in logs if (gitEmail) { const atIndex = gitEmail.indexOf('@') gitEmail = gitEmail.at(0) + '***' + gitEmail.slice(atIndex) } if (gitName) { gitName = gitName.slice(0, gitName.indexOf(' ') + 1) + '*****' } if (!gitEmail) { console.log( chalk.bold.whiteBright( `${KO} Git email is not set. Run "git config --global user.email "`, ), ) } if (!gitName) { console.log( chalk.bold.whiteBright( `${KO} Git name is not set. Run "git config --global user.name "`, ), ) } if (gitEmail && gitName) { console.log( chalk.gray( `Git user set: ${chalk.blue.dim(gitName + ` <${gitEmail}>`)}...${OK}`, ), ) } return gitEmail && gitName } // for reuse const notInstalled = chalk.bold.white('not installed') async function checkVersions() { const nodeVersion = extractVersion(await $`node --version`.nothrow()) // const npmVersion = extractVersion(await $`npm --version`.nothrow()) const pnpmVersion = extractVersion(await $`pnpm --version`.nothrow()) const zxExecutable = process.argv.at(1) const zxVersion = zxExecutable ? extractVersion(await $`node "${zxExecutable}" --version`.nothrow()) : undefined const gitVersion = extractVersion(await $`git --version`.nothrow()) const isOk = [ isNodeOk(nodeVersion), isPnpmOk(pnpmVersion), isZXOk(zxVersion), isGitVersionOk(gitVersion), ] console.log( chalk.gray( `Node: ${nodeVersion || notInstalled}...${ isOk[0] ? OK : `${KO}: ${chalk.bold.whiteBright( 'Update Node and npm to the latest LTS', )}.` }`, ), ) console.log( chalk.gray( `pnpm: ${pnpmVersion || notInstalled}...${ isOk[1] ? OK : `${KO}: ${chalk.bold.whiteBright('Update pnpm')}.` }`, ), ) console.log( chalk.gray( `npx zx: ${zxVersion || notInstalled}...${ isOk[2] ? OK : `${KO}: ${chalk.bold.whiteBright( 'zx should be at least 7.2, try again with "npx zx@7" or clear npx cache with "rm -rf ~/.npm/_npx"', )}.` }`, ), ) console.log( chalk.gray( `git: ${gitVersion || notInstalled}...${ isOk[3] ? OK : `${KO}: ${chalk.bold.whiteBright( 'Make sure to have a recent version of git (>=2.13) installed.', )}.` }`, ), ) isOk.push(await checkGitUser()) if (isOk.some((v) => !v)) { if (process.platform.toLowerCase().startsWith('win')) { console.log( '\n' + chalk.bold( 'You seem to be on Windows. As long as git is working and you are sure that the other tools are installed, you can skip the checks with "--skipChecks".\n', ) + chalk.gray.bold('You can find more about this at:\n') + chalk.gray.bold( '\thttps://github.com/MasteringPinia/mastering-pinia--code#troubleshooting\n', ), ) } process.exit(1) } } if (skipVersionsCheck) { // we still need git to execute everything else const gitVersion = extractVersion(await $`git --version`.nothrow()) const isOk = isGitVersionOk(gitVersion) console.log( chalk.gray( `git: ${gitVersion || notInstalled}...${ isOk ? OK : `${KO}: ${chalk.bold.whiteBright( 'Make sure to have a recent version of git (>=2.13) installed.', )}.` }`, ), ) if (!isOk || !(await checkGitUser())) { process.exit(1) } console.log( chalk.bold.yellowBright(`\nSkipping version checks.\n`) + `Before continuing, make sure to have the following installed:\n` + chalk.bold(`- Node.js >= 18.0.0\n`) + chalk.gray(`\tnode --version\n`) + chalk.bold(`- pnpm >= 8.6.0\n`) + chalk.gray(`\tpnpm --version\n`), ) const reply = (await question('Continue? (y/n)\n')).toLowerCase() if (reply !== 'y' && reply !== 'yes') { process.exit(0) } } else { await checkVersions() } const isUpdateTask = await (async () => { // check if there is a folder named exercises should be enough const exercisesDir = path.join(targetDir, 'src', 'exercises') try { return ( fs.existsSync(exercisesDir) && fs.statSync(exercisesDir).isDirectory() ) } catch (_e) { return false } })() if (isUpdateTask) { console.log( chalk.bold.greenBright( `The target directory "${targetDir}" already exists. It will be updated.`, ), ) const reply = (await question('Continue? (y/n)\n')).toLowerCase() if (reply !== 'y' && reply !== 'yes') { process.exit(0) } } const urlList = [ repoURL, // 'git@github.com:MasteringPinia/mastering-pinia--code.git', // to also try http 'https://github.com/MasteringPinia/mastering-pinia--code.git', ] const resolvedRepoFolder = path.resolve(targetDir) const repoFolderRelative = path.relative('.', resolvedRepoFolder) // ensure the folder is a git repository and doesn't have uncommitted changes if (isUpdateTask) { if (!fs.existsSync(path.join(targetDir, '.git'))) { console.log( chalk.bold.red('FATAL: ') + chalk.red( `The folder ${chalk.bold.whiteBright( path.resolve(targetDir), )} is not a git repository. It cannot be updated...`, ), ) process.exit(1) } $.cwd = targetDir const isDirty = (await $`git status --porcelain`.nothrow()).stdout $.cwd = undefined if (isDirty) { console.log( chalk.bold.red('FATAL: ') + chalk.red( `The folder ${chalk.bold.whiteBright( path.resolve(targetDir), )} has uncommitted changes. Commit them and try again.`, ), ) process.exit(1) } } const cloneFolder = isUpdateTask ? // the extra slash is needed for windows await fs.mkdtempSync(path.join(os.tmpdir(), '/')) : targetDir console.log(chalk.bold(`⬇️ Cloning into ${chalk.greenBright(cloneFolder)}...`)) console.log(chalk.dim(` ↳ ${chalk.greenBright(path.resolve(cloneFolder))}`)) if (branch !== 'main') { console.log( chalk.bold( `🎋 Cloning branch ${chalk.cyanBright(branch)} instead of main.`, ), ) } for (const url of urlList) { try { console.log(chalk.gray(`Cloning git repo from ${url}`)) await spinner( () => $`git clone --depth 1 --branch ${branch} ${url} ${cloneFolder}`, ) // if it worked we can stop break } catch (error) { if (urlList.at(-1) === url) { console.log( chalk.bold.red('FATAL: ') + chalk.red( `Failed to clone the repository with "${url}"... Did you accept the GitHub invitation to the repository?`, ), ) throw error } console.log(chalk.bold.yellow(`Error while cloning with "${url}"...`)) console.error(error) } } console.log(chalk.gray(`Moving to ${cloneFolder}...`)) cd(cloneFolder) console.log(chalk.bold(`💻 Preparing the workshop...`)) // remove solutions const solutions = await globby( [ 'src/exercises/*/*', '!src/exercises/*/.internal/**/*', '!src/exercises/*/instructions.md', ], { onlyFiles: true, dot: false, }, ) console.log(chalk.gray(`Deleting some files...`)) await Promise.all(solutions.map((file) => deleteFile(file))) await deleteFile('src/exercises/_template') await deleteFile('.internal') await deleteFile('.github') // move starting files up const exos = await globby(['src/exercises/*/_start/*'], { onlyFiles: false, dot: true, }) console.log(chalk.gray(`Moving some files...`)) await Promise.all( exos.map((exo) => moveFile(exo, exo.replace('_start/', ''), { overwrite: true }), ), ) console.log(chalk.gray(`Cleaning up some empty folders...`)) await Promise.all( exos.map((exo) => deleteFile(exo.replace(/_start\/.*$/, '_start'))), ) if (!fs.existsSync(path.join(resolvedRepoFolder, '.env'))) { console.log(chalk.gray(`Creating .env file...`)) // it will be copied as is later on await moveFile('.env.example', '.env') } else { // just remove it, they already have one await deleteFile('.env.example') } console.log(chalk.bold(`🗃️ Preparing the Database`)) const resolvedDBFile = path.join(resolvedRepoFolder, 'db.json') if (!fs.existsSync(resolvedDBFile)) { console.log(chalk.gray(`Creating db.json file...`)) await moveFile('db.example.json', resolvedDBFile) } else { console.log(chalk.gray(`Updating db.json file...`)) try { const dbBase = JSON.parse(fs.readFileSync('db.example.json', 'utf-8')) const db = JSON.parse(fs.readFileSync(resolvedDBFile, 'utf-8')) for (const table in dbBase) { if ( Array.isArray(dbBase[table]) && dbBase[table].length > 0 && (!db[table] || !Array.isArray(db[table]) || db[table].length === 0) ) { console.log(chalk.dim(`· Adding table ${chalk.cyanBright(table)}...`)) db[table] = dbBase[table] } } console.log(chalk.gray(`Writing to db.json file...`)) fs.writeFileSync( resolvedDBFile, JSON.stringify(db, null, 2) + '\n', 'utf-8', ) await deleteFile('db.example.json') } catch (err) { console.log( chalk.bold.red(`Local database file is not valid JSON, replacing it...`), ) await moveFile('db.example.json', resolvedDBFile) if ($.verbose) { console.error(err) } } } // delete internal npm scripts used for CI and course creation // we are inside the clone folder console.log(chalk.gray(`Deleting internal npm scripts...`)) const parsedPackage = JSON.parse(await fs.readFile('package.json', 'utf-8')) for (const scriptName in parsedPackage.scripts) { if (scriptName.startsWith('internal:')) { delete parsedPackage.scripts[scriptName] if ($.verbose) { console.log(`⊖ Removing script "${scriptName}"...`) } } } console.log(chalk.gray(`Writing package.json...`)) await fs.writeFile( 'package.json', JSON.stringify(parsedPackage, null, 2) + '\n', ) console.log(chalk.gray(`Cleaning up bootstrap files...`)) await Promise.all( ( await globby([ // '*.*', 'README.md', ]) ).map((exo) => deleteFile(exo)), ) await moveFile('README-project.md', 'README.md') // add vscode settings await moveFile('./.vscode/user-settings.json', './.vscode/settings.json', { overwrite: true, }) if (isUpdateTask) { // copy new exercises console.log(chalk.gray(`Copying new exercises...`)) const allExercises = await globby(['src/exercises/*'], { onlyDirectories: true, }) const existingExercises = await globby(['src/exercises/*'], { onlyDirectories: true, cwd: resolvedRepoFolder, }) const newExercises = allExercises.filter( // both should be relative to their current folder, so we could directly compare them // but in the past some were absolute and the includes seems to be easier (ex) => !existingExercises.some((f) => f.includes(ex)), ) if ($.verbose) { console.log({ allExercises, existingExercises, newExercises, }) } if (newExercises.length > 0) { console.log( chalk.bold(`📕 Adding ${newExercises.length} new exercise(s)...`), ) console.log(chalk.gray(`Copying new exercises...`)) await Promise.all( newExercises.map((ex) => copyFile(ex, path.join(resolvedRepoFolder, ex))), ) } console.log(chalk.gray(`Finding other files to update...`)) const filesToCopy = await globby( [ '**/*', '!db.json', // Avoid overriding student's work '!src/exercises/*/instructions.md', '!src/exercises/*/*.vue', '!src/exercises/*/components/**/*.vue', '!src/exercises/*/pages/**/*.vue', '!src/exercises/*/*.ts', '!src/exercises/*/stores/**/*.ts', // some generated files '!components.d.ts', '!typed-router.d.ts', '!.git', '!node_modules', ], { onlyFiles: true, dot: true, }, ) console.log(chalk.gray(`Updating ${filesToCopy.length} files...`)) if ($.verbose) { console.log(filesToCopy.join('\n')) } await spinner(() => Promise.all( filesToCopy.map((file) => { return copyFile(file, path.join(resolvedRepoFolder, file), { overwrite: true, }) }), ), ) console.log( chalk.bold( `🎉 All done! go to ${chalk.cyanBright( repoFolderRelative, )} and make sure everything is ok with "git status" and "git diff".\n` + `If not, you can always revert everything with "git checkout .".`, ), ) console.log( chalk.bold( `👉 Make sure to run ${chalk.cyanBright( `"pnpm i"`, )} to install/update dependencies.`, ), ) } else { console.log(chalk.bold(`🤖 Preparing the git repository...`)) console.log(chalk.gray(`Adding changes...`)) await spinner(() => $`git add .`) console.log(chalk.gray(`Committing changes...`)) await spinner(() => $`git commit -m 'initial'`) console.log(chalk.gray(`Creating branch...`)) await $`git checkout --orphan tmp` console.log(chalk.gray(`Saving changes...`)) await spinner(() => $`git commit -m 'init'`) console.log(chalk.gray(`Cleaning up branches...`)) await $`git branch -M tmp main` console.log(chalk.gray(`Cleaning up remotes...`)) await $`git remote rm origin` console.log( chalk.bold( `🎉 All done! go to ${chalk.cyanBright( repoFolderRelative, )} and follow the instructions there to start the workshop:`, ), ) console.log( '\n' + chalk.bold.cyanBright(`cd ${repoFolderRelative} pnpm i ${chalk.reset.gray(`# Make sure to use pnpm (or ni from @antfu/ni)`)} pnpm run dev `), ) } export {}