Skip to content

Commit c802668

Browse files
authored
feat: tutorialkit eject command (#81)
1 parent 07d23c1 commit c802668

File tree

14 files changed

+607
-32
lines changed

14 files changed

+607
-32
lines changed

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export default [
2222
},
2323
}),
2424
{
25-
files: ['**/env.d.ts'],
25+
files: ['**/env.d.ts', '**/env-default.d.ts'],
2626
rules: {
2727
'@typescript-eslint/triple-slash-reference': 'off',
2828

packages/cli/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,11 @@
3131
"@babel/types": "7.24.5",
3232
"@clack/prompts": "^0.7.0",
3333
"chalk": "^5.3.0",
34+
"detect-indent": "7.0.1",
3435
"execa": "^9.2.0",
3536
"ignore": "^5.3.1",
3637
"lookpath": "^1.2.2",
38+
"which-pm": "2.2.0",
3739
"yargs-parser": "^21.1.1"
3840
},
3941
"devDependencies": {

packages/cli/src/commands/create/enterprise.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import fs from 'node:fs';
22
import path from 'node:path';
3-
import { parseAstroConfig, replaceArgs } from './astro-config.js';
4-
import { generate } from './babel.js';
3+
import { generateAstroConfig, parseAstroConfig, replaceArgs } from '../../utils/astro-config.js';
54
import type { CreateOptions } from './options.js';
65

76
export async function setupEnterpriseConfig(dest: string, flags: CreateOptions) {
@@ -38,13 +37,7 @@ export async function setupEnterpriseConfig(dest: string, flags: CreateOptions)
3837
astroConfig,
3938
);
4039

41-
const defaultExport = 'export default defineConfig';
42-
let output = generate(astroConfig);
43-
44-
// add a new line
45-
output = output.replace(defaultExport, `\n${defaultExport}`);
46-
47-
fs.writeFileSync(configPath, output);
40+
fs.writeFileSync(configPath, generateAstroConfig(astroConfig));
4841
}
4942
}
5043

