Skip to content

Commit

Permalink
feat: tutorialkit eject command (#81)
Browse files Browse the repository at this point in the history
  • Loading branch information
Nemikolh authored Jul 4, 2024
1 parent 07d23c1 commit c802668
Show file tree
Hide file tree
Showing 14 changed files with 607 additions and 32 deletions.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default [
},
}),
{
files: ['**/env.d.ts'],
files: ['**/env.d.ts', '**/env-default.d.ts'],
rules: {
'@typescript-eslint/triple-slash-reference': 'off',

Expand Down
File renamed without changes.
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@
"@babel/types": "7.24.5",
"@clack/prompts": "^0.7.0",
"chalk": "^5.3.0",
"detect-indent": "7.0.1",
"execa": "^9.2.0",
"ignore": "^5.3.1",
"lookpath": "^1.2.2",
"which-pm": "2.2.0",
"yargs-parser": "^21.1.1"
},
"devDependencies": {
Expand Down
11 changes: 2 additions & 9 deletions packages/cli/src/commands/create/enterprise.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { parseAstroConfig, replaceArgs } from './astro-config.js';
import { generate } from './babel.js';
import { generateAstroConfig, parseAstroConfig, replaceArgs } from '../../utils/astro-config.js';
import type { CreateOptions } from './options.js';

export async function setupEnterpriseConfig(dest: string, flags: CreateOptions) {
Expand Down Expand Up @@ -38,13 +37,7 @@ export async function setupEnterpriseConfig(dest: string, flags: CreateOptions)
astroConfig,
);

const defaultExport = 'export default defineConfig';
let output = generate(astroConfig);

// add a new line
output = output.replace(defaultExport, `\n${defaultExport}`);

fs.writeFileSync(configPath, output);
fs.writeFileSync(configPath, generateAstroConfig(astroConfig));
}
}

Expand Down
17 changes: 1 addition & 16 deletions packages/cli/src/commands/create/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { pkg } from '../../pkg.js';
import { errorLabel, primaryLabel, printHelp, warnLabel } from '../../utils/messages.js';
import { generateProjectName } from '../../utils/project.js';
import { assertNotCanceled } from '../../utils/tasks.js';
import { updateWorkspaceVersions } from '../../utils/workspace-version.js';
import { setupEnterpriseConfig } from './enterprise.js';
import { initGitRepo } from './git.js';
import { installAndStart } from './install-start.js';
Expand Down Expand Up @@ -318,19 +319,3 @@ function verifyFlags(flags: CreateOptions) {
throw new Error('Cannot start project without installing dependencies.');
}
}

function updateWorkspaceVersions(dependencies: Record<string, string>, version: string) {
for (const dependency in dependencies) {
const depVersion = dependencies[dependency];

if (depVersion === 'workspace:*') {
if (process.env.TK_DIRECTORY) {
const name = dependency.split('/')[1];

dependencies[dependency] = `file:${process.env.TK_DIRECTORY}/packages/${name.replace('-', '/')}`;
} else {
dependencies[dependency] = version;
}
}
}
}
196 changes: 196 additions & 0 deletions packages/cli/src/commands/eject/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import * as prompts from '@clack/prompts';
import chalk from 'chalk';
import detectIndent from 'detect-indent';
import { execa } from 'execa';
import fs from 'node:fs';
import path from 'node:path';
import whichpm from 'which-pm';
import type { Arguments } from 'yargs-parser';
import { pkg } from '../../pkg.js';
import { generateAstroConfig, parseAstroConfig, replaceArgs } from '../../utils/astro-config.js';
import { errorLabel, primaryLabel, printHelp } from '../../utils/messages.js';
import { updateWorkspaceVersions } from '../../utils/workspace-version.js';
import { DEFAULT_VALUES, type EjectOptions } from './options.js';

interface PackageJson {
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
}

const TUTORIALKIT_VERSION = pkg.version;
const REQUIRED_DEPENDENCIES = ['@tutorialkit/runtime', '@webcontainer/api', 'nanostores', '@nanostores/react'];

