Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[cli] Refactor setup #4597

Merged
merged 5 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions packages/create-toolpad-app/src/core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import chalk from 'chalk';
import { execa } from 'execa';
import type { GenerateProjectOptions } from './types';
import generateProject from './generateProject';
import writeFiles from './writeFiles';
import { findCtaPackageJson, getPackageManager } from './package';

export async function scaffoldCoreProject(options: GenerateProjectOptions): Promise<void> {
// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(
`${chalk.cyan('info')} - Creating Toolpad Core project in ${chalk.cyan(options.absolutePath)}`,
);
// eslint-disable-next-line no-console
console.log();

const pkg = await findCtaPackageJson();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better to remove this call and pass the version as an option.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we remove the call, how would we set the default value of the version in case nothing is passed in the version option?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is no sensible default, then you make the option mandatory

if (!options.coreVersion) {
options.coreVersion = pkg.version;
}

const packageManager = options.packageManager ?? getPackageManager();

const files = generateProject(options);
await writeFiles(options.absolutePath, files);

if (options.install) {
// eslint-disable-next-line no-console
console.log(`${chalk.cyan('info')} - Installing dependencies`);
// eslint-disable-next-line no-console
console.log();

await execa(packageManager, ['install'], {
stdio: 'inherit',
cwd: options.absolutePath,
});

// eslint-disable-next-line no-console
console.log();
}

// eslint-disable-next-line no-console
console.log(
`${chalk.green('success')} - Created Toolpad Core project at ${chalk.cyan(options.absolutePath)}`,
);
// eslint-disable-next-line no-console
console.log();

if (options.auth) {
// eslint-disable-next-line no-console
console.log(
`${chalk.cyan('info')} - Bootstrapped ${chalk.cyan('env.local')} with empty values. See https://authjs.dev/getting-started on how to add your credentials.`,
);
// eslint-disable-next-line no-console
console.log();
}
}
263 changes: 38 additions & 225 deletions packages/create-toolpad-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,220 +1,32 @@
#!/usr/bin/env node

import * as fs from 'fs/promises';
import { constants as fsConstants } from 'fs';
import path from 'path';
import yargs from 'yargs';
import { input, confirm, select, checkbox } from '@inquirer/prompts';
import chalk from 'chalk';
import { errorFrom } from '@toolpad/utils/errors';
import { execa } from 'execa';
import { satisfies } from 'semver';
import { readJsonFile } from '@toolpad/utils/fs';
import invariant from 'invariant';
import { bashResolvePath } from '@toolpad/utils/cli';
import type { SupportedAuthProvider } from '@toolpad/core/SignInPage';
import generateProject from './generateProject';
import generateStudioProject from './generateStudioProject';
import writeFiles from './writeFiles';
import { bashResolvePath } from '@toolpad/utils/cli';
import { downloadAndExtractExample } from './examples';
import type { PackageJson } from './templates/packageType';
import type {
SupportedFramework,
SupportedRouter,
PackageManager,
GenerateProjectOptions,
} from './types';

/**
* Find package.json of the create-toolpad-app package
*/
async function findCtaPackageJson() {
const ctaPackageJsonPath = path.resolve(__dirname, '../package.json');
const content = await fs.readFile(ctaPackageJsonPath, 'utf8');
const packageJson = JSON.parse(content);
return packageJson;
}
import type { SupportedFramework, SupportedRouter, GenerateProjectOptions } from './types';
import { findCtaPackageJson, getPackageManager } from './package';
import { scaffoldCoreProject } from './core';
import { scaffoldStudioProject } from './studio';
import { validatePath } from './validation';

declare global {
interface Error {
code?: unknown;
}
}

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

if (userAgent) {
if (userAgent.startsWith('yarn')) {
return 'yarn';
}
if (userAgent.startsWith('pnpm')) {
return 'pnpm';
}
if (userAgent.startsWith('npm')) {
return 'npm';
}
}
return 'npm';
}

// From https://github.com/vercel/next.js/blob/canary/packages/create-next-app/helpers/is-folder-empty.ts
async function isFolderEmpty(pathDir: string): Promise<boolean> {
const validFiles = [
'.DS_Store',
'.git',
'.gitattributes',
'.gitignore',
'.gitlab-ci.yml',
'.hg',
'.hgcheck',
'.hgignore',
'.idea',
'.npmignore',
'.travis.yml',
'LICENSE',
'Thumbs.db',
'docs',
'mkdocs.yml',
'npm-debug.log',
'yarn-debug.log',
'yarn-error.log',
'yarnrc.yml',
'.yarn',
];

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) {
return false;
}
return true;
}

// Detect the package manager
const packageManager = getPackageManager();

