Files
skeleton/packages/create-skeleton-app/src/index.js
2023-10-09 10:47:04 -05:00

334 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { SkeletonOptions, createSkeleton } from './creator.js';
import fs from 'fs-extra';
import mri from 'mri';
import { bold, cyan, gray, grey, red } from 'kleur/colors';
import { intro, text, select, multiselect, spinner } from '@clack/prompts';
import { dist, getHelpText, goodbye, whichPMRuns, checkIfDirSafeToInstall } from './utils.js';
import { resolve, join, relative, dirname, basename } from 'path';
import semver from 'semver';
import fg from 'fast-glob';
// Minimum version required for Svelte Kit
const requiredVersion = '16.14.0';
async function main() {
if (semver.lt(process.version, requiredVersion)) {
console.error(`You need to be running Node ${requiredVersion} to use Svelte Kit`);
process.exit(1);
}
const startPath = process.cwd();
let defaults = new SkeletonOptions();
// grab any passed arguments from the command line
let opts = await parseArgs();
// need to set some defaults if they are not passed in
if (!('quiet' in opts)) opts.quiet = false;
// if no templatedir is provided we have to account for the dist location
if (!('skeletontemplatedir' in opts)) {
opts.skeletontemplatedir = resolve(dist('.'), '../templates');
} else {
// Resolve can handle multiple absolute paths so passing in a relative or absolute path is fine
opts.skeletontemplatedir = resolve(process.cwd(), opts.skeletontemplatedir);
}
try {
checkIfDirSafeToInstall(opts.path);
} catch (e) {
console.error(red(`\n${e.message}`));
process.exit(1);
}
if (!opts.quiet) {
opts = await askForMissingParams(opts);
}
opts = Object.assign(defaults, opts);
opts.packagemanager = whichPMRuns()?.name;
// Now that we have all of the options, lets create it.
const s = spinner();
if (!opts.quiet) {
s.start('Installing');
}
try {
await createSkeleton(opts);
} catch (e) {
console.error(red(`\n${e.message}`));
process.exit(1);
}
if (!opts.quiet) {
s.stop('Done installing');
// And give the user some final information on what to do Next
const pm = opts.packagemanager;
let runString = `${pm} install\n${pm} dev\n`;
if (pm == 'npm') {
runString = 'npm install\nnpm run dev\n';
}
let finalInstructions = bold(cyan(`\nDone! You can now:\n\n`));
if (startPath != opts.path) {
finalInstructions += bold(cyan(`cd ${relative(startPath, opts.path)}\n`));
}
finalInstructions += bold(cyan(runString));
finalInstructions += grey(`Need some help or found an issue? Visit us on Discord https://discord.gg/EXqV7W8MtY`);
console.log(finalInstructions);
}
process.exit();
}
async function parseArgs() {
const argv = process.argv.slice(2);
// mri will parse argv and expand any shorthand args. Accepted args are the literal props of SkelOptions
/** @type {SkeletonOptions} */
const opts = mri(argv, {
alias: {
h: 'help',
p: 'path',
t: 'skeletontheme',
m: 'monorepo',
q: 'quiet',
v: 'verbose',
},
boolean: [
'help',
'quiet',
'monorepo',
'library',
'prettier',
'eslint',
'playwright',
'vitest',
'inspector',
'codeblocks',
'popups',
'forms',
'typography',
'mdsvex',
],
});
// If a user invokes 'create-app blah foo', it falls into the _ catch all list, the best we can do is take the first one and use that as the path
// if args are passed in incorrectly such as --prettier=0 instead of --prettier=false then a 0 will be added to the _ collection, we check that the
// first one isn't a bungled arg set to 0
if (opts._.length && opts._[0] != 0) {
opts.path = opts._[0];
}
// Show help if specified regardless of how many other options are specified, have fun updating the text string in utils.ts :(
if ('help' in opts) {
console.log(getHelpText());
process.exit();
}
return opts;
}
/**
* @param {SkeletonOptions} opts
*/
async function askForMissingParams(opts) {
const { version } = JSON.parse(fs.readFileSync(dist('../package.json'), 'utf-8'));
intro(`Create Skeleton App ${gray(`(version ${version})`)}
${bold(cyan('Welcome to Skeleton 💀! A UI toolkit for Svelte + Tailwind'))}
Problems? Open an issue on ${cyan('https://github.com/skeletonlabs/skeleton/issues')} if none exists already.`);
//NOTE: When doing checks here, make sure to test for the presence of the prop, not the prop value as it may be set to false deliberately.
if (!('path' in opts)) {
opts.path = await text({
message: 'Where should we install your project (Enter for current directory)?',
placeholder: '',
validate(value) {
if (value.length === 0) value = '.';
try {
checkIfDirSafeToInstall(resolve(process.cwd(), value));
} catch (e) {
return e.message;
}
},
});
goodbye(opts.path);
}
if (opts?.path == undefined) opts.path = '.';
// name to set in package.json
opts.name = basename(opts.path);
// Skeleton Template Selection
// We have to ask for the template first as it may dictate things like required packages and typechecking
// skeletontemplatedir is the path to the templates directory, it's either passed in as an arg or set to cwd
// it may be a single directory with a csa-meta in the root,
// or it holds multiple directories with csa-meta files in them and skeletontemplate selects that sub folder.
let templateFound = false;
if (opts?.skeletontemplate) {
// they have asked for a specific template within the folder
opts.skeletontemplate = resolve(opts.skeletontemplatedir, opts.skeletontemplate, 'csa-meta.json');
//check that it exists
if (!fs.existsSync(opts.skeletontemplate)) {
console.error(`The template ${opts.skeletontemplate} does not exist`);
process.exit(1);
}
templateFound = true;
}
// no template specified, so scan the templatedir for csa-meta files
if (!templateFound) {
const metaFiles = fg.sync(['**/csa-meta.json'], { cwd: opts.skeletontemplatedir, deep: 2 });
if (metaFiles.length === 0) {
console.error(`No templates found in ${opts.skeletontemplatedir}`);
process.exit(1);
}
let parsedChoices = [];
metaFiles.forEach((meta_file) => {
const path = join(opts.skeletontemplatedir, meta_file);
const { position, label, description, enabled } = JSON.parse(fs.readFileSync(path, 'utf8'));
if (enabled) {
parsedChoices.push({ position, label, hint: description, value: path });
}
});
parsedChoices.sort((a, b) => a.position - b.position);
opts.skeletontemplate = await select({
message: 'Which Skeleton app template?',
options: parsedChoices,
});
goodbye(opts.skeletontemplate);
}
// Now that we have the template, lets get the meta data from it and the base path
opts.meta = JSON.parse(fs.readFileSync(opts.skeletontemplate, 'utf8'));
if (opts.meta.requiredFeatures) {
opts.meta.requiredFeatures.forEach((val) => {
Object.assign(opts, val);
});
}
opts.skeletontemplatedir = dirname(opts.skeletontemplate);
// If it's a premium template, wording needs to be change to indicate that there is a theme already built in
// Skeleton Theme Selection
if (!('skeletontheme' in opts)) {
let themeChoices = [
{ label: 'Skeleton', value: 'skeleton' },
{ label: 'Wintry', value: 'wintry' },
{ label: 'Modern', value: 'modern' },
{ label: 'Hamlindigo', value: 'hamlindigo' },
{ label: 'Rocket', value: 'rocket' },
{ label: 'Sahara', value: 'sahara' },
{ label: 'Gold Nouveau', value: 'gold-nouveau' },
{ label: 'Vintage', value: 'vintage' },
{ label: 'Seafoam', value: 'seafoam' },
{ label: 'Crimson', value: 'crimson' },
{ label: cyan('Custom'), value: 'custom', hint: 'Will ask for a name next' },
];
if (opts.meta.type === 'premium') {
themeChoices.unshift({ label: 'Use templates built in theme', value: 'builtin' });
}
opts.skeletontheme = await multiselect({
message: 'Select a theme (top most selection will be default):',
options: themeChoices,
required: true,
});
goodbye(opts.skeletontheme);
}
if (opts.skeletontheme.includes('custom')) {
let customName = await text({
message: 'Enter a name for your custom theme:',
placeholder: 'theme_name',
validate(value) {
if (value.length === 0) {
return 'Please enter a name for your custom theme';
}
// regex to check if value can be used as a variable name, it cannot allow hyphens
if (!/^[a-zA-Z_$][a-zA-Z_$0-9]*$/.test(value)) {
return 'Name for theme must be a valid syntax for a Javascript variable name';
}
},
});
opts.skeletontheme.pop('custom');
opts.skeletontheme.push({ custom: customName });
goodbye(customName);
}
// Additional packages to install - these can be influenced by the template selected
let packages = [
{ value: 'forms', label: 'Add Tailwind forms?', package: '@tailwindcss/forms', force: false },
{ value: 'typography', label: 'Add Tailwind typography?', package: '@tailwindcss/typography', force: false },
{ value: 'codeblocks', label: 'Add CodeBlock (installs highlight.js)?', package: 'highlight.js', force: false },
{ value: 'popups', label: 'Add Popups (installs floating-ui)?', package: '@floating-ui/dom', force: false },
// { value: 'mdsvex', label: 'Add Markdown support (installs mdsvex)?', package: 'mdsvex', force: false },
];
// Force the packages that are required by the template
packages.forEach((pkg) => {
if (opts[pkg.value] != undefined) pkg.force = true;
});
// Now we can ask the user about any options that are not forced to be installed
let optionalPackages = packages.filter((pkg) => !pkg.force);
// Get list of forced packages to display to the user
let msg = '';
packages.forEach((p) => {
if (p.force) msg += p.package + '\n';
});
if (msg.length > 0) {
msg = `\nThe following packages will be installed because they are required by the template:\n\n${msg}\nWhat other packages would you like to install:`;
} else {
msg = `\nWhat other packages would you like to install:`;
}
if (optionalPackages.length > 0) {
// check which options are set and fill the initialValues array
const packageChoices = await multiselect({
message: msg,
options: optionalPackages,
required: false,
});
goodbye(packageChoices);
if (Array.isArray(packageChoices)) {
packageChoices.forEach((value) => (opts[value] = true));
}
}
if (!('types' in opts)) {
opts.types = await select({
message: 'Add type checking with TypeScript?',
options: [
{ value: 'typescript', label: 'Yes, using TypeScript syntax' },
{ value: 'checkjs', label: 'Yes, using JavaScript with JSDoc comments' },
{ value: null, label: 'No' },
],
required: true,
});
goodbye(opts.types);
}
// Setup dev oriented packages and settings
if (
!['eslint', 'prettier', 'playwright', 'vitest', 'inspector'].every((value) => {
return Object.keys(opts).includes(value);
})
) {
const optionalInstalls = await multiselect({
message: 'What would you like setup in your project:',
// test opts for which values have been provided and prefill them
initialValues: ['eslint', 'prettier', 'playwright', 'vitest', 'inspector'].filter((value) => {
return Object.keys(opts).includes(value);
}),
options: [
{ value: 'eslint', label: 'Add ESLint for code linting?' },
{ value: 'prettier', label: 'Add Prettier for code formatting?' },
{ value: 'playwright', label: 'Add Playwright for browser testing?' },
{ value: 'vitest', label: 'Add Vitest for unit testing?' },
{ value: 'inspector', label: 'Add Svelte Inspector for quick access to your source files from the browser?' },
],
required: false,
});
goodbye(optionalInstalls);
optionalInstalls.forEach((value) => (opts[value] = true));
}
return opts;
}
main().catch(console.error);