export function ejectRoutes(flags: Arguments) {
if (flags._[1] === 'help' || flags.help || flags.h) {
printHelp({
commandName: `${pkg.name} eject`,
usage: '[folder] [...options]',
tables: {
Options: [
[
'--force',
`Overwrite existing files in the target directory without prompting (default ${chalk.yellow(DEFAULT_VALUES.force)})`,
],
['--defaults', 'Skip all the prompts and eject the routes using the defaults'],
],
},
});

return 0;
}

try {
return _eject(flags);
} catch (error) {
console.error(`${errorLabel()} Command failed`);

if (error.stack) {
console.error(`\n${error.stack}`);
}

process.exit(1);
}
}

async function _eject(flags: EjectOptions) {
let folderPath = flags._[1] !== undefined ? String(flags._[1]) : undefined;

if (folderPath === undefined) {
folderPath = process.cwd();
} else {
folderPath = path.resolve(process.cwd(), folderPath);
}

/**
* First we make sure that the destination has the correct files
* and that there won't be any files overwritten in the process.
*
* If there are any and `force` was not specified we abort.
*/
const { astroConfigPath, srcPath, pkgJsonPath, astroIntegrationPath, srcDestPath } = validateDestination(
folderPath,
flags.force,
);

/**
* We proceed with the astro configuration.
*
* There we must disable the default routes so that the
* new routes that we're copying will be automatically picked up.
*/
const astroConfig = await parseAstroConfig(astroConfigPath);

replaceArgs({ defaultRoutes: false }, astroConfig);

fs.writeFileSync(astroConfigPath, generateAstroConfig(astroConfig));

// we copy all assets from the `default` folder into the `src` folder
fs.cpSync(srcPath, srcDestPath, { recursive: true });

/**
* Last, we ensure that the `package.json` contains the extra dependencies.
* If any are missing we suggest to install the new dependencies.
*/
const pkgJsonContent = fs.readFileSync(pkgJsonPath, 'utf-8');
const indent = detectIndent(pkgJsonContent).indent || ' ';
const pkgJson: PackageJson = JSON.parse(pkgJsonContent);

const astroIntegrationPkgJson: PackageJson = JSON.parse(
fs.readFileSync(path.join(astroIntegrationPath, 'package.json'), 'utf-8'),
);

const newDependencies = [];

for (const dep of REQUIRED_DEPENDENCIES) {
if (!(dep in pkgJson.dependencies) && !(dep in pkgJson.devDependencies)) {
pkgJson.dependencies[dep] = astroIntegrationPkgJson.dependencies[dep];

newDependencies.push(dep);
}
}

updateWorkspaceVersions(pkgJson.dependencies, TUTORIALKIT_VERSION, (dependency) =>
REQUIRED_DEPENDENCIES.includes(dependency),
);

if (newDependencies.length > 0) {
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, undefined, indent), { encoding: 'utf-8' });

console.log(
primaryLabel('INFO'),
`New dependencies added: ${newDependencies.join(', ')}. Install the new dependencies before proceeding.`,
);

if (!flags.defaults) {
const packageManager = (await whichpm(path.dirname(pkgJsonPath))).name;

const answer = await prompts.confirm({
message: `Do you want to install those dependencies now using ${chalk.blue(packageManager)}?`,
});

if (answer === true) {
await execa(packageManager, ['install'], { cwd: folderPath, stdio: 'inherit' });
}
}
}
}

function validateDestination(folder: string, force: boolean) {
assertExists(folder);

const pkgJsonPath = assertExists(path.join(folder, 'package.json'));
const astroConfigPath = assertExists(path.join(folder, 'astro.config.ts'));
const srcDestPath = assertExists(path.join(folder, 'src'));

const astroIntegrationPath = assertExists(path.resolve(folder, 'node_modules', '@tutorialkit', 'astro'));

const srcPath = path.join(astroIntegrationPath, 'dist', 'default');

// check that there are no collision
if (!force) {
walk(srcPath, (relativePath) => {
const destination = path.join(srcDestPath, relativePath);

if (fs.existsSync(destination)) {
throw new Error(
`Eject aborted because '${destination}' would be overwritten by this command. Use ${chalk.yellow('--force')} to ignore this error.`,
);
}
});
}

return {
astroConfigPath,
astroIntegrationPath,
pkgJsonPath,
srcPath,
srcDestPath,
};
}

function assertExists(filePath: string) {
if (!fs.existsSync(filePath)) {
throw new Error(`${filePath} does not exists!`);
}

return filePath;
}

