Skip to content

Commit

Permalink
Add folder as argument to create-toolpad-app (#1795)
Browse files Browse the repository at this point in the history
Co-authored-by: Jan Potoms <2109932+Janpot@users.noreply.github.com>
  • Loading branch information
bharatkashyap and Janpot authored Apr 20, 2023
1 parent d2e0767 commit 2e7c0b2
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 117 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ MUI Toolpad is in its alpha stages of development. Feel free to run this applica
Run:

```sh
npx create-toolpad-app
npx create-toolpad-app my-app
# or
yarn create toolpad-app
yarn create toolpad-app my-app
# or
pnpm create toolpad-app
pnpm create toolpad-app my-app
```

## Documentation
Expand Down
289 changes: 176 additions & 113 deletions packages/create-toolpad-app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,76 @@

import * as fs from 'fs/promises';
import path from 'path';
import yargs from 'yargs';

type PackageManager = 'npm' | 'pnpm' | 'yarn';

type Require<T, K extends keyof T> = T & { [P in K]-?: T[P] };

type Ensure<U, K extends PropertyKey> = K extends keyof U ? Require<U, K> : U & Record<K, unknown>;

declare global {
interface Error {
code?: unknown;
}
}
/**
* Type aware version of Object.protoype.hasOwnProperty.
* See https://fettblog.eu/typescript-hasownproperty/
*/

function hasOwnProperty<X extends {}, Y extends PropertyKey>(obj: X, prop: Y): obj is Ensure<X, Y> {
return obj.hasOwnProperty(prop);
}

/**
* Limits the length of a string and adds ellipsis if necessary.
*/

function truncate(str: string, maxLength: number, dots: string = '...') {
if (str.length <= maxLength) {
return str;
}
return str.slice(0, maxLength) + dots;
}

/**
* Creates a javascript `Error` from an unkown value if it's not already an error.
* Does a best effort at inferring a message. Intended to be used typically in `catch`
* blocks, as there is no way to enforce only `Error` objects being thrown.
*
* ```
* try {
* // ...
* } catch (rawError) {
* const error = errorFrom(rawError);
* console.assert(error instancof Error);
* }
* ```
*/

function errorFrom(maybeError: unknown): Error {
if (maybeError instanceof Error) {
return maybeError;
}

if (
typeof maybeError === 'object' &&
maybeError &&
hasOwnProperty(maybeError, 'message') &&
typeof maybeError.message! === 'string'
) {
return new Error(maybeError.message, { cause: maybeError });
}

if (typeof maybeError === 'string') {
return new Error(maybeError, { cause: maybeError });
}

const message = truncate(JSON.stringify(maybeError), 500);
return new Error(message, { cause: maybeError });
}

function getPackageManager(): PackageManager {
const userAgent = process.env.npm_config_user_agent;

Expand All @@ -24,8 +91,7 @@ function getPackageManager(): PackageManager {

// From https://github.com/vercel/next.js/blob/canary/packages/create-next-app/helpers/is-folder-empty.ts

async function isFolderEmpty(root: string, name: string): Promise<boolean> {
const { default: chalk } = await import('chalk');
async function isFolderEmpty(pathDir: string): Promise<boolean> {
const validFiles = [
'.DS_Store',
'.git',
Expand All @@ -49,36 +115,14 @@ async function isFolderEmpty(root: string, name: string): Promise<boolean> {
'.yarn',
];

const conflicts = await fs.readdir(root);
const conflicts = await fs.readdir(pathDir);

conflicts
.filter((file) => !validFiles.includes(file))
// Support IntelliJ IDEA-based editors
.filter((file) => !/\.iml$/.test(file));

if (conflicts.length > 0) {
// eslint-disable-next-line no-console
console.log(`The directory ${chalk.green(name)} contains files that could conflict:`);
// eslint-disable-next-line no-console
console.log();
for (const file of conflicts) {
try {
// eslint-disable-next-line no-await-in-loop
const stats = await fs.lstat(path.join(root, file));
if (stats.isDirectory()) {
// eslint-disable-next-line no-console
console.log(` ${chalk.blue(file)}/`);
} else {
// eslint-disable-next-line no-console
console.log(` ${file}`);
}
} catch {
// eslint-disable-next-line no-console
console.log(` ${file}`);
}
}
// eslint-disable-next-line no-console
console.log();
return false;
}
return true;
Expand All @@ -87,116 +131,137 @@ async function isFolderEmpty(root: string, name: string): Promise<boolean> {
// Detect the package manager
const packageManager = getPackageManager();

// Install the dependencies
const installDeps = async (projectName: string, cwd: string) => {
const { execaCommand } = await import('execa');
const validatePath = async (relativePath: string): Promise<boolean | string> => {
const { default: chalk } = await import('chalk');

const absolutePath = path.join(process.cwd(), relativePath);

try {
await fs.access(absolutePath, fs.constants.F_OK);

// Directory exists, verify if it's empty to proceed
if (await isFolderEmpty(absolutePath)) {
return true;
}
return `${chalk.red('error')} - The directory at ${chalk.blue(
absolutePath,
)} contains files that could conflict. Either use a new directory, or remove conflicting files.`;
} catch (rawError: unknown) {
// Directory does not exist, create it
const error = errorFrom(rawError);
if (error.code === 'ENOENT') {
await fs.mkdir(absolutePath, { recursive: true });
return true;
}
// Unexpected error, let it bubble up and crash the process
throw error;
}
};

// Create a new `package.json` file and install dependencies
const scaffoldProject = async (absolutePath: string): Promise<void> => {
const { default: chalk } = await import('chalk');
const { execaCommand } = await import('execa');

// eslint-disable-next-line no-console
console.log(`${chalk.blue('info')} - Installing dependencies`);
console.log();
// eslint-disable-next-line no-console
console.log(`${chalk.blue('info')} - Creating Toolpad project in ${chalk.blue(absolutePath)}`);
// eslint-disable-next-line no-console
console.log();

const packageJson = {
name: path.basename(absolutePath),
version: '0.1.0',
private: true,
scripts: {
dev: 'toolpad dev',
build: 'toolpad build',
start: 'toolpad start',
},
};

await fs.writeFile(path.join(absolutePath, 'package.json'), JSON.stringify(packageJson, null, 2));

// eslint-disable-next-line no-console
console.log(
`${chalk.blue('info')} - Installing the following dependencies: ${chalk.magenta(
'@mui/toolpad',
)}`,
);
// eslint-disable-next-line no-console
console.log();

const installVerb = packageManager === 'yarn' ? 'add' : 'install';
const command = `${packageManager} ${installVerb} @mui/toolpad`;
await execaCommand(command, { stdio: 'inherit', cwd: path.join(cwd, projectName) });
await execaCommand(command, { stdio: 'inherit', cwd: absolutePath });

// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(`${chalk.green('success')} - Dependencies installed successfully!`);
// eslint-disable-next-line no-console
console.log();
};

// Create a new directory and initialize a new project
const scaffoldProject = async (projectName: string, cwd: string): Promise<string | null> => {
// Run the CLI interaction with Inquirer.js
const run = async () => {
const { default: chalk } = await import('chalk');
const { default: inquirer } = await import('inquirer');
// eslint-disable-next-line no-console
console.log(`Creating a new MUI Toolpad project in ${chalk.blue(projectName)}`);
// eslint-disable-next-line no-console
console.log();
try {
await fs.mkdir(projectName);

const packageJson = {
name: projectName,
version: '0.1.0',
private: true,
scripts: {
dev: 'toolpad dev',
build: 'toolpad build',
start: 'toolpad start',
},
};

await fs.writeFile(`./${projectName}/package.json`, JSON.stringify(packageJson, null, 2));
return projectName;
} catch (error) {
// Directory exists, verify if it is empty to continue
if (!(await isFolderEmpty(path.join(cwd, projectName), projectName))) {
// eslint-disable-next-line no-console
console.log('Either try using a new directory name, or remove the files listed above.');

const args = await yargs(process.argv.slice(2))
.scriptName('create-toolpad-app')
.usage('$0 [path]')
.positional('path', {
type: 'string',
describe: 'The path where the Toolpad project directory will be created',
})
.help().argv;

const pathArg = args._?.[0] as string;

if (pathArg) {
const pathValidOrError = await validatePath(pathArg);
if (typeof pathValidOrError === 'string') {
// eslint-disable-next-line no-console
console.log();
return null;
}
try {
await fs.access(projectName);

const installDependenciesConsent = await inquirer.prompt([
{
type: 'confirm',
name: 'installInExisting',
message: `The following dependencies will be installed: ${chalk.magentaBright(
'@mui/toolpad',
)}. Do you want to continue?`,
default: false,
},
]);

if (installDependenciesConsent.installInExisting) {
return projectName;
}
console.error(`${chalk.red('error')} - Dependencies are required to be installed.`);
// eslint-disable-next-line no-console
console.log();
process.exit(1);
} catch (err) {
console.error(
`Unable to create directory ${chalk.red(projectName)}. Please provide a different name.`,
);
console.log(pathValidOrError);
// eslint-disable-next-line no-console
console.log();
process.exit(1);
}
return null;
}
};

// Run the CLI interaction with Inquirer.js
const run = async () => {
const { default: chalk } = await import('chalk');
const { default: inquirer } = await import('inquirer');
let projectName;
const cwd = process.cwd();
do {
// eslint-disable-next-line no-await-in-loop
const name = await inquirer.prompt([
{
type: 'input',
name: 'projectName',
message: 'Enter the name of your project:',
default: 'my-toolpad-app',
},
]);

// eslint-disable-next-line no-await-in-loop
projectName = await scaffoldProject(name.projectName, cwd);
} while (!projectName);

await installDeps(projectName, cwd);

const message = `\nRun the following to get started: \n\n ${chalk.magentaBright(
`cd ${projectName} && ${packageManager}${packageManager === 'yarn' ? '' : ' run'} dev`,
)}\n`;
const questions = [
{
type: 'input',
name: 'path',
message: 'Enter path for new project directory:',
validate: (input: string) => validatePath(input),
when: !pathArg,
default: '.',
},
];

const answers = await inquirer.prompt(questions);

const absolutePath = path.join(process.cwd(), answers.path || pathArg);

await scaffoldProject(absolutePath);

const changeDirectoryInstruction =
/* `path.relative` is truth-y if the relative path
* between `absolutePath` and `process.cwd()`
* is not empty
*/
path.relative(process.cwd(), absolutePath)
? `cd ${path.relative(process.cwd(), absolutePath)} && `
: '';

const message = `Run the following to get started: \n\n${chalk.magentaBright(
`${changeDirectoryInstruction}${packageManager}${packageManager === 'yarn' ? '' : ' run'} dev`,
)}`;
// eslint-disable-next-line no-console
console.log(message);
// eslint-disable-next-line no-console
Expand All @@ -207,5 +272,3 @@ run().catch((error) => {
console.error(error.message);
process.exit(1);
});

// Define the questions to be asked during the CLI interaction
1 change: 1 addition & 0 deletions packages/create-toolpad-app/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"target": "es2020",
"module": "commonjs",
"moduleResolution": "node16",
"lib": ["esnext"],
"isolatedModules": true,
"strict": true,
"resolveJsonModule": true,
Expand Down
2 changes: 1 addition & 1 deletion test/integration/editor/new.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ test('can create/delete page', async ({ page, localApp }) => {

await editorModel.createPage('somePage');

const pageMenuItem = editorModel.hierarchyItem('pages', 'somePage');
const pageMenuItem = editorModel.getHierarchyItem('pages', 'somePage');
const pageFolder = path.resolve(localApp.dir, './toolpad/pages/somePage');
const pageFile = path.resolve(pageFolder, './page.yml');

Expand Down

0 comments on commit 2e7c0b2

Please sign in to comment.