///
// @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 {}