create-narrat
Version:
A tool to automatically create a narrat game
297 lines (263 loc) • 7.49 kB
JavaScript
// @ts-check
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import minimist from 'minimist';
import prompts from 'prompts';
import { green, red, reset, yellow } from 'kolorist';
// Avoids autoconversion to number of the project name by defining that the args
// non associated with an option ( _ ) needs to be parsed as a string. See #4606
const argv = minimist(process.argv.slice(2), { string: ['_'] });
const cwd = process.cwd();
const TEMPLATES = [
{
path: 'empty',
name: 'Empty Game',
description: 'Game with an empty script to get started',
color: yellow,
},
{
path: 'demo',
name: 'Demo Game',
description:
'Game with a demo script and config/assets to have an example to work with',
color: green,
},
// {
// path: 'rpg',
// name: 'RPG Game',
// description: 'RPG Game Demo',
// color: green,
// },
];
const renameFiles = {
_gitignore: '.gitignore',
};
async function init() {
let targetDir = formatTargetDir(argv._[0]);
let templateChoice = argv.template || argv.t;
const defaultTargetDir = 'narrat-game';
const getProjectName = () =>
targetDir === '.' ? path.basename(path.resolve()) : targetDir;
let result = {};
try {
result = await prompts(
[
{
type: targetDir ? null : 'text',
name: 'projectName',
message: reset('Project name:'),
initial: defaultTargetDir,
onState: (state) => {
targetDir = formatTargetDir(state.value) || defaultTargetDir;
},
},
{
type: () =>
!fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm',
name: 'overwrite',
message: () =>
(targetDir === '.'
? 'Current directory'
: `Target directory "${targetDir}"`) +
` is not empty. Remove existing files and continue?`,
},
{
type: (_, { overwrite } = {}) => {
if (overwrite === false) {
throw new Error(red('✖') + ' Operation cancelled');
}
return null;
},
name: 'overwriteChecker',
},
{
type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
name: 'packageName',
message: reset(
'Package name (game name with dashes, like narrat-game):',
),
initial: () => toValidPackageName(getProjectName()),
validate: (dir) =>
isValidPackageName(dir) || 'Invalid package.json name',
},
{
type:
templateChoice && TEMPLATES.includes(templateChoice)
? null
: 'select',
name: 'template',
message:
typeof templateChoice === 'string' &&
!TEMPLATES.includes(templateChoice)
? reset(
`"${templateChoice}" isn't a valid template. Please choose from below: `,
)
: reset('Select a template:'),
initial: 0,
choices: TEMPLATES.map((template) => {
const templateColor = template.color;
return {
title: templateColor(template.name),
value: template,
};
}),
},
],
{
onCancel: () => {
throw new Error(red('✖') + ' Operation cancelled');
},
},
);
} catch (cancelled) {
console.log(cancelled.message);
return;
}
// user choice associated with prompts
const { template, overwrite, packageName } = result;
const root = path.join(cwd, targetDir);
if (overwrite) {
emptyDir(root);
} else if (!fs.existsSync(root)) {
fs.mkdirSync(root, { recursive: true });
}
// determine template
templateChoice = template.path;
console.log(`\nCreating a narrat game in ${root}...`);
const baseTemplateDir = path.resolve(
fileURLToPath(import.meta.url),
'..',
'template',
);
const gameTemplateDir = path.resolve(
fileURLToPath(import.meta.url),
'..',
`template-games`,
`${templateChoice}`,
);
const write = (root, dir, file, content) => {
const targetPath = renameFiles[file]
? path.join(root, renameFiles[file])
: path.join(root, file);
if (content) {
fs.writeFileSync(targetPath, content);
} else {
copy(path.join(dir, file), targetPath);
}
};
// 1. Copy base template
const baseFiles = fs.readdirSync(baseTemplateDir);
for (const file of baseFiles.filter((f) => f !== 'package.json')) {
write(root, baseTemplateDir, file);
}
// 2. Rename and copy package.json
const pkg = JSON.parse(
fs.readFileSync(path.join(baseTemplateDir, `package.json`), 'utf-8'),
);
pkg.name = packageName || getProjectName();
write(root, baseTemplateDir, 'package.json', JSON.stringify(pkg, null, 2));
// 3. Copy game template
const gameFiles = fs.readdirSync(gameTemplateDir);
for (const file of gameFiles) {
write(root, gameTemplateDir, file);
}
// 4. Finish and show run instructions
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent);
const pkgManager = pkgInfo ? pkgInfo.name : 'npm';
console.log(`\nDone. Now run:\n`);
if (root !== cwd) {
console.log(` cd ${path.relative(cwd, root)}`);
}
switch (pkgManager) {
case 'yarn':
console.log(' yarn');
console.log(' yarn dev');
break;
default:
console.log(` ${pkgManager} install`);
console.log(` ${pkgManager} start`);
break;
}
console.log();
}
/**
* @param {string | undefined} targetDir
*/
function formatTargetDir(targetDir) {
return targetDir?.trim().replace(/\/+$/g, '');
}
function copy(src, dest) {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
copyDir(src, dest);
} else {
fs.copyFileSync(src, dest);
}
}
/**
* @param {string} projectName
*/
function isValidPackageName(projectName) {
return /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(
projectName,
);
}
/**
* @param {string} projectName
*/
function toValidPackageName(projectName) {
return projectName
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/^[._]/, '')
.replace(/[^a-z0-9-~]+/g, '-');
}
/**
* @param {string} srcDir
* @param {string} destDir
*/
function copyDir(srcDir, destDir) {
fs.mkdirSync(destDir, { recursive: true });
for (const file of fs.readdirSync(srcDir)) {
const srcFile = path.resolve(srcDir, file);
const destFile = path.resolve(destDir, file);
copy(srcFile, destFile);
}
}
/**
* @param {string} path
*/
function isEmpty(path) {
const files = fs.readdirSync(path);
return files.length === 0 || (files.length === 1 && files[0] === '.git');
}
/**
* @param {string} dir
*/
function emptyDir(dir) {
if (!fs.existsSync(dir)) {
return;
}
for (const file of fs.readdirSync(dir)) {
fs.rmSync(path.resolve(dir, file), { recursive: true, force: true });
}
}
/**
* @param {string | undefined} userAgent process.env.npm_config_user_agent
* @returns object | undefined
*/
function pkgFromUserAgent(userAgent) {
if (!userAgent) return undefined;
const pkgSpec = userAgent.split(' ')[0];
const pkgSpecArr = pkgSpec.split('/');
return {
name: pkgSpecArr[0],
version: pkgSpecArr[1],
};
}
init().catch((e) => {
console.error(e);
});