const validatePath = async (relativePath: string): Promise<boolean | string> => {
const absolutePath = bashResolvePath(relativePath);

try {
await fs.access(absolutePath, fsConstants.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.cyan(
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 scaffoldStudioProject = async (absolutePath: string, installFlag: boolean): Promise<void> => {
// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(
`${chalk.cyan('info')} - Creating Toolpad Studio project in ${chalk.cyan(absolutePath)}`,
);
// eslint-disable-next-line no-console
console.log();
const options: GenerateProjectOptions = {
name: path.basename(absolutePath),
absolutePath,
projectType: 'studio',
packageManager,
};
const files = generateStudioProject(options);

await writeFiles(absolutePath, files);

if (installFlag) {
// eslint-disable-next-line no-console
console.log(`${chalk.cyan('info')} - Installing dependencies`);
// eslint-disable-next-line no-console
console.log();

await execa(packageManager, ['install'], { 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();
}
};

const scaffoldCoreProject = async (options: GenerateProjectOptions): Promise<void> => {
// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(
`${chalk.cyan('info')} - Creating Toolpad Core project in ${chalk.cyan(options.absolutePath)}`,
);
// eslint-disable-next-line no-console
console.log();
const pkg = await findCtaPackageJson();
if (!options.coreVersion) {
options.coreVersion = pkg.version;
}
const files = generateProject(options);
await writeFiles(options.absolutePath, files);

if (options.install) {
// eslint-disable-next-line no-console
console.log(`${chalk.cyan('info')} - Installing dependencies`);
// eslint-disable-next-line no-console
console.log();

await execa(packageManager, ['install'], { stdio: 'inherit', cwd: options.absolutePath });

// eslint-disable-next-line no-console
console.log();
}
// eslint-disable-next-line no-console
console.log(
`${chalk.green('success')} - Created Toolpad Core project at ${chalk.cyan(options.absolutePath)}`,
);
// eslint-disable-next-line no-console
console.log();

if (options.auth) {
// eslint-disable-next-line no-console
console.log(
`${chalk.cyan('info')} - Bootstrapped ${chalk.cyan('env.local')} with empty values. See https://authjs.dev/getting-started on how to add your credentials.`,
);
// eslint-disable-next-line no-console
console.log();
}
};

// Run the CLI interaction with Inquirer.js
const run = async () => {
const pkgJson: PackageJson = (await readJsonFile(
path.resolve(__dirname, `../package.json`),
)) as any;
const pkgJson = await findCtaPackageJson();
const packageManager = getPackageManager();

invariant(pkgJson.engines?.node, 'Missing node version in package.json');

// check the node version before create
if (!satisfies(process.version, pkgJson.engines.node)) {
// eslint-disable-next-line no-console
console.log(
Expand Down Expand Up @@ -259,27 +71,29 @@ const run = async () => {
const example = args.example as string;

if (pathArg) {
const pathValidOrError = await validatePath(pathArg);
if (typeof pathValidOrError === 'string') {
// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(pathValidOrError);
// eslint-disable-next-line no-console
console.log();
process.exit(1);
if (!example) {
const pathValidOrError = await validatePath(pathArg, true);
if (typeof pathValidOrError === 'string') {
// eslint-disable-next-line no-console
console.log();
// eslint-disable-next-line no-console
console.log(pathValidOrError);
// eslint-disable-next-line no-console
console.log();
process.exit(1);
}
} else {
await validatePath(pathArg);
}
}
let projectPath = pathArg;

let projectPath = pathArg;
if (!pathArg) {
projectPath = await input({
message: example
? `Enter path of directory to download example "${chalk.cyan(example)}" into`
: 'Enter path of directory to bootstrap new app',
// This check is only necessary if an empty app is being bootstrapped,
// not if an example is being downloaded.
validate: example ? () => true : validatePath,
validate: (pathInput) => validatePath(pathInput, !example),
default: '.',
});
}
Expand All @@ -289,16 +103,11 @@ const run = async () => {
let hasNodemailerProvider = false;
let hasPasskeyProvider = false;

// If the user has provided an example, download and extract it
if (example) {
await downloadAndExtractExample(absolutePath, example);
}

// If the studio flag is set, create a new project with Toolpad Studio
else if (studioFlag) {
await scaffoldStudioProject(absolutePath, installFlag);
} else if (studioFlag) {
await scaffoldStudioProject(absolutePath, installFlag, packageManager);
} else {
// Otherwise, create a new project with Toolpad Core
const frameworkOption: SupportedFramework = await select({
message: 'Which framework would you like to use?',
default: 'nextjs',
Expand Down Expand Up @@ -367,7 +176,7 @@ const run = async () => {
hasPasskeyProvider = authProviderOptions?.includes('passkey');
}

const options = {
const options: GenerateProjectOptions = {
name: path.basename(absolutePath),
absolutePath,
coreVersion: args.coreVersion,
Expand All @@ -379,18 +188,22 @@ const run = async () => {
hasCredentialsProvider: authProviderOptions?.includes('credentials'),
hasNodemailerProvider,
hasPasskeyProvider,
packageManager,
};

await scaffoldCoreProject(options);
}

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)}\n`
: '';
let changeDirectoryInstruction = '';
if (example) {
if (!path.relative(process.cwd(), absolutePath)) {
changeDirectoryInstruction = ` cd ./${example}\n`;
} else {
changeDirectoryInstruction = ` cd ${path.basename(absolutePath)}/${example}\n`;
}
} else if (path.relative(process.cwd(), absolutePath)) {
changeDirectoryInstruction = ` cd ${path.relative(process.cwd(), absolutePath)}\n`;
}

const installInstruction = example || !installFlag ? ` ${packageManager} install\n` : '';

Expand Down
Loading
Loading