packages/cli/src/commands/create/index.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { pkg } from '../../pkg.js';
88
import { errorLabel, primaryLabel, printHelp, warnLabel } from '../../utils/messages.js';
99
import { generateProjectName } from '../../utils/project.js';
1010
import { assertNotCanceled } from '../../utils/tasks.js';
11+
import { updateWorkspaceVersions } from '../../utils/workspace-version.js';
1112
import { setupEnterpriseConfig } from './enterprise.js';
1213
import { initGitRepo } from './git.js';
1314
import { installAndStart } from './install-start.js';
@@ -318,19 +319,3 @@ function verifyFlags(flags: CreateOptions) {
318319
throw new Error('Cannot start project without installing dependencies.');
319320
}
320321
}
321-
322-
function updateWorkspaceVersions(dependencies: Record<string, string>, version: string) {
323-
for (const dependency in dependencies) {
324-
const depVersion = dependencies[dependency];
325-
326-
if (depVersion === 'workspace:*') {
327-
if (process.env.TK_DIRECTORY) {
328-
const name = dependency.split('/')[1];
329-
330-
dependencies[dependency] = `file:${process.env.TK_DIRECTORY}/packages/${name.replace('-', '/')}`;
331-
} else {
332-
dependencies[dependency] = version;
333-
}
334-
}
335-
}
336-
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import * as prompts from '@clack/prompts';
2+
import chalk from 'chalk';
3+
import detectIndent from 'detect-indent';
4+
import { execa } from 'execa';
5+
import fs from 'node:fs';
6+
import path from 'node:path';
7+
import whichpm from 'which-pm';
8+
import type { Arguments } from 'yargs-parser';
9+
import { pkg } from '../../pkg.js';
10+
import { generateAstroConfig, parseAstroConfig, replaceArgs } from '../../utils/astro-config.js';
11+
import { errorLabel, primaryLabel, printHelp } from '../../utils/messages.js';
12+
import { updateWorkspaceVersions } from '../../utils/workspace-version.js';
13+
import { DEFAULT_VALUES, type EjectOptions } from './options.js';
14+
15+
interface PackageJson {
16+
dependencies: Record<string, string>;
17+
devDependencies: Record<string, string>;
18+
}
19+
20+
const TUTORIALKIT_VERSION = pkg.version;
21+
const REQUIRED_DEPENDENCIES = ['@tutorialkit/runtime', '@webcontainer/api', 'nanostores', '@nanostores/react'];
22+
23+
export function ejectRoutes(flags: Arguments) {
24+
if (flags._[1] === 'help' || flags.help || flags.h) {
25+
printHelp({
26+
commandName: `${pkg.name} eject`,
27+
usage: '[folder] [...options]',
28+
tables: {
29+
Options: [
30+
[
31+
'--force',
32+
`Overwrite existing files in the target directory without prompting (default ${chalk.yellow(DEFAULT_VALUES.force)})`,
33+
],
34+
['--defaults', 'Skip all the prompts and eject the routes using the defaults'],
35+
],
36+
},
37+
});
38+
39+
return 0;
40+
}
41+
42+
try {
43+
return _eject(flags);
44+
} catch (error) {
45+
console.error(`${errorLabel()} Command failed`);
46+
47+
if (error.stack) {
48+
console.error(`\n${error.stack}`);
49+
}
50+
51+
process.exit(1);
52+
}
53+
}
54+
55+
async function _eject(flags: EjectOptions) {
56+
let folderPath = flags._[1] !== undefined ? String(flags._[1]) : undefined;
57+
58+
if (folderPath === undefined) {
59+
folderPath = process.cwd();
60+
} else {
61+
folderPath = path.resolve(process.cwd(), folderPath);
62+
}
63+
64+
/**
65+
* First we make sure that the destination has the correct files
66+
* and that there won't be any files overwritten in the process.
67+
*
68+
* If there are any and `force` was not specified we abort.
69+
*/
70+
const { astroConfigPath, srcPath, pkgJsonPath, astroIntegrationPath, srcDestPath } = validateDestination(
71+
folderPath,
72+
flags.force,
73+
);
74+
75+
/**
76+
* We proceed with the astro configuration.
77+
*
78+
* There we must disable the default routes so that the
79+
* new routes that we're copying will be automatically picked up.
80+
*/
81+
const astroConfig = await parseAstroConfig(astroConfigPath);
82+
83+
replaceArgs({ defaultRoutes: false }, astroConfig);
84+
85+
fs.writeFileSync(astroConfigPath, generateAstroConfig(astroConfig));
86+
87+
// we copy all assets from the `default` folder into the `src` folder
88+
fs.cpSync(srcPath, srcDestPath, { recursive: true });
89+
90+
/**
91+
* Last, we ensure that the `package.json` contains the extra dependencies.
92+
* If any are missing we suggest to install the new dependencies.
93+
*/
94+
const pkgJsonContent = fs.readFileSync(pkgJsonPath, 'utf-8');
95+
const indent = detectIndent(pkgJsonContent).indent || ' ';
96+
const pkgJson: PackageJson = JSON.parse(pkgJsonContent);
97+
98+
const astroIntegrationPkgJson: PackageJson = JSON.parse(
99+
fs.readFileSync(path.join(astroIntegrationPath, 'package.json'), 'utf-8'),
100+
);
101+
102+
const newDependencies = [];
103+
104+
for (const dep of REQUIRED_DEPENDENCIES) {
105+
if (!(dep in pkgJson.dependencies) && !(dep in pkgJson.devDependencies)) {
106+
pkgJson.dependencies[dep] = astroIntegrationPkgJson.dependencies[dep];
107+
108+
newDependencies.push(dep);
109+
}
110+
}
111+
112+
updateWorkspaceVersions(pkgJson.dependencies, TUTORIALKIT_VERSION, (dependency) =>
113+
REQUIRED_DEPENDENCIES.includes(dependency),
114+
);
115+
116+
if (newDependencies.length > 0) {
117+
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, undefined, indent), { encoding: 'utf-8' });
118+
119+
console.log(
120+
primaryLabel('INFO'),
121+
`New dependencies added: ${newDependencies.join(', ')}. Install the new dependencies before proceeding.`,
122+
);
123+
124+
if (!flags.defaults) {
125+
const packageManager = (await whichpm(path.dirname(pkgJsonPath))).name;
126+
127+
const answer = await prompts.confirm({
128+
message: `Do you want to install those dependencies now using ${chalk.blue(packageManager)}?`,
129+
});
130+
131+
if (answer === true) {
132+
await execa(packageManager, ['install'], { cwd: folderPath, stdio: 'inherit' });
133+
}
134+
}
135+
}
136+
}
137+
138+
function validateDestination(folder: string, force: boolean) {
139+
assertExists(folder);
140+
141+
const pkgJsonPath = assertExists(path.join(folder, 'package.json'));
142+
const astroConfigPath = assertExists(path.join(folder, 'astro.config.ts'));
143+
const srcDestPath = assertExists(path.join(folder, 'src'));
144+
145+
const astroIntegrationPath = assertExists(path.resolve(folder, 'node_modules', '@tutorialkit', 'astro'));
146+
147+
const srcPath = path.join(astroIntegrationPath, 'dist', 'default');
148+
149+
// check that there are no collision
150+
if (!force) {
151+
walk(srcPath, (relativePath) => {
152+
const destination = path.join(srcDestPath, relativePath);
153+
154+
if (fs.existsSync(destination)) {
155+
throw new Error(
156+
`Eject aborted because '${destination}' would be overwritten by this command. Use ${chalk.yellow('--force')} to ignore this error.`,
157+
);
158+
}
159+
});
160+
}
161+
162+
return {
163+
astroConfigPath,
164+
astroIntegrationPath,
165+
pkgJsonPath,
166+
srcPath,
167+
srcDestPath,
168+
};
169+
}
170+
171+
function assertExists(filePath: string) {
172+
if (!fs.existsSync(filePath)) {
173+
throw new Error(`${filePath} does not exists!`);
174+
}
175+
176+
return filePath;
177+
}
178+
179+
function walk(root: string, visit: (relativeFilePath: string) => void) {
180+
function traverse(folder: string, pathPrefix: string) {
181+
for (const filename of fs.readdirSync(folder)) {
182+
const filePath = path.join(folder, filename);
183+
const stat = fs.statSync(filePath);
184+
185+
const relativeFilePath = path.join(pathPrefix, filename);
186+
187+
if (stat.isDirectory()) {
188+
traverse(filePath, relativeFilePath);
189+
} else {
190+
visit(relativeFilePath);
191+
}
192+
}
193+
}
194+
195+
traverse(root, '');
196+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export interface EjectOptions {
2+
_: Array<string | number>;
3+
force?: boolean;
4+
defaults?: boolean;
5+
}
6+
7+
export const DEFAULT_VALUES = {
8+
force: false,
9+
defaults: false,
10+
};

packages/cli/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@
33
import chalk from 'chalk';
44
import yargs from 'yargs-parser';
55
import { createTutorial } from './commands/create/index.js';
6+
import { ejectRoutes } from './commands/eject/index.js';
67
import { pkg } from './pkg.js';
78
import { errorLabel, primaryLabel, printHelp } from './utils/messages.js';
89

9-
type CLICommand = 'version' | 'help' | 'create';
10+
type CLICommand = 'version' | 'help' | 'create' | 'eject';
1011

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

1314
cli();
1415

@@ -53,6 +54,9 @@ async function runCommand(cmd: CLICommand, flags: yargs.Arguments): Promise<numb
5354
case 'create': {
5455
return createTutorial(flags);
5556
}
57+
case 'eject': {
58+
return ejectRoutes(flags);
59+
}
5660
default: {
5761
console.error(`${errorLabel()} Unknown command ${chalk.red(cmd)}`);
5862
return 1;

packages/cli/src/commands/create/astro-config.ts renamed to packages/cli/src/utils/astro-config.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'node:fs/promises';
2-
import type { Options } from '../../../../astro/src/index.js';
3-
import { parse, t, visit } from './babel.js';
2+
import type { Options } from '../../../astro/src/index.js';
3+
import { parse, t, visit, generate } from './babel.js';
44

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

20+
export function generateAstroConfig(astroConfig: t.File): string {
21+
const defaultExport = 'export default defineConfig';
22+
23+
let output = generate(astroConfig);
24+
25+
// add a new line
26+
output = output.replace(defaultExport, `\n${defaultExport}`);
27+
28+
return output;
29+
}
30+
2031
/**
2132
* This function modifies the arguments provided to the tutorialkit integration in the astro
2233
* configuration.
@@ -156,9 +167,9 @@ function updateObject(properties: any, object: t.ObjectExpression | undefined):
156167

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

159-
for (const property of properties) {
170+
for (const property in properties) {
160171
const propertyInObject = object.properties.find((prop) => {
161-
return prop.type === 'ObjectProperty' && prop.key === property;
172+
return prop.type === 'ObjectProperty' && prop.key.type === 'Identifier' && prop.key.name === property;
162173
}) as t.ObjectProperty | undefined;
163174

164175
if (!propertyInObject) {
@@ -191,6 +202,10 @@ function fromValue(value: any): t.Expression {
191202
return t.numericLiteral(value);
192203
}
193204

205+
if (typeof value === 'boolean') {
206+
return t.booleanLiteral(value);
207+
}
208+
194209
if (Array.isArray(value)) {
195210
return t.arrayExpression(value.map(fromValue));
196211
}

0 commit comments

Comments
 (0)