function walk(root: string, visit: (relativeFilePath: string) => void) {
function traverse(folder: string, pathPrefix: string) {
for (const filename of fs.readdirSync(folder)) {
const filePath = path.join(folder, filename);
const stat = fs.statSync(filePath);

const relativeFilePath = path.join(pathPrefix, filename);

if (stat.isDirectory()) {
traverse(filePath, relativeFilePath);
} else {
visit(relativeFilePath);
}
}
}

traverse(root, '');
}
10 changes: 10 additions & 0 deletions packages/cli/src/commands/eject/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface EjectOptions {
_: Array<string | number>;
force?: boolean;
defaults?: boolean;
}

export const DEFAULT_VALUES = {
force: false,
defaults: false,
};
8 changes: 6 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
import chalk from 'chalk';
import yargs from 'yargs-parser';
import { createTutorial } from './commands/create/index.js';
import { ejectRoutes } from './commands/eject/index.js';
import { pkg } from './pkg.js';
import { errorLabel, primaryLabel, printHelp } from './utils/messages.js';

type CLICommand = 'version' | 'help' | 'create';
type CLICommand = 'version' | 'help' | 'create' | 'eject';

const supportedCommands = new Set(['version', 'help', 'create']);
const supportedCommands = new Set<string>(['version', 'help', 'create', 'eject'] satisfies CLICommand[]);

cli();

Expand Down Expand Up @@ -53,6 +54,9 @@ async function runCommand(cmd: CLICommand, flags: yargs.Arguments): Promise<numb
case 'create': {
return createTutorial(flags);
}
case 'eject': {
return ejectRoutes(flags);
}
default: {
console.error(`${errorLabel()} Unknown command ${chalk.red(cmd)}`);
return 1;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from 'node:fs/promises';
import type { Options } from '../../../../astro/src/index.js';
import { parse, t, visit } from './babel.js';
import type { Options } from '../../../astro/src/index.js';
import { parse, t, visit, generate } from './babel.js';

export async function parseAstroConfig(astroConfigPath: string): Promise<t.File> {
const source = await fs.readFile(astroConfigPath, { encoding: 'utf-8' });
Expand All @@ -17,6 +17,17 @@ export async function parseAstroConfig(astroConfigPath: string): Promise<t.File>
return result;
}

export function generateAstroConfig(astroConfig: t.File): string {
const defaultExport = 'export default defineConfig';

let output = generate(astroConfig);

// add a new line
output = output.replace(defaultExport, `\n${defaultExport}`);

return output;
}

/**
* This function modifies the arguments provided to the tutorialkit integration in the astro
* configuration.
Expand Down Expand Up @@ -156,9 +167,9 @@ function updateObject(properties: any, object: t.ObjectExpression | undefined):

object ??= t.objectExpression([]);

for (const property of properties) {
for (const property in properties) {
const propertyInObject = object.properties.find((prop) => {
return prop.type === 'ObjectProperty' && prop.key === property;
return prop.type === 'ObjectProperty' && prop.key.type === 'Identifier' && prop.key.name === property;
}) as t.ObjectProperty | undefined;

if (!propertyInObject) {
Expand Down Expand Up @@ -191,6 +202,10 @@ function fromValue(value: any): t.Expression {
return t.numericLiteral(value);
}

if (typeof value === 'boolean') {
return t.booleanLiteral(value);
}

if (Array.isArray(value)) {
return t.arrayExpression(value.map(fromValue));
}
Expand Down
File renamed without changes.
23 changes: 23 additions & 0 deletions packages/cli/src/utils/workspace-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export function updateWorkspaceVersions(
dependencies: Record<string, string>,
version: string,
filterDependency: (dependency: string) => boolean = allowAll,
) {
for (const dependency in dependencies) {
const depVersion = dependencies[dependency];

if (depVersion === 'workspace:*' && filterDependency(dependency)) {
if (process.env.TK_DIRECTORY) {
const name = dependency.split('/')[1];

dependencies[dependency] = `file:${process.env.TK_DIRECTORY}/packages/${name.replace('-', '/')}`;
} else {
dependencies[dependency] = version;
}
}
}
}

function allowAll() {
return true;
}
Loading

0 comments on commit c802668

Please sign in to comment.